Nextjs to tauri
Markdown to web page — upload .md files to S3-compatible storage, rendered instantly via Docsify. A skill for sharing long content as readable links.
npx -y skills add rockbenben/aishort-skills --skill nextjs-to-tauriAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
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.
SKILL.md
17.6 KB, as published. Nobody here has run it
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.
-
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 eachtauri-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@v0is the moving major)
- npm:
-
Gate static export on Tauri in
next.config.*, driven by an EXPLICIT build flag — NOT Tauri's auto-injectedTAURI_ENV_PLATFORM, which isn't reliably set for the frontend build and silently yields flat files (gotcha #1). Merge into your existing config, keeping thenext-intlplugin 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'sbeforeBuildCommandruns it, NOT plainyarn build:// package.json → "scripts" "build:tauri": "cross-env TAURI_BUILD=1 next build" -
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-onlyThen fix
.gitignore— see gotcha #3. -
Edit
src-tauri/config & Rust. Copy fromtauri-files.md:tauri.conf.json(windowurl, updater,webviewInstallMode, portablemainBinaryName),Cargo.toml(plugin deps + release profile),src/lib.rs(plugin registration + tray + single-instance),capabilities/default.json(permissions). -
Frontend integration (only the features you need). Copy from
frontend-integration.md: external-links util (opener-based), auto-update hook, and — fornext-intlapps — the remember-language hook. Mount them in one"use client"component rendered inside your providers (e.g. antd<App>, insideNextIntlClientProvider). That component also installs the global external-link interceptor (gotcha #10). Switch locales with plainrouter.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). -
Version single-source + CI. Add
src-tauri/update-version.jsand anupdate-versionscript (copiespackage.jsonversion intotauri.conf.json). Copydesktop-build.ymlfor cross-platform builds, signing, draft release withlatest.json, and the portable-exe steps.- Make the git tag the single source of truth (most robust — the template does this): on tag push, inject the tag into
package.jsonbeforeupdate-versionruns —npm pkg set version="${GITHUB_REF_NAME#v}", guardedif: github.event_name == 'push'.update-versionthen propagates it intotauri.conf.json, so installer filenames, the embedded app version, andtagName: v__VERSION__all equal the tag automatically — you only ever set the version by tagging, never bump branches. Do NOT instead pointtagNameatgithub.ref_name(gotcha #13). - If you skip injection, the pushed git tag MUST equal
package.jsonversion (v3.0.0↔"version": "3.0.0").tauri-actionexpands__VERSION__from the app version, and the portable-exe step uploads tov${APP_VERSION}— a mismatch silently breaks the portable upload. - Windows is fully covered; macOS/Linux are not signed. Unsigned
.dmg/.appis blocked by macOS Gatekeeper, and unsigned.AppImagetriggers warnings. If you ship beyond Windows, add Apple notarization / codesigning secrets — out of scope here.
- Make the git tag the single source of truth (most robust — the template does this): on tag push, inject the tag into
-
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.pubkeyThe 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_PASSWORDPowerShell has no
<redirection — pipe instead:Get-Content src-tauri/app.key -Raw | gh secret set TAURI_SIGNING_PRIVATE_KEYManual path: repo → Settings → Secrets and variables → Actions → New repository secret.
-
Verify (without Rust):
yarn build:tauri, then confirm the export shape:ls out/en/index.html # MUST exist (not out/en.html) — proves trailingSlash workedThe 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:
- 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). - 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). - Tag from a
main/default-branch commit that already has the corrected workflow — a tag-triggered run uses the workflow from the tagged commit, not the branch tip, so a fix landed after the tag silently doesn't apply (gotcha #13). With the template's tag-injection step you no longer hand-sync versions; only if you removed it must you keeppackage.json== the pushedv*tag (step 6).
Gotchas (the non-obvious, hard-won ones)
| # | Gotcha | Fix |
|---|---|---|
| 1 | White 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). |
| 2 | output: "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 ? {} : {...}). |
| 3 | src-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. |
| 4 | isTauri() 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. |
| 5 | Single-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. |
| 6 | Auto-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. |
| 7 | Committing 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. |
| 8 | Portable "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. |
| 9 | System-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. |
| 10 | External 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). |
| 11 | Language 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. |
| 12 | App aborts or shows a blank window on some Linux desktops (Xfce, VMs, hybrid/NVIDIA, and especially rolling distros — Manjaro/Arch/CachyOS) with Could not create surfaceless EGL display: EGL_BAD_ALLOC. Aborting.... (Surrounding xapp-gtk3-module / atk-bridge lines are harmless GTK/at-spi noise.) NOT a routing/white-screen (gotcha #1): EGL errors are present, it's platform-specific, Windows renders fine. Two different layers fail with near-identical output — the decisive tell is whether the EGL line DISAPPEARS once WEBKIT_DISABLE_DMABUF_RENDERER=1 is actually in effect. Layer A — WebKit render layer: disabling DMABUF makes the line vanish and the app works. Layer B — EGL-init layer, below WebKit: the EGL line persists unchanged no matter what WEBKIT_* vars you set (the process aborts before WebKit ever reads its env vars), because the AppImage ships its own libwayland-*.so that clashes with the host's newer Mesa/libEGL — libwayland's protocol/ABI must match the loaded libEGL/Mesa, and on a rolling distro the bundled (older, Ubuntu-build-host) copy doesn't. A persisting EGL line after the env var is confirmed-baked-in ⇒ layer B; stop adding WEBKIT_* vars. | Layer A: set WEBKIT_DISABLE_DMABUF_RENDERER=1 at the very top of run(), before Builder::default() (must precede GTK/WebView init), #[cfg(target_os = "linux")] + only-if-unset (var_os(...).is_none()). GPU-buffer sharing only — still accelerated, near-zero cost. (WEBKIT_DISABLE_COMPOSITING_MODE=1 forces software rendering and only helps a genuine GL-compositing failure; do NOT bake it in to chase a persisting EGL line — that's layer B and this won't fix it. We first misdiagnosed Manjaro Xfce as needing it — it didn't.) Layer B: WEBKIT_* cannot help — fix the library clash. Confirm by having the reporter strip the bundled libs: ./App.AppImage --appimage-extract && find squashfs-root -name 'libwayland-*.so*' -delete && ./squashfs-root/AppRun — if it paints, it's layer B. Permanent fix: at the very top of run() (before the WEBKIT block), when inside an AppImage (APPIMAGE env set), find the host libwayland-client.so.0 (try /usr/lib/x86_64-linux-gnu/, /usr/lib64/, /usr/lib/), prepend it to LD_PRELOAD, and exec() re-run self once (sentinel env var to avoid a loop) so the loader overrides the stale bundled lib. Self-contained, ecosystem norm (yaak, tolaria). Confirmed fixing Manjaro Xfce where every layer-A/WEBKIT_* attempt had failed (subtitle-translator v3.0.0). Alternative: strip those 4 libs (libwayland-client/cursor/egl/server) from the AppImage in CI. See tauri-files.md lib.rs. Refs: tauri #9394, espressif/idf-im-ui #755 (Arch/CachyOS), tolaria fix. |
| 13 | A CI fix on main doesn't apply to the release; or the release comes out empty / with mismatched installers. Tag-triggered runs execute the workflow definition from the tagged commit, not the branch tip — tag a commit whose history predates your fix and the old workflow runs (or a newly-added push: tags trigger never fires at all). Separately, pointing tagName at github.ref_name ships a release named v3.0.1 containing ..._3.0.0_... installers, because installer names + embedded version come from tauri.conf.json, not tagName (and on workflow_dispatch, github.ref_name is a branch name). | Cut the v* tag from a commit that already contains the corrected workflow. To rescue a broken/empty release, re-tag after fixing: gh release delete vX --yes; git push origin :refs/tags/vX; git tag vX <fixed-commit>; git push origin vX. For version coherence, inject the tag into package.json at build time (step 6), don't fiddle with tagName. Multi-app "release hub" (one tag on main fans out to Tauri + Electron, each workflow pinning checkout … ref: <feature-branch> to build that app's code): keep the workflows only on main and trigger from there — duplicate copies on feature branches drift out of sync and mislead, since the feature-branch copy is never what a tag runs. |
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.