Nextjs To Electron

Other

Use when converting or migrating a Next.js (App Router) web app into an Electron desktop app — packaging a static-export site as a Windows desktop/portable/unpacked build, especially for fully-offline or intranet machines that lack the WebView2 runtime (where Tauri fails), or adding language persistence, window-state, single-instance, system tray, or GitHub Actions Electron builds. Covers the file:// white-screen trap, next-intl static-export i18n, custom app:// protocol, and electron-builder packaging. Also matches "nextjs2electron".

Install

openclaw skills install @rockbenben/nextjs-to-electron

Next.js → Electron Desktop App

Overview

Wrap a client-side Next.js (App Router) static export in a thin Electron shell. Electron bundles its own Chromium, so unlike Tauri it needs no system WebView2 — the reason to pick it for locked-down/intranet machines. The React code stays untouched; all desktop behavior lives in a electron/ main-process layer that serves the static export.

Core principle: Serve the export over a custom app:// protocol, NOT file://. Almost all migration pain is path/origin resolution, not React — and file:// silently breaks both. (See electron-files.md for all copy-paste code.)

When to use

  • The app is (or can be) a static export (output: "export") — diff/format/calculator/viewer tools, no SSR or API-at-runtime.
  • You want a Windows desktop build that runs fully offline, especially where WebView2 is absent (old LTSC/Server, air-gapped intranet) so Tauri won't launch.

Do NOT use when the app needs a live Node server at runtime (real API routes, SSR, server actions). Prefer nextjs-to-tauri when the targets have WebView2 and you want a ~3–10 MB exe with auto-update — Electron is ~150 MB because it ships Chromium.

This playbook targets Windows (--win dir/portable, SmartScreen, WebView2-less boxes). macOS/Linux packaging (.dmg/.AppImage, notarization, codesigning) is out of scope.

Procedure

