npm package: @nolly-cafe/electron-router
Description: Next.js-style file-based router for Electron + Webpack + React apps
Author: Nolly
Latest version: 1.1.0

# Electron Router

[![npm version](https://img.shields.io/npm/v/@nolly-cafe/electron-router.svg)](https://www.npmjs.com/package/@nolly-cafe/electron-router)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Downloads](https://img.shields.io/npm/dm/%40nolly-cafe%2Felectron-router)](https://www.npmjs.com/package/@nolly-cafe/electron-router)
[![React Peer](https://img.shields.io/npm/dependency-version/%40nolly-cafe%2Felectron-router/peer/react)](https://reactjs.org/)
[![Electron Compatible](https://img.shields.io/badge/electron-%5E40-brightgreen)](https://www.electronjs.org/)

> 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.tsx` page convention
- `<Link>` and `<Redirect>` components
- `useNavigate()`, `useParams()`, `useCurrentRoute()` hooks
- Works with **Electron Forge + Webpack**
- No bundled React - uses your app's own copy

---

## Installation

```bash
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-router
````

**Peer dependencies** (already present in your Electron app):

```bash
pnpm add react react-dom electron
```

---

## Setup

### 1. Webpack preload config

Create `webpack.preload.config.js` at your project root:

```js
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:

```js
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`:

```ts
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:

```ts
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

```ts
// src/preload.ts
import '@nolly-cafe/electron-router/preload'
```

### 6. Renderer entry

```ts
// 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

```tsx
// 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>`

```tsx
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>`

```tsx
import { Redirect } from '@nolly-cafe/electron-router'

<Redirect to="/dashboard" />
<Redirect to="/dashboard" replace />
```

### `useNavigate`

```tsx
import { useNavigate } from '@nolly-cafe/electron-router'

const navigate = useNavigate()

navigate('/dashboard')
navigate('/dashboard', { replace: true })
navigate(-1) // go back
```

---

## Hooks

### `useParams`

```tsx
import { useParams } from '@nolly-cafe/electron-router'

const { id } = useParams()           // "/other/123" -> { id: "123" }
const { slug } = useParams()         // "/blog/a/b"  -> { slug: ["a", "b"] }
```

### `useCurrentRoute`

```tsx
import { useCurrentRoute } from '@nolly-cafe/electron-router'

const route = useCurrentRoute()      // RouteNode | null
```

---

## Custom 404

Create `src/app/not-found.tsx` - it's picked up automatically:

```tsx
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`

```ts
import { resolvePath } from '@nolly-cafe/electron-router'

resolvePath('../profile', '/dashboard/settings')
// -> "/dashboard/profile"
```

---

## Types

```ts
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