@nolly-cafe/electron-router
v1.1.0
Published
Next.js-style file-based router for Electron + Webpack + React apps
Maintainers
Readme
Electron Router
Next.js-style file-based router for Electron + Webpack + React.
Provides route rendering, nested layouts, dynamic/catch-all routes, custom 404, and navigation hooks - fully compatible with React 18+.
Features
- File-based routing like Next.js
- Nested layout composition (automatic parent layout wrapping)
- Dynamic routes
[param]->:param - Catch-all routes
[...slug]->[[...slug]] - Optional grouping folders
(group)- stripped from URL - Custom
not-found.tsxpage convention <Link>and<Redirect>componentsuseNavigate(),useParams(),useCurrentRoute()hooks- Works with Electron Forge + Webpack
- No bundled React - uses your app's own copy
Installation
npm install @nolly-cafe/electron-router
# or
pnpm add @nolly-cafe/electron-router # for simplicity, this README uses pnpm in examples
# or
yarn add @nolly-cafe/electron-routerPeer dependencies (already present in your Electron app):
pnpm add react react-dom electronSetup
1. Webpack preload config
Create webpack.preload.config.js at your project root:
module.exports = {
target: 'electron-preload',
devtool: 'source-map',
externals: {
'fs': 'commonjs fs',
'path': 'commonjs path',
'node:fs': 'commonjs fs',
'node:path': 'commonjs path',
},
}2. Webpack renderer config
In your webpack.renderer.config.js/ts, add a React alias and historyApiFallback so hard-reloads work on any route:
const path = require('path')
module.exports = {
resolve: {
alias: {
// Force a single React copy - prevents "invalid hook call" with symlinked packages
'react': path.resolve('./node_modules/react'),
'react-dom': path.resolve('./node_modules/react-dom'),
},
},
}3. Forge config
In your forge.config.ts, reference both configs and enable historyApiFallback:
new WebpackPlugin({
mainConfig,
renderer: {
config: rendererConfig,
entryPoints: [
{
html: './src/index.html',
js: './src/renderer.ts',
name: 'main_window',
preload: {
js: './src/preload.ts',
config: './webpack.preload.config.js', // <- required
},
},
],
},
devServer: {
historyApiFallback: {
rewrites: [
{ from: /.*/, to: '/main_window/index.html' }, // <- required for hard-reload on any route
],
},
},
}),4. BrowserWindow
Disable sandbox so the preload can access fs and path at runtime:
new BrowserWindow({
webPreferences: {
preload: MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY,
contextIsolation: true, // keep
sandbox: false, // required - allows preload to use Node.js APIs
},
})5. Preload entry
// src/preload.ts
import '@nolly-cafe/electron-router/preload'6. Renderer entry
// src/renderer.ts
import { registerContext } from '@nolly-cafe/electron-router'
registerContext(require.context('./app', true, /(page|layout|not-found)\.tsx$/))
import './app'The regex must include all file conventions you use (
page,layout,not-found). Webpack uses it statically - files not matched here won't be bundled.
7. App entry
// src/app/index.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { App } from '@nolly-cafe/electron-router'
ReactDOM.createRoot(document.body).render(
<React.StrictMode>
<App />
</React.StrictMode>
)File Structure
src/app/
├─ index.tsx <- app entry (mounts <App />)
├─ index.css <- global styles (optional)
├─ not-found.tsx <- custom 404 page (optional)
├─ layout.tsx <- root layout (optional)
├─ page.tsx <- renders "/"
├─ about/
│ └─ page.tsx <- renders "/about"
├─ (legal)/
│ └─ page.tsx <- renders "/legal" - (legal) stripped from URL
├─ other/
│ └─ [id]/
│ └─ page.tsx <- renders "/other/:id"
└─ blog/
└─ [...slug]/
└─ page.tsx <- renders "/blog/*"Conventions
| File / Pattern | Behavior |
| ----------------- | -------------------------------------------- |
| page.tsx | Page component for the current folder |
| layout.tsx | Wraps all descendant pages automatically |
| not-found.tsx | Rendered on unmatched routes (custom 404) |
| (group)/ | Folder name stripped from final URL |
| [param]/ | Dynamic segment -> available via useParams |
| [...slug]/ | Catch-all segment -> array via useParams |
Pages and layouts must use default exports.
Navigation
<Link>
import { Link } from '@nolly-cafe/electron-router'
<Link to="/about">About</Link>
<Link to="/other/123">Item 123</Link>
<Link to="../profile">Back to Profile</Link> // relative path
<Link to="/dashboard" replace>Dashboard</Link><Redirect>
import { Redirect } from '@nolly-cafe/electron-router'
<Redirect to="/dashboard" />
<Redirect to="/dashboard" replace />useNavigate
import { useNavigate } from '@nolly-cafe/electron-router'
const navigate = useNavigate()
navigate('/dashboard')
navigate('/dashboard', { replace: true })
navigate(-1) // go backHooks
useParams
import { useParams } from '@nolly-cafe/electron-router'
const { id } = useParams() // "/other/123" -> { id: "123" }
const { slug } = useParams() // "/blog/a/b" -> { slug: ["a", "b"] }useCurrentRoute
import { useCurrentRoute } from '@nolly-cafe/electron-router'
const route = useCurrentRoute() // RouteNode | nullCustom 404
Create src/app/not-found.tsx - it's picked up automatically:
import React from 'react'
import { Link } from '@nolly-cafe/electron-router'
export default function NotFound() {
return (
<div>
<h1>404 - Page not found</h1>
<Link to="/">Go home</Link>
</div>
)
}If no not-found.tsx exists, a built-in minimal 404 page is shown (styled after Next.js).
Live Reload Behavior
| Action | Behavior |
| --------------------------- | ------------------------------------------- |
| Edit existing page.tsx | ✅ HMR - instant, no restart needed |
| Edit existing layout.tsx | ✅ HMR - instant, no restart needed |
| Add or delete a route file | ❌ Requires app restart (pnpm start) |
Utilities
resolvePath
import { resolvePath } from '@nolly-cafe/electron-router'
resolvePath('../profile', '/dashboard/settings')
// -> "/dashboard/profile"Types
import type { RouteNode } from '@nolly-cafe/electron-router'
interface RouteNode {
path: string
page: React.FC<any>
layout?: React.FC<any>
paramNames?: string[]
children?: RouteNode[]
parent?: RouteNode
}License
MIT © 2026 Nolly
