Install
openclaw skills install @rockbenben/nextjs-to-electronUse 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".
openclaw skills install @rockbenben/nextjs-to-electronWrap 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.)
output: "export") — diff/format/calculator/viewer tools, no SSR or API-at-runtime.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.
Do these in order. Full code is in electron-files.md. The web source under src/, messages/, next.config.* stays untouched.
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).
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).
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.js — SCHEME="app", LOCALES[]. resolvePath.js — resolveAssetPath(outDir, pathname, exists) with .html→/index.html→404.html fallback. store.js — dependency-free JSON store in userData. locale.js — startUrl/parseLocale/trackLocale. window-state.js — createWindowStateKeeper. (All pure → unit-tested.)protocol.js, tray.js, main.js — the only files that import electron.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> → resolveAssetPath → net.fetch(pathToFileURL(file)) (with a .catch returning a 404 Response). Load app://local/ + the saved locale.
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.
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.
CI (.github/workflows/electron.yml): windows-latest → setup-node → yarn install --frozen-lockfile → yarn test:electron → yarn 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).
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.
| # | Gotcha | Fix |
|---|---|---|
| 1 | White 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. |
| 2 | trailingSlash: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 path → path + ".html" → path + "/index.html" → 404.html. Handles both trailingSlash modes. Use exact-segment matching so zh-hant isn't swallowed by zh. |
| 3 | Language 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. |
| 4 | Don'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. |
| 5 | node --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. |
| 6 | Packaged 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/. |
| 7 | Web 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. |
| 8 | App 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 close → preventDefault()+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. |
| 9 | Relaunch spawns duplicate windows. | app.requestSingleInstanceLock(); app.quit() if not primary; on second-instance restore/show/focus the existing window. |
| 10 | workflow_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. |
| 11 | portable 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: dir → win-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. |
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.