Nextjs To Tauri

Other

Use when converting or migrating a Next.js 16 (App Router) web app into a Tauri 2 desktop app — packaging a static-export site as a desktop/portable .exe, adding auto-update, language persistence, window-state, single-instance, or system tray, or setting up GitHub Actions Tauri builds. Covers next-intl i18n static-export gotchas (the trailingSlash white-screen), updater signing keys, and cross-platform CI.

Install

openclaw skills install nextjs-to-tauri

Next.js 16 → Tauri 2 Desktop App

Overview

Wrap a client-side Next.js 16 (App Router) app in a thin Tauri 2 native shell. The React code is untouched; Tauri serves the static export from its embedded asset server. Build in GitHub Actions so no local Rust is needed.

Core principle: Tauri loads the static export over its own asset protocol — which behaves differently from a real HTTP server. Most migration pain is path/routing resolution, not React.

When to use

  • The app is (or can be) a static export (output: "export") — diffing/formatting/calculator/viewer tools, no SSR-at-runtime needed.
  • You want a desktop/portable .exe, .dmg, .AppImage, optionally with auto-update.

Do NOT use when the app needs a live Node server at runtime (real API routes, SSR, server actions). Tauri can bundle a sidecar server, but that's a different, heavier playbook.

Procedure

Do these in order. Copy-paste templates live in the supporting files.

  1. Verify latest versions first (they drift — never trust the numbers in templates):

    • npm: npm view @tauri-apps/cli version (+ plugin-opener, plugin-updater)
    • crates: curl -sA x https://crates.io/api/v1/crates/tauri | python -c "import sys,json;print(json.load(sys.stdin)['crate']['max_stable_version'])" (repeat for each tauri-plugin-*)
    • actions: gh api repos/actions/checkout/releases/latest --jq .tag_name (+ actions/setup-node, Swatinem/rust-cache, actions/upload-artifact; tauri-apps/tauri-action@v0 is the moving major)
  2. Gate static export on Tauri in next.config.*, driven by an EXPLICIT build flag — NOT Tauri's auto-injected TAURI_ENV_PLATFORM, which isn't reliably set for the frontend build and silently yields flat files (gotcha #1). Merge into your existing config, keeping the next-intl plugin wrapper:

    import createNextIntlPlugin from "next-intl/plugin";
    const withNextIntl = createNextIntlPlugin();
    
    const isDev = process.env.NODE_ENV === "development";
    const isTauri = process.env.TAURI_BUILD === "1"; // set by `yarn build:tauri`
    
    const nextConfig = {
      // ...your existing config...
      ...(isDev ? {} : { output: "export" }),       // export is build-only (gotcha #2)
      ...(isTauri ? { trailingSlash: true } : {}),   // Tauri-only (gotcha #1)
      images: { unoptimized: true },
    };
    
    export default withNextIntl(nextConfig);  // keep your existing wrapper(s)
    

    Add the flag-setting script (yarn add -D cross-env) — tauri.conf's beforeBuildCommand runs it, NOT plain yarn build:

    // package.json → "scripts"
    "build:tauri": "cross-env TAURI_BUILD=1 next build"
    
  3. Scaffold (CLI steps are JS — no Rust needed):

    yarn add -D @tauri-apps/cli@latest
    yarn tauri init --ci --app-name "<app>" --window-title "<Title>" \
      --frontend-dist "../out" --dev-url "http://localhost:3000" \
      --before-dev-command "yarn dev" --before-build-command "yarn build:tauri"
    yarn tauri icon public/logo.png   # needs a ≥512×512 source; rm src-tauri/icons/{android,ios} if desktop-only
    

    Then fix .gitignore — see gotcha #3.

  4. Edit src-tauri/ config & Rust. Copy from tauri-files.md: tauri.conf.json (window url, updater, webviewInstallMode, portable mainBinaryName), Cargo.toml (plugin deps + release profile), src/lib.rs (plugin registration + tray + single-instance), capabilities/default.json (permissions).

  5. Frontend integration (only the features you need). Copy from frontend-integration.md: external-links util (opener-based), auto-update hook, and — for next-intl apps — the remember-language hook. Mount them in one "use client" component rendered inside your providers (e.g. antd <App>, inside NextIntlClientProvider). That component also installs the global external-link interceptor (gotcha #10). Switch locales with plain router.push (soft nav works in Tauri — never hard-nav a switch); if you remember the language, guard the launch redirect with a module-level flag (gotcha #11).

  6. Version single-source + CI. Add src-tauri/update-version.js and an update-version script (copies package.json version into tauri.conf.json). Copy desktop-build.yml for cross-platform builds, signing, draft release with latest.json, and the portable-exe steps.

    • The git tag you push MUST equal package.json version (v3.0.0"version": "3.0.0"). tauri-action expands __VERSION__ from the app version, and the portable-exe step uploads to v${APP_VERSION} — a mismatch silently breaks the portable upload.
    • Windows is fully covered; macOS/Linux are not signed. Unsigned .dmg/.app is blocked by macOS Gatekeeper, and unsigned .AppImage triggers warnings. If you ship beyond Windows, add Apple notarization / codesigning secrets — out of scope here.
  7. Auto-update signing keys (only if shipping updates):

    yarn tauri signer generate --ci -p "" -w src-tauri/app.key -f   # private key — gitignore it, NEVER commit (gotcha #7)
    cat src-tauri/app.key.pub   # paste this base64 into tauri.conf.json plugins.updater.pubkey
    

    The CI signing step needs the private key as a repo secret — without it every signed build fails. The agent doing the migration usually can't set repo secrets, so it MUST remind the user to run this (or set it in the GitHub UI):

    gh secret set TAURI_SIGNING_PRIVATE_KEY < src-tauri/app.key   # bash / Git Bash
    # only if you generated the key WITH a password (not `-p ""`):
    gh secret set TAURI_SIGNING_PRIVATE_KEY_PASSWORD
    

    PowerShell has no < redirection — pipe instead:

    Get-Content src-tauri/app.key -Raw | gh secret set TAURI_SIGNING_PRIVATE_KEY
    

    Manual path: repo → Settings → Secrets and variables → Actions → New repository secret.

  8. Verify (without Rust): yarn build:tauri, then confirm the export shape:

    ls out/en/index.html   # MUST exist (not out/en.html) — proves trailingSlash worked
    

    The Rust shell (tray/single-instance API) only compiles in CI — flag that you can't compile it locally and let the first Actions run validate it.

Handoff — remind the user (manual, easy to forget, fail silently)

The code migration is done by the agent; these steps need the human and break things quietly if skipped. After migrating, surface them explicitly:

  1. Set the signing secret before the first build — bash: gh secret set TAURI_SIGNING_PRIVATE_KEY < src-tauri/app.key; PowerShell: Get-Content src-tauri/app.key -Raw | gh secret set TAURI_SIGNING_PRIVATE_KEY; or the GitHub UI. Without it, CI signing fails (step 7).
  2. Publish (un-draft) the first release — builds produce a draft; the updater resolves releases/latest, which ignores drafts, so updates never reach anyone until you un-draft it (gotcha #6).
  3. Keep package.json version == the pushed v* tag (gotcha — step 6).

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

#GotchaFix
1White screen / flat files. Static export puts /en at en.html + an en/ dir of RSC data; Tauri's asset server serves a directory's index.html but does not append .html to extensionless paths.trailingSlash: true (Tauri builds only) → emits en/index.html, resolved by directory-index. Gate it with an explicit TAURI_BUILD flag (yarn build:tauri), NOT the auto-injected TAURI_ENV_PLATFORM — the latter isn't reliably set, so a stray yarn build emits flat files and locale routing breaks. Set window url: "/en/" as a safe entry; don't rely on the root //en redirect (emitted without a trailing slash, 404s in Tauri).
2output: "export" + middleware is forbidden in Next 16 — even in dev. next-intl ships middleware (proxy.ts/middleware.ts). Setting output in dev silently kills locale redirects.Make output build-only (isDev ? {} : {...}).
3src-tauri/ vanishes from git status. Some repo .gitignores have a bare src-tauri line.Replace with src-tauri/target + src-tauri/gen/schemas. Verify: git check-ignore src-tauri/tauri.conf.json returns nothing.
4isTauri() false positives if you detect by dynamic-importing @tauri-apps/api. It imports fine in a plain browser; invoke only throws at call time.Detect via runtime globals: window.__TAURI_INTERNALS__ / __TAURI__ / UA contains Tauri. Same bundle ships to web + desktop.
5Single-instance plugin must be registered FIRST, before all other plugins, or second-launch refocus won't route. Desktop-only (target-gate it off mobile).See lib.rs ordering in tauri-files.md.
6Auto-update never reaches clients though CI "succeeds." The updater endpoint resolves releases/latest, which ignores draft and prerelease releases.Builds publish a draft; you must manually un-draft to roll out. Document this in the workflow.
7Committing the private signing key lets anyone sign malicious auto-updates your clients auto-trust. The .pub is public/safe; the bare key is not..gitignore the private key; store it only as a GitHub secret. If one ever lands in git history, rotate it.
8Portable "green" exe + auto-update are incompatible. update.install() expects an installed-app layout; on a standalone exe it fails/no-ops. The portable exe also needs the WebView2 runtime (preinstalled Win 11 / current Win 10; absent on old LTSC/Server).It's the raw target/release/<mainBinaryName>.exe (web assets compiled in). Ship it for convenience, but document "no self-update" and the WebView2 dependency. The failed install() is already caught by the hook, so it degrades gracefully. You can't cleanly detect "am I portable?" at runtime — it's the same compiled binary as the one inside the installers — so to truly hide the update prompt you'd produce a separate build with the updater plugin disabled.
9System-language autodetect is unreliable on macOS/Linux. navigator.language in WKWebView/WebKitGTK can be en-US regardless of OS. Also lang.split("-")[0] can yield an unsupported locale.Treat saved preference as source of truth; validate the detected locale against your locale list (validLocales.includes(...)) before using/saving it.
10External links open inside the app webview, hijacking the tool; or openUrl does nothing at all.One capture-phase document click delegate routes external http(s)/mailto/tel through @tauri-apps/plugin-opener's openUrl (not shell); same-origin links fall through to the router. Capability must be opener:default — bare opener:allow-open-url has no URL scope, so the call is denied at runtime and nothing opens. opener:default bundles allow-open-url + allow-default-urls (http/https/mailto/tel).
11Language switching looks broken / bounces back to one locale. Two self-inflicted causes: (a) hard-navigating a switch, and (b) a startup "remember-language" redirect guarded by a useRef. A locale switch remounts the [locale] layout subtree, so a hard reload re-resolves paths AND a useRef guard resets → the redirect re-fires and bounces every switch.Switch with plain router.push (soft nav) — it works in Tauri; never hard-nav a switch. If you redirect to a remembered locale at launch, guard it with a MODULE-LEVEL flag (not useRef) so it fires once per session, use a soft router.replace, and save the preference in the switcher, not a navigation effect.

What to expect

A typical multi-locale next-intl tool migrates in a single branch: React untouched, a ~3–10 MB exe (vs ~120 MB for Electron), fully offline, CI-only builds across Windows/macOS/Linux plus a portable exe. The frontend build verifies locally; Rust compiles in CI.