Do these in order. Full code is in electron-files.md. The web source under src/, messages/, next.config.* stays untouched.

  1. Confirm the export is client-side and check its shape. yarn build, then ls out/ — you'll see flat files (index.html, en.html, zh.html) and _next/. trailingSlash: false (flat files) is fine; the resolver handles both layouts (gotcha #2).

  2. Add the toolchain: yarn add -D electron electron-builder. In package.json add "main": "electron/main.js" and scripts: electron:dev (a node launcher that sets ELECTRON_DEV=1 and spawns electron — avoids a cross-env dep), electron:build (next build && electron-builder --win dir), test:electron (list the test files explicitly — node --test electron/resolvePath.test.js electron/store.test.js …; prefer this to the glob electron/*.test.js, which PowerShell won't expand in CI, so it leans entirely on node --test's own glob (Node ≥ 21) — an explicit list is portable across every shell and Node version).

  3. Create the electron/ layer (copy from electron-files.md). Split into pure modules that must NOT require("electron") (so node --test runs them) and Electron-bound ones:

    • constants.jsSCHEME="app", LOCALES[]. resolvePath.jsresolveAssetPath(outDir, pathname, exists) with .html/index.html404.html fallback. store.js — dependency-free JSON store in userData. locale.jsstartUrl/parseLocale/trackLocale. window-state.jscreateWindowStateKeeper. (All pure → unit-tested.)
    • protocol.js, tray.js, main.js — the only files that import electron.
  4. Load via the app:// protocol, never file:// (gotcha #1). Before app.ready: protocol.registerSchemesAsPrivileged([{scheme:"app", privileges:{standard:true, secure:true, supportFetchAPI:true}}]). After ready: protocol.handle("app", …) mapping app://local/<path>resolveAssetPathnet.fetch(pathToFileURL(file)) (with a .catch returning a 404 Response). Load app://local/ + the saved locale.

  5. Wire the desktop features in main.js (all passive — zero renderer changes, gotchas #3, #4): single-instance lock + second-instance focus; restore window bounds from the store; trackLocale(win, store) persists locale on did-navigate; tray with close-to-tray via an app.isQuitting flag.

  6. Package (gotcha #6). electron-builder.yml: extraResources maps out → out and build/icon.png → icon.png; main.js reads them at process.resourcesPath/out and /icon.png when packaged. Choose target: dir (unpacked folder — runs TextDiff.exe directly, fast) vs portable (self-extractor, re-unzips to %TEMP% every launch, slower) — gotcha #11.

  7. CI (.github/workflows/electron.yml): windows-latest → setup-node → yarn install --frozen-lockfileyarn test:electronyarn electron:build → upload the build as an artifact. If you keep the desktop work on a side branch, also put a dispatch-only copy on the default branch or the manual button won't appear (gotcha #10).

  8. Verify: yarn test:electron (pure modules), then yarn electron:build and run the unpacked TextDiff.exe. GUI/visual QA (no white screen, i18n, persistence, tray) must be done by a human on a real (ideally WebView2-less) Windows box — a headless agent can only confirm the process launches without crashing.

Gotchas (the non-obvious, hard-won ones)

#GotchaFix
1White screen / unstyled — the file:// trap. win.loadFile("out/index.html") makes absolute asset paths /_next/... resolve to the filesystem root, not the app dir → every asset 404s. loadFile does NOT rebase absolute paths. (Agents confidently claim it "resolves relative to the file" — it does not.)Register a custom app:// standard+secure scheme and serve out/ via protocol.handle; loadURL("app://local/"). Absolute /_next/... then resolve against the protocol origin. Bonus: a stable origin makes localStorage (theme/locale via next-themes) persist reliably — file://'s opaque origin silently breaks it.
2trailingSlash:false → flat files, no directory index. Pages are en.html/zh.html, not en/index.html; a file/protocol server won't append .html.Resolver fallback: try pathpath + ".html"path + "/index.html"404.html. Handles both trailingSlash modes. Use exact-segment matching so zh-hant isn't swallowed by zh.
3Language not remembered across launches. Static export has no middleware, so root index.html redirects to the default locale every launch — the web app cannot remember.Persist locale in a main-process JSON store; launch with loadURL("app://local/" + savedLocale); capture changes via webContents.on("did-navigate", …) parsing the locale segment from the URL.
4Don't couple the web app to Electron. The naive instinct is a preload.js + ipcMain.handle("set-locale") that the React switcher must call — this edits src/ and breaks the plain web build.Everything (locale, window-state) is doable passively in the main process (did-navigate, window events). No preload, no IPC, no renderer edits. Keep src/ byte-identical.
5node --test can't run if pure logic imports electron. require("electron") outside an Electron runtime throws.Keep path-resolution, locale-parsing, and the store in modules that import only Node built-ins + ./constants. Pass win/app in as parameters. Import electron ONLY in protocol.js/tray.js/main.js. Also: test:electron should list the test files explicitly (node --test electron/resolvePath.test.js …) — the electron/*.test.js glob is fragile (PowerShell won't expand it, so it leans on node --test's own glob, which needs Node ≥ 21), and node --test electron tries to load electron as a module.
6Packaged resource paths must match electron-builder; works in dev, white-screens packaged. main.js uses process.resourcesPath/out and /icon.png when app.isPackaged.extraResources must map from: out → to: out and from: build/icon.png → to: icon.png so the runtime paths line up. The static export is NOT in the asar — it's real files under resources/.
7Web fonts hang offline. A raw <link href="fonts.googleapis.com"> fetches at runtime → FOUT/hang on an air-gapped box.next/font/google self-hosts fonts into _next/static/media at build time (fine offline, as long as the CI build machine has internet). Self-host any other web fonts; never CDN-link them.
8App can never quit / tray icon vanishes. Close-to-tray that always preventDefaults traps the user; a Tray with no retained reference is garbage-collected and disappears.Intercept window closepreventDefault()+hide() UNLESS app.isQuitting; only the tray "Quit" item sets app.isQuitting=true then app.quit(). Assign the Tray to a variable that outlives setup.
9Relaunch spawns duplicate windows.app.requestSingleInstanceLock(); app.quit() if not primary; on second-instance restore/show/focus the existing window.
10workflow_dispatch button missing. GitHub only shows "Run workflow" if the workflow file is on the default branch. Keeping the desktop build on a side branch hides the button.Put a dispatch-only copy on main with checkout: { with: { ref: <desktop-branch> } } so the button on main builds the branch's code. Keep it separate from any existing Tauri/desktop workflow rather than overwriting it.
11portable exe is slow to start; unsigned exe warns. electron-builder portable = a self-extractor that re-unzips to %TEMP% on every launch. And any unsigned build trips Windows SmartScreen "unknown publisher".For intranet, target: dirwin-unpacked/ runs TextDiff.exe directly (no per-launch extraction). Distribute the folder (zip for transport; GitHub auto-zips an uploaded folder artifact). SmartScreen needs a code-signing cert to silence — usually acceptable internally.

Real-world result

An 18-locale next-intl static-export tool wrapped in one branch: src/ untouched, the hard logic (path resolution, locale parsing, store) unit-tested as pure Node modules (node --test), ~150 MB self-contained Windows build that runs with no WebView2 / no network, remembers language + window state, single-instance, close-to-tray. Pure modules verify locally; GUI QA is human-on-Windows.