Porting an Existing App
Start from the scaffold, graft your code into it, and convert route-by-route — the paved road for bringing an existing app to nextrs
Two real production apps have been ported to nextrs — a 1.37M-LOC Next.js dashboard and a ~20k-LOC booking app. This page is the instructions those stories imply: what worked, in what order, and the contracts a port must respect. The case studies are the evidence; this is the procedure.
Rule one: start from the scaffold, even for a port
The single biggest porting mistake is assembling nextrs around your existing code by hand — copying a build.rs from somewhere, hand-writing process entry points, or improvising the client package. Every port that went smoothly did the opposite: it started from nextrs new output and grafted the existing routes, auth, and database code into the generated skeleton.
The scaffold is not demo content — it is the wiring: build.rs codegen, the hidden linked client and its Orval/TypeScript pipeline, the cargo dev alias, the shared src/app.rs, the Vercel process adapter, the prebuilt deploy script, and a rust-toolchain.toml pin. Hand-rolling these means re-discovering, one confusing error at a time, decisions the scaffold already made.
Two ways to get the skeleton:
-
Fresh directory (your existing app keeps living elsewhere — see the strangler pattern below):
cargo install cargo-nextrs nextrs new my-app-rs # equivalent: cargo nextrs new my-app-rs -
Into an existing repo —
--adoptgenerates the same skeleton into a non-empty directory, minus the demo routes. It never overwrites: existing files are skipped and reported, an existingsrc/main.rsgets asrc/main.rs.examplebeside it instead, and if you already have aCargo.tomlit prints the dependency lines to merge by hand:cd my-existing-repo nextrs new --adopt --here
Then move your code in: your route.ts bodies become route.rs handlers, your auth becomes middleware.rs, your React pages drop into app/**/page.tsx, shared React UI can move into components/, and Rust domain code belongs in src/ — replacing the scaffold's example files rather than inventing parallel structure.
The strangler pattern: convert route-by-route
Neither case-study port was a big-bang rewrite of a live system. The existing app keeps serving; nextrs takes over route-by-route. The shape that worked:
- Inventory first. Walk the existing route tree and write a worksheet (
MIGRATION.md) with one row per route: URL, data dependencies, auth requirements, and the nextrs target files. If the app uses server actions or RPC, add a second table — one row per module and function. In action-heavy apps that table, not the route list, is the real API surface. - Keep the frontend identical. Client-rendered React components port nearly unchanged into
app/**/page.tsx— the 1.37M-LOC port reused its ~768k-LOC React UI byte-for-byte. What gets rewritten is everything behind the components: the Node server becomes one Rust binary. - Convert leaf routes first, one vertical slice at a time:
page.tsx+ itsroute.rsendpoints +prefetch.rsseed +middleware.rsguard. Verify the slice end-to-end (same wire shapes, same flows) before the next. - Bridge what you can't port yet. The booking-app port ran its auth as a sidecar first, then ported it natively and oracle-diffed 48/48 responses against the live sidecar before deleting it. A temporary proxy from the nextrs app to the old backend (or routing at your edge/CDN, path-by-path) keeps both halves live during the transition.
- Diff against the original as you go. Byte-level wire parity on representative endpoints is cheap to check and catches semantic drift early. Porting is an audit — the booking-app conversion found three latent bugs in the original.
Where each old concept lands:
| You have | nextrs target |
|---|---|
| Client-rendered React page | app/**/page.tsx (unchanged, client-rendered) |
| Next.js server component | app/**/page.tsx + app/**/prefetch.rs (Rust pre-runs the data, seeds the React Query cache) |
| API route / route handler | app/**/route.rs — plain Axum handlers, #[nextrs::api] for the typed client |
| Server actions / RPC modules | route.rs endpoints + a same-signature TypeScript shim, so call sites don't change |
Auth / route guards / middleware.ts | middleware.rs — scoped by directory placement, runs before anything renders |
| Layout | layout.tsx |
| Loading / suspense skeleton | loading.tsx |
| Route-local React component | Any ordinary filename beside its page, such as TodoRow.tsx |
| Shared React component | Top-level components/ |
| DB layer | your Rust choice (both ports used sqlx) — called from route.rs and prefetch.rs |
Contracts a port must respect
These are the conventions a hand-assembled port tends to miss. All of them are load-bearing.
The app/ tree is the router
Every directory under app/ is a URL segment; the build step discovers exact
convention filenames and wires the router. page.tsx, layout.tsx,
loading.tsx, and not-found.tsx are React slots. Rust supplies
middleware.rs, route.rs, and an optional prefetch.rs beside a React page.
Other files are ordinary colocated modules and do not create routes. Full reference:
Routing Conventions.
The generated client is a real hidden package
.nextrs/client is a genuine npm workspace package generated by the framework,
not a place for application components. The root package.json links it into
node_modules and owns every JavaScript dependency. Fetch functions come from
@your-app/client; hooks and query/mutation helpers come from
@your-app/client/react-query.
- Every bare import used by
.tsxcode belongs in the rootpackage.json. Runnpm installonly at the app root. Never install dependencies inside.nextrs/client. - Don't hand-write API types.
route.rshandlers annotated with#[nextrs::api]become an OpenAPI document, andcargo nextrs client generateat the app root regenerates typed fetch functions and React Query hooks. A Rust field rename breaks the TSX compile — that end-to-end check is most of the point of porting. See Typesafe Client Generation. - Don't add resolution shims. The generated package emits JavaScript and
.d.tsfor both entry points. New nested files resolve them through normal package exports, withouttsconfig.paths, relative generated imports, ordeclare modulefiles.
The dev loop is cargo dev
The scaffold aliases cargo dev (in .cargo/config.toml) to the watcher bundled with cargo-nextrs: it rebuilds and restarts on Rust, template, asset, and env changes, and the app wires live-reload in debug builds. Install the one CLI with cargo install cargo-nextrs. Don't substitute a hand-rolled watch script — the runner knows which inputs matter.
cargo nextrs dev and nextrs dev are the direct equivalents. The unified dev
command refreshes the generated client before watching.
The Rust app is shared; process entry points are thin
src/app.rs constructs the Axum Router and owns application-wide layers.
src/main.rs starts the local/container process. api/index.rs only adapts
the same app to Vercel's required Rust function entry. build.rs remains the
normal Rust build script for route/OpenAPI discovery and TSX bundling.
If Vercel is not a target, remove api/index.rs, the index Cargo target,
Vercel-only dependencies, vercel.json, and the prebuilt deploy script as one
unit. Do not delete the shared src/app.rs or local src/main.rs.
Deploys are prebuilt
Scaffolded apps ship scripts/deploy-prebuilt.sh and a vercel.json with git auto-builds disabled: you compile locally (via cargo-zigbuild) and upload artifacts; deploys take seconds instead of a cloud cargo build plus queue. The same vercel.json also contains a self-sufficient root npm ci and client/Cargo build for anyone who deliberately re-enables cloud builds. Guide: Deploy: Build Locally, Ship Artifacts.
Gotchas
- You never call
/__nx/prefetchyourself. Route chunk preloading and data prefetch on hover are automatic: the generated app shell preloads the target route's seeds through that endpoint on link intent. If you find yourself fetching/__nx/prefetchfrom app code, you're rebuilding a feature that's already on. prefetch.rsneeds apage.tsxsibling. It exists to warm that React page's query cache.- Don't hand-edit generated output.
.nextrs/openapi.json, the complete.nextrs/client/**package, andpublic/dist/are regenerated and ignored. The tracked.nextrs/template/clientwiring recreates the workspace target automatically. Application seams areapp/**,components/**,src/**, and the rootpackage.json.
When to bother
The small-app case study is blunt: at 20k LOC the JS dev loop is genuinely fast, and if your dev loop is your complaint, porting is not the fix. The reasons to port at any size are runtime — cold starts statistically indistinguishable from warm requests, ~2 orders of magnitude less memory, one small static binary — and, at scale, the dev loop too (the 1.37M-LOC numbers). Read both before committing a team.