# Getting Started > Create a React-first nextrs app and learn where application and generated code belong nextrs combines a React frontend with a Rust application. URL structure lives under `app/`, reusable React UI can live under `components/`, Rust domain logic lives under `src/`, and framework-generated state stays out of sight under `.nextrs/`. ## Install one CLI and create the app ```bash title="Terminal" cargo install cargo-nextrs nextrs new mysite cd mysite ``` Cargo subcommand users can run the same operation as: ```bash title="Terminal" cargo nextrs new mysite ``` One `cargo install` provides both `nextrs` and `cargo nextrs`. The older `create-nextrs-app` and `cargo nextrs-dev` launchers remain compatibility commands, but new projects should use the unified CLI. The scaffolder installs the root JavaScript dependencies and generates the typed client before it returns. Its default tree has a deliberate split: ```text title="Project structure" mysite/ ├── app/ # URL tree and route-specific code │ ├── layout.tsx # shared React layout │ ├── page.tsx # / │ ├── PingDemo.tsx # ordinary colocated component, not a route │ ├── slow/ │ │ ├── page.tsx # /slow │ │ ├── loading.tsx # pending UI │ │ └── prefetch.rs # server-warmed React Query data │ ├── api/ping/route.rs # typed Axum API │ └── api/cron/heartbeat/route.rs # disabled #[nextrs::cron] starter ├── components/ # React UI shared by multiple routes ├── src/ │ ├── app.rs # shared Rust Router and application wiring │ └── main.rs # local/container process entry ├── .nextrs/ # generated framework state; do not edit │ ├── client/ # linked generated npm package │ └── dump-openapi.rs # hidden code-generation helper ├── api/index.rs # Vercel process adapter ├── public/ # static assets ├── build.rs # route discovery and browser bundling ├── package.json # all JavaScript dependencies live here ├── nextrs.toml # app config: Vercel settings + cron schedules └── vercel.json # generated from nextrs.toml by `nextrs generate` ``` The mental model is: - `app/` describes URLs. Only recognized convention filenames create routes. Put a component beside the page that alone uses it. - `components/` holds React components shared across routes. It is a useful default, not a restriction. - `src/` is the Rust application and domain layer. Add ordinary Rust modules here. - `.nextrs/` is generated. Import its package; do not write application code there or run `npm install` inside it. ## Your first page `app/page.tsx` is an ordinary React component: ```tsx title="app/page.tsx" import { NextrsLogo } from "@/components/NextrsLogo"; export default function HomePage() { return
Hello from nextrs
; } ``` Directories become URL segments, so `app/settings/page.tsx` serves `/settings`. You can freely colocate supporting files: ```text title="Colocated route files" app/settings/page.tsx app/settings/SettingsForm.tsx app/settings/format-preferences.ts ``` Only `page.tsx` is a convention file; the other two are normal modules. Move a component to top-level `components/` when several routes share it. ## Add a typed Rust endpoint Create `app/api/greeting/route.rs`: ```rust title="app/api/greeting/route.rs" use axum::Json; use serde::Serialize; use utoipa::ToSchema; #[derive(Serialize, ToSchema)] pub struct Greeting { pub message: String, } #[nextrs::api] pub async fn get() -> Json { Json(Greeting { message: "Hello from Rust".into() }) } ``` `#[nextrs::api]` opts the handler into the generated OpenAPI contract. nextrs derives its method and URL from `get` and `app/api/greeting/route.rs`, then Orval generates two stable entry points. Use the package root for framework-independent fetch functions: ```ts title="app/load-greeting.ts" import { getApiGreeting } from "@mysite/client"; const response = await getApiGreeting(); console.log(response.data.message); ``` Use `/react-query` for hooks, query options, query keys, and mutations: ```tsx title="components/Greeting.tsx" import { useGetApiGreeting } from "@mysite/client/react-query"; export function Greeting() { const greeting = useGetApiGreeting(); return

{greeting.data?.data.message}

; } ``` When `cargo dev` is running, changes to annotated endpoints automatically refresh the generated client. Production builds regenerate it through the configured build process as well, so client generation is not normally a step you need to think about. To refresh the client without starting the application, you can run: ```bash title="Terminal" cargo nextrs client generate # or: nextrs client generate ``` `.nextrs/client` is a genuine npm workspace dependency linked into root `node_modules`. Its package exports point to built JavaScript and `.d.ts` declarations, so a brand-new nested `.ts` or `.tsx` file resolves both imports in TypeScript and VS Code. No relative generated import, declaration shim, or `tsconfig.paths` entry is required. `nextrs new` also creates the root `.gitignore`. The whole generated `.nextrs/client` package is ignored. A small tracked template under `.nextrs/template/client` lets `cargo dev` and client generation recreate the package before TypeScript or the browser build consumes it. ## Run the dev loop The default shortcut is: ```bash title="Terminal" cargo dev ``` These direct forms run the same watcher: ```bash title="Terminal" cargo nextrs dev nextrs dev ``` `cargo dev` is a scaffolded Cargo alias. The unified dev command refreshes the generated client, builds the app, starts it, and watches relevant Rust, frontend, template, asset, and environment files. ## Why `app.rs`, `main.rs`, `api/index.rs`, and `build.rs` all exist - `src/app.rs` constructs the shared Axum `Router`. Application-wide layers and domain wiring belong here. - `src/main.rs` only starts the local/container process and calls that shared app. - `api/index.rs` is a thin Vercel adapter required by Vercel's current Rust entry convention. Do not put application logic there. - `build.rs` is normal Rust build-script infrastructure. It discovers the `app/` tree, generates the route/OpenAPI registry, and bundles React pages. If Vercel is not a deployment target, remove `api/index.rs`, its `index` Cargo target, the Vercel-only dependencies, `vercel.json` (and the `[vercel]` table in `nextrs.toml`), and the prebuilt-deploy script together. Keep `src/app.rs`, `src/main.rs`, and `build.rs`. ## Where to go next - [Routing Conventions](/docs/conventions) - [A Rust-First Tour](/docs/rust-first-tour) - [Client Generation: Step by Step](/docs/client-codegen) - [Porting an Existing App](/docs/porting) - [Deploy to Vercel](/docs/deploy-vercel) or [Deploy with Docker](/docs/deploy-docker) ### Choosing the development app Run `nextrs dev` from the application directory. It selects the current Cargo package's `default-run` binary, or its sole runnable binary. The generated `cargo dev` alias is optional shorthand for the same framework command. Use `nextrs dev --bin NAME` only to choose another binary or resolve an ambiguous workspace. The framework command is `nextrs`, not `next`. --- # Routing Conventions > Exact route filenames, free colocation, dynamic URLs, APIs, and server prefetch Directories under `app/` describe URL segments. Files only acquire framework meaning when their names match a convention exactly; every other `.ts`, `.tsx`, or Rust module is ordinary colocated application code. ## Recognized files | File | Role | |---|---| | `page.tsx` | React page for this URL | | `layout.tsx` | React layout for this segment and descendants | | `loading.tsx` | React pending UI | | `not-found.tsx` | React 404 surface for this subtree | | `middleware.rs` | Request guard or transformation | | `route.rs` | Axum handlers named for HTTP methods | | `prefetch.rs` | Server data that warms a sibling `page.tsx` query cache | New applications should use the React conventions above. `prefetch.rs` requires a `page.tsx` sibling because it feeds that React page's cache.
Legacy server-rendered conventions Earlier nextrs applications may contain `page.rs`/`page.html`, `layout.rs`/`layout.html`, `loading.rs`/`loading.html`, or `not-found.rs`/`not-found.html`. They remain compatibility conventions for existing applications, but are not part of the recommended React-first model for new projects. A legacy rendering slot cannot coexist with its `.tsx` form in the same segment.
## Colocation is free The router ignores files outside the table. Both of these are valid: ```text app/todos/page.tsx app/todos/TodoRow.tsx # used only by /todos app/todos/format-todo.ts # ordinary helper components/Button.tsx # shared by many routes ``` You do not need an underscore-prefixed component directory. Keep route-local code near its page; use top-level `components/` for broadly reusable React UI. Application Rust and domain logic normally live in `src/`, while `route.rs`, `middleware.rs`, and `prefetch.rs` stay thin web adapters. ## React pages and layouts ```tsx // app/users/page.tsx -> /users export default function UsersPage() { return

Users

; } ``` Layouts nest from root to leaf and receive the matched page as `children`: ```tsx import type { ReactNode } from "react"; export default function DashboardLayout({ children }: { children: ReactNode }) { return
{children}
; } ``` `app/layout.tsx` wraps all React routes. `app/dashboard/layout.tsx` adds a layer only below `/dashboard`. ## Loading and server prefetch Place `loading.tsx` beside or above a React page to define its pending UI: ```tsx export default function Loading() { return

Loading…

; } ``` A `prefetch.rs` beside `page.tsx` returns a `nextrs::QuerySeed`. On a hard load, nextrs includes those cache entries in the page shell. On link intent and soft navigation, the app shell warms the route chunk and data. See [React Pages & Server Prefetch](/docs/react-server-props). ## Middleware `middleware.rs` files compose from root to leaf and run before pages and API handlers: ```rust use axum::body::Body; use http::Request; use nextrs::conventions::MiddlewareResult; pub async fn handle(mut req: Request) -> MiddlewareResult { let Some(user) = authenticate(&req).await else { return MiddlewareResult::response(( http::StatusCode::SEE_OTHER, [("location", "/login")], )); }; req.extensions_mut().insert(user); MiddlewareResult::next(req) } ``` ## Typed API routes `route.rs` exports async functions named `get`, `post`, `put`, `patch`, `delete`, `head`, or `options`. Axum extractors define the inputs and concrete response types define the output: ```rust use axum::{extract::Path, Json}; use serde::Serialize; use utoipa::ToSchema; #[derive(Serialize, ToSchema)] pub struct User { pub id: u64 } #[nextrs::api] pub async fn get(Path(id): Path) -> Json { Json(User { id }) } ``` The handler routes without the annotation. `#[nextrs::api]` additionally puts it in the generated OpenAPI/client contract. See [Client Generation: Step by Step](/docs/client-codegen). ## Dynamic and catch-all segments ```text app/users/[id]/page.tsx -> /users/{id} app/api/users/[id]/route.rs -> /api/users/{id} app/api/auth/[...all]/route.rs -> /api/auth/*all ``` A React page receives dynamic values through its typed `params` prop. API handlers use Axum's `Path` extractor. The generated client carries typed path arguments rather than asking callers to assemble URLs. For multiple path parameters, use one `Path` extractor with a named struct. The field names match the dynamic directory names: ```text title="Route" app/api/organizations/[organization_id]/todos/[todo_id]/route.rs ``` ```rust title="app/api/organizations/[organization_id]/todos/[todo_id]/route.rs" use axum::{extract::Path, Json}; use serde::Deserialize; use utoipa::IntoParams; #[derive(Deserialize, IntoParams)] #[into_params(parameter_in = Path)] pub struct TodoPath { pub organization_id: u64, pub todo_id: u64, } #[nextrs::api] pub async fn get(Path(path): Path) -> Json { find_todo(path.organization_id, path.todo_id).await } ``` A tuple is supported as a compact alternative. Its values follow the URL segment order: ```rust title="Tuple shorthand" #[nextrs::api] pub async fn get( Path((organization_id, todo_id)): Path<(u64, u64)>, ) -> Json { find_todo(organization_id, todo_id).await } ``` Use a single scalar such as `Path` only when the route has one dynamic segment. Invalid scalar, tuple, or multiple-`Path` shapes produce a compiler error that recommends the named-struct or tuple form. ## Handler arguments are extractors A `route.rs` function is an ordinary Axum handler. Each argument is an **extractor** that tells Axum where its value comes from. Axum performs the runtime extraction; `#[nextrs::api]` inspects the request-contract types to describe the endpoint in OpenAPI and generate its TypeScript client. ```rust title="app/api/organizations/[organization_id]/todos/[todo_id]/route.rs" use axum::{ Extension, Json, extract::{Path, Query}, http::HeaderMap, }; use nextrs::{ApiError, Timing}; #[nextrs::api] pub async fn get( Path(path): Path, Query(query): Query, Extension(ctx): Extension, headers: HeaderMap, timing: Timing, ) -> Result, ApiError> { let todo = timing .span( "database", ctx.todos.find( path.organization_id, path.todo_id, query.include_archived, ), ) .await?; Ok(Json(todo)) } ``` ### Values derived from the request | Extractor | Source | |---|---| | `Path` | Dynamic URL segments | | `Query` | URL query parameters | | `Json` | JSON request body | | `Form` | Form request body | | `HeaderMap` | Request headers | | `Method` | HTTP method | | `Uri` | Complete request URI | | `Request` | Low-level request access | Extractors that consume the request body, such as `Json`, must come last. The body can only be consumed once: ```rust title="app/api/todos/route.rs" #[nextrs::api] pub async fn post( Extension(ctx): Extension, timing: Timing, Json(input): Json, ) -> Result, ApiError> { let todo = timing.span("database", ctx.todos.create(input)).await?; Ok(Json(todo)) } ``` ### Values provided by the application Application dependencies are currently installed as Axum extensions when the shared router is constructed: ```rust title="src/app.rs" pub fn app() -> axum::Router { let context = AppContext { db: Database::connect(), todos: TodoService::new(), }; nextrs::router::build_router(generated_registry()) .layer(axum::Extension(context)) } ``` Any route can then request `Extension(ctx): Extension`. The layer inserts the value into each request before the route runs. This is useful in a serverless deployment too: configuration, services, and database pools can be created once per instance and reused by warm invocations. Axum's typed `State` extractor is not currently supported by the generated nextrs route registry. Use `Extension` for application dependencies today. ### Values provided by nextrs Framework middleware makes a few server-only extractors available: | Extractor | Purpose | |---|---| | `Timing` | Add named spans to the `Server-Timing` response header | | `WaitUntil` | Register work that may continue after the response | | `Params` | Access nextrs route parameters in lower-level handlers | The handler does not construct these values. nextrs places the required context into the request before invoking it. ### What becomes part of the generated client | Handler value | Generated TypeScript contract | |---|---| | `Path` | Typed path arguments | | `Query` | Typed query object | | `Json` | Typed request body | | Success response | Typed response data | | `ApiError` | Typed error response | | `Extension`, `Timing`, `WaitUntil` | Server-only; omitted | Request-contract values become client inputs. Application and framework context remain server-only. ### Invalid JSON is rejected before the handler runs `Json` checks the content type, parses the body, and deserializes it before calling the route function. If extraction fails, the handler is not called and Axum returns its standard rejection response: - `415 Unsupported Media Type` for a missing or incorrect JSON content type; - `400 Bad Request` for malformed JSON syntax; - `422 Unprocessable Entity` for valid JSON that does not match `T`. For example, a string sent for a boolean field is valid JSON but the wrong shape, so Axum responds with `422`. This default is the recommended behavior for now. A route that needs a custom rejection body can accept `Result, JsonRejection>` and map the error itself, but that customized shape must currently declare `request_body = T` explicitly for client generation. ## Static assets Files in `public/` are served at the root URL path: ```text public/logo.svg -> /logo.svg ``` Avoid assigning the same URL to both a public file and a route. --- # A Rust-First Tour > Build from a tiny React component to local state, a typed Rust API call, React Query, and server-seeded data nextrs is easiest to understand one layer at a time. Start with the smallest React component, then add each backend or data abstraction only when the problem in front of you needs it. Route-local components can sit beside `page.tsx`; shared components can live in top-level `components/`. Only exact convention filenames create routes, so neither choice changes the URL tree. This tour deliberately builds the same idea five times. It is also a useful demo path: start with almost nothing, put Rust behind an HTTP boundary, and then show how that Rust contract keeps the richer client honest. ## 1. A React page with text Create `app/page.tsx`: ```tsx export default function HomePage() { return

Hello from nextrs

; } ``` That is a complete page. The file convention creates `/`; no backend handler or data library is required. ## 2. A page with local state Add local state when the page needs browser interaction: ```tsx import { useState } from "react"; export default function Counter() { const [count, setCount] = useState(0); return ( ); } ``` This state is entirely local. Rust serves the page and its bundle, but there is no backend data yet. ## 3. Put the contract in Rust Add `app/api/todos/route.rs`: ```rust use axum::Json; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; #[derive(Serialize, Deserialize, ToSchema)] pub struct Todo { pub id: u64, pub title: String, pub done: bool, } #[nextrs::api] pub async fn get() -> Json> { Json(vec![Todo { id: 1, title: "Learn nextrs".into(), done: false, }]) } ``` The handler is an ordinary typed Axum handler. `#[nextrs::api]` opts it into the generated client. nextrs gets the method from `get`, the URL from the file location, and the response shape from `Json>`. After changing the contract, generate the client from the application root: ```bash cargo nextrs client generate # equivalent: nextrs client generate ``` ## 4. Call Rust directly from TypeScript The generated client does not require React Query. Import its plain function from the package root and call it like any other async function: ```tsx import { getApiTodos } from "@mysite/client"; import { useState } from "react"; export default function Todos() { const [message, setMessage] = useState("Nothing loaded yet"); async function load() { const response = await getApiTodos(); setMessage(response.data[0]?.title ?? "No todos"); } return ; } ``` There is no handwritten URL, response interface, cast, or generic annotation. The generated `getApiTodos` function and its result come from the Rust endpoint. Rename `title` in Rust, regenerate, and this page stops type-checking at the exact place that still expects the old contract. Use this plain client in browser modules, event handlers, or any UI framework. It is the smallest end-to-end example of TypeScript consuming a Rust-owned API. ## 5. Add React Query when server state grows React Query becomes useful when the page needs caching, loading states, refetching, mutations, or invalidation. The hook is generated beside the plain function from the same contract: ```tsx import { useGetApiTodos } from "@mysite/client/react-query"; export default function Todos() { const { data, isPending } = useGetApiTodos(); if (isPending) return

Loading…

; return (
    {data?.data.map((todo) =>
  • {todo.title}
  • )}
); } ``` The Rust handler has not changed. The direct function and the hook are two ways to consume the same generated API, not two competing backend designs. Both imports resolve through the generated package linked in the app's root `node_modules`; the package emits JavaScript and declarations for editors and type checking. ## 6. Seed the first render from Rust Finally, add a sibling `prefetch.rs` when the first render should already have the query result. nextrs runs that work on the server and seeds the same canonical query key used by `useGetTodos`. The component above does not need a special server-data prop or a second data path. This is the progression used by the [`react-todos`](https://github.com/drewhirschi/nextrs/tree/main/examples/react-todos) example: ``` React text page → React local state → typed Rust route → plain generated client → generated React Query hook → Rust-seeded first render ``` Every step earns the next abstraction. Rust remains the source of truth for the backend contract, while the frontend can stay tiny or grow into a full interactive application without duplicating that contract. Next, read [Client Code Generation, Step by Step](/docs/client-codegen) for the smallest complete client workflow, [Typesafe Client Generation](/docs/typesafe-client) for the full reference, and [Server Data in React](/docs/react-server-props) for prefetching, streaming, and cache-key details. --- # Loading and Prefetch > Keep React navigation responsive while route code and server data are prepared nextrs uses React loading components and server-warmed React Query data to keep navigation responsive. The supported frontend conventions are `page.tsx`, `layout.tsx`, and `loading.tsx`. ## Loading UI Add `loading.tsx` beside a page: ```tsx export default function LoadingTodos() { return

Loading todos…

; } ``` The app shell can show this component while the route bundle and data become available. Keep it small and independent of the data it is waiting for. ## Prefetch server data Add `prefetch.rs` beside the same `page.tsx` to warm its React Query cache. On a hard load, nextrs puts those entries into the page shell before React mounts. On link intent and soft navigation, the app shell preloads the target route and calls the same prefetch path automatically. The page itself continues using an ordinary generated hook: ```tsx import { useGetApiTodos } from "@mysite/client/react-query"; export default function TodosPage() { const { data, isPending } = useGetApiTodos(); if (isPending) return

Loading todos…

; return
    {data?.data.map((todo) =>
  • {todo.title}
  • )}
; } ``` Delete `prefetch.rs` and the component still works; its hook simply fetches on mount. Prefetch is an optimization, not a second frontend data model. See [React Pages & Server Prefetch](/docs/react-server-props) for the complete server-seeding flow. --- # API Routes > Handler return shapes: bodies, status codes, headers, and typed errors A `route.rs` under `app/` exports one async function per HTTP method (`get`, `post`, `patch`, …), annotated with `#[nextrs::api]`. The macro derives the OpenAPI path from the file location and infers the operation's request and response types from the signature — so the signature is the contract, and the generated TypeScript client can't drift from it. This page is about the *return* side of that contract: how you produce a body, a status code, headers, and errors — and what each choice costs you in generated-client fidelity. ## The three tiers | Return type | Status | Codegen | |---|---|---| | `Json` | always 200 | full: typed client + seed companion | | `Result, ApiError>` | 200 or the error's status | full, **including a typed error body** | | anything else `IntoResponse` | yours to build | none inferred — declare `responses(...)` by hand | The framework never forces a shape — every handler is an ordinary Axum handler — but the further down the table you go, the more you have to state manually. ## Bodies Return `Json` where `T: Serialize + ToSchema`. The macro reads `T` out of the return type and registers it as the 200 response, which is what types the generated hook (`useGetApiTodosById` returns `TodoDetail`, not `unknown`). Don't return a bare `T` — there is deliberately no "you probably meant JSON" inference. `Json(value)` is one wrapper and makes the wire format explicit. One gotcha: **type aliases defeat inference**. `-> ApiResult>` looks tidy but a proc-macro can't resolve aliases, so the operation silently loses its response schema *and* its seed companion. Spell the return type out. ## Errors: `Result, ApiError>` The recommended shape for anything fallible. `nextrs::ApiError` carries a status code plus a typed JSON body: ```rust use nextrs::ApiError; #[nextrs::api(get)] pub async fn get(Path(id): Path) -> Result, ApiError> { let todo = ctx.get(id).await .ok_or_else(|| ApiError::not_found("no todo with that id") .with_code("todo_not_found"))?; Ok(Json(todo.into())) } ``` On the wire an error is the status plus `{"error": "...", "code": "..."}` (`code` optional, for clients that branch on failures without string-matching). Constructors exist for the common statuses — `bad_request`, `unauthorized`, `forbidden`, `not_found`, `conflict`, `unprocessable`, `internal` — and `ApiError::new(status, msg)` covers the rest. `From` is implemented, so handlers that used `?` on a `StatusCode` migrate by changing the return type. Because the macro recognizes the shape structurally, a `Result, ApiError>` handler **self-registers a `default` error response with the `ApiError` schema** — no `responses(...)` block. The generated client then has a typed error union, not just a typed success. Your own error enum works too: implement `IntoResponse` (most apps convert to `ApiError` internally) and declare its responses on the attribute. If you do declare `responses(...)`, you only need the *error* entries — the inferred 200 is merged in whenever your block doesn't declare a success status: ```rust #[nextrs::api(responses((status = 404, description = "unknown org", body = ApiError)))] pub async fn get(...) -> Result, ApiError> { ... } // spec gets: 200 → OrgDetail (inferred), 404 → ApiError (declared) ``` ## Status codes - An infallible `Json` is a 200; a `Result` is 200 or the error's status. That covers most routes. - A non-200 success (a `201`, say) is Axum tuple composition: `(StatusCode::CREATED, Json(created))`. Tuple returns aren't inferred yet, so declare the response: `responses((status = 201, body = Todo))`. - A body-less handler can return `StatusCode` directly (the react-todos `delete` does) — fine for endpoints the typed client only calls for effect. A bare `StatusCode` return infers a body-less 200, so the operation still appears in the spec without a `responses(...)` block. ## Headers Also Axum composition — anything `IntoResponseParts` stacks in front of the body: ```rust use axum::http::header::SET_COOKIE; use axum::response::AppendHeaders; pub async fn post(...) -> (AppendHeaders<[(HeaderName, String); 1]>, Json) { (AppendHeaders([(SET_COOKIE, cookie)]), Json(session)) } ``` `HeaderMap` works the same way, and both compose with a status: `(StatusCode, headers, Json)`. As with non-200 successes, tuples currently need a hand-written `responses(...)` for the body to reach the spec. For response timing there's a shortcut: take a `nextrs::Timing` extractor and wrap work in `timing.span("db", fut)` — the `Server-Timing` header is emitted for you (see [Route Telemetry](/docs/telemetry)). ## The escape hatch Any `impl IntoResponse` — streams, redirects, raw `Response` — is a valid handler. The macro can't see through it, so infer nothing: declare `responses(...)` yourself if the operation should appear typed in the client, and know that opaque GETs never get a seed companion. That's the trade: full Axum freedom, manual contract. ## Where the pieces live Routing (which files become routes, dynamic `[id]` segments, method naming) is covered in [Routing Conventions](/docs/conventions); how the spec becomes a typed client is [Client Generation](/docs/client-codegen). The worked example for everything above is `examples/react-todos/app/api/todos/` in the repo. --- # React Pages & Server Prefetch > page.tsx in the app tree, with the React Query cache warmed by the server before your bundle runs > **Status: implemented, pre-release.** Everything on this page runs in the nextrs repo today — the runnable [`examples/react-todos`](https://github.com/drewhirschi/nextrs/tree/main/examples/react-todos) crate is exactly this code. APIs may still shift before a release. The typed-client pipeline it builds on is documented at [Typesafe Client Generation](/docs/typesafe-client). ## The idea nextrs discovers React pages and their optional server prefetch from the `app/` tree: ``` app/ ├── layout.tsx # shared React layout └── todos/ ├── page.tsx # React page — discovered and routed by the same codegen └── prefetch.rs # optional: Rust warms your React Query cache ``` `.tsx` pages are client-rendered. The server sends the React shell and script; your component renders in the browser and talks to the Rust backend through generated typed hooks. One Rust binary serves the frontend assets and APIs. There is no Node server or JavaScript runtime inside the binary. The interesting part is what replaces server-side rendering's data story. ## The waterfall, and `prefetch.rs` A client-rendered page normally pays: stream shell → download bundle → mount React → hook fires a fetch → round-trip *back to the server that just streamed the shell*. The server had the data the whole time. `prefetch.rs` is a Rust file beside your page that runs per request, calls the same handler that serves the API endpoint, and injects the result into the streamed HTML — keyed exactly the way the generated client keys its queries: ```rust // app/todos/prefetch.rs include!(concat!(env!("OUT_DIR"), "/nextrs_seeds.rs")); pub async fn prefetch(req: http::Request) -> nextrs::QuerySeed { nextrs::QuerySeed::new() // A plain typed function call (no HTTP): runs the GET /api/todos // handler and pairs the result with its canonical query key. .seed(get_api_todos( api_todos::TodosFilter { status: Some("open".into()) }, req.extensions(), )) .await } ``` The `get_api_todos` companion (and the `api_todos` module alias that makes the filter type reachable) is generated by the build from the `#[nextrs::api]` annotation on the handler — seedable handlers are GETs returning `Json<...>` (or `Result, E>`) whose extractors are at most one `Path`, at most one `Query`, plus any `Extension` / `WaitUntil` args. `Extension` state (your DB handle installed with `.layer(Extension(ctx))`) and `WaitUntil` are pulled from the request extensions automatically during prefetch — a handler needing app context stays fully seedable. If an `Extension` value is missing at prefetch time, the entry seeds nothing and the page falls back to fetch-on-mount. (`State` is not supported — hold shared context in an `Extension` layer instead.) By the time your bundle executes, the JSON is already in the DOM, loaded into the React Query cache before mount. ## What the page looks like The payoff: **the component has no idea any of this happened.** It's vanilla React Query — except the data is just there on first paint: ```tsx // app/todos/page.tsx import { useQueryClient } from "@tanstack/react-query"; import { useGetTodos, useAddTodo, getGetTodosQueryKey, } from "@my-app/client/react-query"; export default function Todos() { const queryClient = useQueryClient(); // Warmed from the stream: defined on first render, no spinner, no mount // fetch. Goes stale and refetches like any query afterward. const { data: todos, refetch, isFetching } = useGetTodos({ status: "open" }); const addTodo = useAddTodo({ mutation: { onSuccess: () => { // Prefix invalidation refetches every /api/todos variant — including // the server-seeded entry, because the seed used the same canonical // key the hooks use. queryClient.invalidateQueries({ queryKey: getGetTodosQueryKey() }); }, }, }); return (
    {todos?.data.map((t) =>
  • {t.title}
  • )}
); } ``` Three properties worth noticing: 1. **Seeding is a pure progressive enhancement.** Delete `prefetch.rs` and this file works unchanged — it just fetches on mount instead of rendering instantly. 2. **Mutations invalidate seeded data.** The seed lives under the same `[url, params]` key the hooks use, so your `invalidateQueries` call refreshes streamed data and fetched data alike. 3. **Refetching, staleness, optimistic updates are untouched.** The seed is an ordinary cache entry; everything React Query does applies to it. ## Thin handlers, and why seeds go through them nextrs's Rust conventions are deliberately just the adapter layer — `route.rs`, `middleware.rs`, and `prefetch.rs` translate between the web and domain logic, which lives wherever you keep it. Handlers stay thin: ```rust // app/api/todos/route.rs — adapter only: extract, delegate, map #[nextrs::api] pub async fn get(Query(f): Query) -> Json> { Json(core::todos::list(f.into()).await) } ``` `prefetch.rs` runs on the server, so it *could* call `core::todos::list` directly. It calls the handler instead, on purpose: the seed is a cache entry **keyed by URL** — it impersonates a response from `GET /api/todos`, and the client will refetch that endpoint later and overwrite it. The wire shape (the DTO mapping, serde casing, the response envelope) belongs to the HTTP adapter, so producing a cache entry for that endpoint has to go through the adapter — or risk drifting from it and flickering from seed-shape to handler-shape on the first refetch. With a thin handler, calling it costs exactly one DTO mapping more than calling the service, and that mapping is the part the seed can't safely skip. The supported seed contract is endpoint-shaped on purpose. For session data, feature flags, or a page-specific view model, expose the typed endpoint whose wire representation the browser will later refetch, then seed that same endpoint. This keeps first-paint data and subsequent client data on one typed path. ## End-to-end type safety The same property the typed client has, extended to seeds and props: the Rust structs derive `ToSchema`, the schema flows into the OpenAPI document, and orval generates the TypeScript. Rename a field in Rust and the `.tsx` stops compiling. ## What ships today - **Client-rendered `page.tsx`** — discovery, routing, and bundling run in `cargo build`. The bundler is embedded Rolldown, gated behind the `tsx` cargo feature. Root JavaScript dependencies still supply React and the generated-client toolchain; there is no separate application frontend server. - **`prefetch.rs` React Query cache seeding** — exactly as shown above: the server streams seed entries into the HTML and the client loads them into the cache before mount. - **`loading.tsx` skeletons** — a loading component mounts immediately while the page bundle loads. Generated hooks come from `@my-app/client/react-query`; direct fetch functions come from `@my-app/client`. Both are normal package exports backed by emitted JavaScript and declarations. Still on the roadmap: build-time prerendering — static `.tsx` pages rendered to HTML during the build (Node at build time only) and hydrated in the browser. Follow along or argue with us: [github.com/drewhirschi/nextrs](https://github.com/drewhirschi/nextrs). --- # 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](/docs/case-study-port-at-scale) and a [~20k-LOC booking app](/docs/case-study-hhh). 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): ```bash cargo install cargo-nextrs nextrs new my-app-rs # equivalent: cargo nextrs new my-app-rs ``` - **Into an existing repo** — `--adopt` generates the same skeleton into a non-empty directory, minus the demo routes. It never overwrites: existing files are skipped and reported, an existing `src/main.rs` gets a `src/main.rs.example` beside it instead, and if you already have a `Cargo.toml` it prints the dependency lines to merge by hand: ```bash 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: 1. **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. 2. **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. 3. **Convert leaf routes first**, one vertical slice at a time: `page.tsx` + its `route.rs` endpoints + `prefetch.rs` seed + `middleware.rs` guard. Verify the slice end-to-end (same wire shapes, same flows) before the next. 4. **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. 5. **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](/docs/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 `.tsx` code belongs in the root `package.json`.** Run `npm install` only at the app root. Never install dependencies inside `.nextrs/client`. - **Don't hand-write API types.** `route.rs` handlers annotated with `#[nextrs::api]` become an OpenAPI document, and `cargo nextrs client generate` at 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](/docs/typesafe-client). - **Don't add resolution shims.** The generated package emits JavaScript and `.d.ts` for both entry points. New nested files resolve them through normal package exports, without `tsconfig.paths`, relative generated imports, or `declare module` files. ### 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 deploy with `nextrs deploy` (their `vercel.json`, generated from `nextrs.toml`, has 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](/docs/deploy-prebuilt). ## Gotchas - **You never call `/__nx/prefetch` yourself.** 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/prefetch` from app code, you're rebuilding a feature that's already on. - **`prefetch.rs` needs a `page.tsx` sibling.** It exists to warm that React page's query cache. - **Don't hand-edit generated output.** `.nextrs/openapi.json`, the complete `.nextrs/client/**` package, and `public/dist/` are regenerated and ignored. The tracked `.nextrs/template/client` wiring recreates the workspace target automatically. Application seams are `app/**`, `components/**`, `src/**`, and the root `package.json`. ## When to bother The [small-app case study](/docs/case-study-hhh) 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](/docs/case-study-port-at-scale)). Read both before committing a team. --- # List State Belongs in the URL > The default pattern for filters and pagination: URL search params, passed to the server, via the generated FromUrl hooks **This is the house style for nextrs apps — humans and coding agents alike should treat it as the default, not an option.** Any list that can be filtered, sorted, searched, or paginated keeps that state in URL search params, and those params are passed through to the server. Do not reach for `useState` for list state. Why it's the default: - **Links are the whole point.** `?status=open&page=3` can be shared, bookmarked, opened in a new tab, and restored on refresh; back/forward walks previous views out of warm cache. `useState` loses all of that. - **The server sees the real query.** nextrs seeds data server-side per request; when the filter lives in the URL, the first render of any filtered URL is the *filtered* result — no flash of unfiltered content, no client fetch on load. - **One source of truth.** The URL, the query key, and the API call all derive from the same params, so they cannot drift apart. ## The pattern The wiring is generated end to end. Declare the params as a `Query` extractor on the API route — that makes them part of the OpenAPI contract and the typed client: ```rust #[derive(Serialize, Deserialize, IntoParams)] pub struct ItemsQuery { #[serde(skip_serializing_if = "Option::is_none")] pub q: Option, #[serde(skip_serializing_if = "Option::is_none")] pub page: Option, } #[nextrs::api] pub async fn get(Query(query): Query) -> Json { ... } ``` (`skip_serializing_if` matters: seeded query keys drop absent fields, so serializing `None` as `null` would make the server-built key never match the client hook's.) On the client, the codegen emits a **`use...FromUrl` variant of every GET hook with params**. Params are read from the page URL; `setParams` soft-navigates, which re-keys the query and keeps the previous view warm in cache: ```tsx const { data, params, setParams } = useGetApiItemsFromUrl(); setParams({ q: e.target.value || undefined, page: undefined })} /> ``` `undefined` in a patch deletes the key from the URL; a filter change should reset `page`. Pass `{ history: "replace" }` for typeahead-style updates that shouldn't spam history. Close the loop with a `prefetch.rs` beside the page that parses the **same** query string and seeds through the handler's generated companion: ```rust include!(concat!(env!("OUT_DIR"), "/nextrs_seeds.rs")); pub async fn prefetch(req: http::Request) -> nextrs::QuerySeed { let query = nextrs::search_params::(&req) .unwrap_or(api_items::ItemsQuery { q: None, page: None }); nextrs::QuerySeed::new() .seed(get_api_items(query, req.extensions())) .await } ``` Now every `?q=&page=` URL renders server-seeded with the right slice. Scaffolded apps ship a worked example at `app/items/` (a filtered, paginated list); `examples/react-todos` does the same for its `?status=` filter. ## When to reach for nuqs For URL state that is **not** tied to a generated endpoint hook — an active tab, a selected view mode, panel state you want shareable — [nuqs](https://nuqs.dev) is the recommended package: `useState` ergonomics, type-safe parsers, reads and writes the URL. Don't layer it on top of a `FromUrl` hook for the same params, though — two writers to one URL. ## Rules of thumb - Filtering, sorting, searching, pagination, active tab on a list page → URL search params, always. - Ephemeral UI (open modal, hover, half-typed input before submit) → local state is fine. - Params are part of the API contract: declare them in the `Query` struct so they flow into OpenAPI and the typed client — never hand-build query strings. - Debounce text search locally if you must, but commit the value to the URL. --- # Route Telemetry > Diagnose slow routes progressively: Server-Timing in DevTools, custom segments with the Timing extractor, and OpenTelemetry export for production history Every nextrs route records a latency breakdown — middleware chain, handler, and named sub-segments like the `seed` step of a prefetch-backed React page. Nothing to configure; it's on for every app the moment you rebuild. Diagnosing a slow route is a ladder. Most problems die on the first rung. ## 1. Read the Server-Timing header Open the slow page with DevTools' Network tab, select the request, and look at the **Timing** panel (or the raw headers): ``` server-timing: mw;dur=1.2, seed;dur=430.0, handler;dur=445.1, total;dur=447.0, route;desc="/todos/{id}" ``` | Metric | Meaning | |---|---| | `mw` | your `middleware.rs` chain, root → leaf | | `seed` | server-side query seeding (`prefetch.rs`), for React pages | | `handler` | the page render or API handler, inclusive of segments | | `total` | the whole request, inside the framework | | `route;desc` | which route template matched | Here the verdict is immediate: 430 of 447 ms is seeding — the data layer, not rendering or middleware. Streaming pages (`loading` present) send headers before the page function runs, so their header carries only `mw` and a `streaming` marker; the full breakdown still reaches tracing (rung 3) when the stream completes. To turn the header off (it is visible to clients), set `NEXTRS_SERVER_TIMING=0`. ## 2. Add your own segments When `handler` is big and you need to know *why*, extract `Timing` and wrap the suspects: ```rust use axum::{Extension, Json}; #[nextrs::api(get, ...)] pub async fn get( Extension(db): Extension, timing: nextrs::Timing, ) -> Json> { let todos = timing.span("db", db.list()).await; Json(todos) } ``` Reload — `db;dur=…` appears in the same header. No infrastructure, no config; the iteration loop is edit → reload → read the Network tab. `Timing` works in seeded GET handlers too: when `prefetch.rs` seeds through the handler during a page render, its segments land in that page's breakdown. Outside a request (unit tests) every method is a no-op. ## 3. Ship it to a collector The same instrumentation is a `tracing` span (`nextrs.route`) plus a per-request summary event (target `nextrs::telemetry`) with OpenTelemetry semantic-convention fields: `http.route`, `http.request.method`, `http.response.status_code`, `total_ms`, `mw_ms`, `handler_ms`, `seed_ms`, `segments`, `streaming`, `cold`. - **Local dev:** `RUST_LOG=nextrs=info` prints one summary line per request (`=debug` adds the span and per-segment events). - **Vercel:** the same lines land in your function logs — queryable in the dashboard with zero setup. - **OTel backend** (Grafana, Honeycomb, Axiom, …): add an OTLP-exporting subscriber and the spans flow as real traces, your `timing.span` and library spans nested under `nextrs.route`: ```rust // Cargo.toml: tracing-subscriber, tracing-opentelemetry, // opentelemetry, opentelemetry-otlp use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; let tracer = opentelemetry_otlp::new_pipeline() .tracing() .with_exporter(opentelemetry_otlp::new_exporter().tonic()) .install_batch(opentelemetry_sdk::runtime::Tokio)?; tracing_subscriber::registry() .with(tracing_subscriber::fmt::layer()) .with(tracing_opentelemetry::layer().with_tracer(tracer)) .init(); ``` **Serverless caveat:** OTel's batch exporter buffers spans, and a frozen Vercel instance never flushes them. Either use the simple (per-span) exporter, or flush from background work registered with [`WaitUntil`](/docs/react-server-props) so the runtime drains it before freezing the instance. ## What gets measured - Every matched page and API route, labeled with its route template. - The soft-nav prefetch endpoint, labeled `/__nx/prefetch` so slow seed work is attributable to soft navigations separately from hard loads. - `not_found` surfaces, labeled `__not_found`. - Static files are not instrumented. The cold-start headers (`x-nextrs-cold`, `x-nextrs-boot-id`) ride on the same responses, and the summary event carries `cold` — so cold and warm latency for a route are separable in whatever backend you query. --- # Performance > Same app, same UI, same database — measured head to head, twice: a minimal app and a real production app converted end-to-end
Authed, DB-backed page
99×
throughput vs Next.js — real app, same Postgres
Public page render
522×
340k req/s vs 652 req/s
Cold start, real app
20×
215 ms vs 4.3 s — and nextrs cold ≈ its warm
Memory serving
2.6×
92 MB vs 236 MB — real app, RSS
Benchmark blog posts usually compare a hello-world. We did that too — but then we took a **real production app** (a bookings/admin platform: better-auth, Postgres, S3, shadcn/radix, 23 pages, 68 server actions) and converted it to nextrs with **byte-identical frontends** — same React components, same flows, verified route-by-route and flow-by-flow against the original before any benchmark ran. Only the backend changed: the Node/RSC runtime became a single compiled Rust binary. Everything below is measured, reproducible from [`benchmarks/`](https://github.com/drewhirschi/nextrs/tree/main/benchmarks), and reported with its caveats. The conversion itself is documented down to per-slice timings in [`docs/hhh-migration-timelog.md`](https://github.com/drewhirschi/nextrs/blob/main/docs/hhh-migration-timelog.md). ## The real app, head to head Local, matched profiles (release Rust vs production `next build`), same machine, same Postgres, `hey` with 50 concurrent connections: | Metric | nextrs | Next.js | gap | |---|---|---|---| | **Page `/` (public landing)** | 340,589 req/s | 652 req/s | **~522×** | | **Page `/app` (authed: cookie HMAC + session row + user query)** | 38,351 req/s | 389 req/s | **~99×** | | `/app` latency p50 / p99 | 1.3 / 1.8 ms | 123 / 206 ms | ~95× | | **Memory (RSS, serving)** | 91.7 MB | 235.8 MB | **~2.6×** | The authed row is the one to stare at: both sides validate the session cookie and hit the same Postgres on every request. That's not a static-file trick — it's the per-request cost of the framework runtime, and it's two orders of magnitude. The minimal-app numbers (same todos app, both client-rendered, in-memory store) are the ceiling: **~423×** page throughput, **~132×** API throughput, **~43×** memory (5.7 MB vs 247 MB). Details in [`benchmarks/results/results.md`](https://github.com/drewhirschi/nextrs/blob/main/benchmarks/results/results.md). ## Why it's this lopsided A nextrs request is a **compiled Rust function** — the handler runs in well under a millisecond, with no per-request runtime to spin up. A Next.js request, even for a client-rendered page, runs through the **Node + React Server Components pipeline** every time: serialize the flight payload, resolve the dynamic import, run the framework's request machinery. The per-request cost is the *runtime*, not the rendering — which is why the gap holds even when both pages render in the browser. ## Cold starts: latency *and* frequency Vercel exposes no cold/warm signal, so both apps self-report (`x-cold`, `x-instance` headers) and we count instances directly. Same region, both apps loaded **simultaneously**. **Latency — this is where app size decides everything.** On the minimal app the gap is modest: cold p50 **648 ms vs 830 ms**, a ~200 ms difference that is Node runtime boot vs loading a static binary. On the **real app**, that boot cost explodes with the dependency tree: | Cold start, real app (`iad1`) | nextrs | Next.js | |---|---|---| | cold p50 | **215 ms** | **4,323 ms** | | cold p95 | 582 ms | 4,812 ms | | warm p50 | 209 ms | 342 ms | nextrs's cold start is statistically indistinguishable from its warm requests — loading the binary costs nothing your users can see. Next.js's grew ~5× from the todo app to **4.3 seconds**, because every cold instance re-boots the framework plus the app's module graph. One line grows with your app; the other doesn't. **Frequency** — how often users actually *hit* a cold start. At low concurrency it's a tie, and we say so: Vercel scales per concurrent connection regardless of framework. Under 150-way sustained load on the **real app**, **Next.js needed 100 instances (89 cold boots); nextrs served the same load on 43 (32)** — 57% fewer instances, half the cold starts per request, and instance-time is what Fluid compute bills. The two effects compound: Next.js's cold starts are both ~2× more frequent *and* ~20× more expensive, which is why its p95 TTFB under that load was 5.5 s while nextrs's p50 sat at ~200 ms. ## The conversion is real — and repeatable The real-app comparison only counts because the two frontends are identical. The conversion that got us there is codified in an agent-followable guide ([`docs/migrating-nextjs-to-nextrs.md`](https://github.com/drewhirschi/nextrs/blob/main/docs/migrating-nextjs-to-nextrs.md)): server actions become same-signature fetch shims (call sites unchanged), server-component pages become seeded client pages, and even better-auth moved into the binary — a native Rust implementation of its wire protocol (scrypt, signed session cookies, Google OAuth), oracle-diffed 48/48 against the real thing and locked in by 111 tests, with the unchanged better-auth React client none the wiser. The whole conversion was verified route-by-route, three roles, money flows step-by-step, plus a byte-level wire audit that caught two serialization drifts before they could ship. The deployed nextrs app is **one Rust binary and a folder of static files**. No Node runtime anywhere. Scaffold to fully-verified conversion: **~4.5 hours wall clock**, mostly parallel agents. The timelog has every slice. ## Reading it honestly - **Warm latency over the network is a tie.** ~260 ms round-trips bury a sub-millisecond handler. nextrs wins throughput, memory, cold start, and instance count — not warm wall-clock latency. - **nextrs's memory advantage shrinks as the app grows** — 43× on the todo app, 2.6× on the real one (5.7 → 92 MB; the sqlx pool and a 31 MB binary are real). Node's footprint barely moved (247 → 236 MB): it's dominated by the runtime floor, nextrs's by what your app actually uses. - **Throughput numbers are floors.** At 340k req/s the load generator is the bottleneck, not the server. - **This isn't "Next.js is bad."** Next.js ships HMR, a vast ecosystem, RSC streaming, image optimization — far more than these apps exercise. The claim is narrow: for the same user-visible app, nextrs serves it with a fraction of the per-request cost, memory, and cold-start exposure. ## Reproduce it ```sh # Minimal app: throughput + memory (local) benchmarks/scripts/bench-local.sh # Real app: throughput + memory (local, DB-backed) benchmarks/scripts/bench-hhh-local.sh # Cold start latency + frequency (against deployed URLs) benchmarks/scripts/bench-cold.sh https://your-app.vercel.app/api/health benchmarks/scripts/bench-cold-freq.sh https://your-app.vercel.app/api/health 300 40 ``` Fairness controls — matched build profiles, both pages client-rendered, per-request fresh data, same-region simultaneous cold-start runs — are documented in [`benchmarks/methodology.md`](https://github.com/drewhirschi/nextrs/blob/main/benchmarks/methodology.md). --- # nextrs.toml > The app's single config source for identity and deployment settings `nextrs.toml` at the app root is where a nextrs app is configured. It is the one file you edit; `nextrs generate` turns it into the provider files Vercel and Cloudflare actually read. Newly scaffolded apps ship with one. ```toml [app] name = "myapp" # names generated resources (myapp-cron) url = "https://myapp.vercel.app" # the deployed app, for cron triggers [vercel] regions = ["pdx1"] # runtime = "vercel-rust@4.0.11" # defaults, shown for reference # install_command = "npm ci" # build_command = "npm run client:prepare && cargo build --release --bin index && npm run client:build" # git_deploys = false # emitted into generated local config # [vercel.extra] # non-framework Vercel keys only # trailingSlash = false ``` Cron schedules are colocated with their protected handlers as `#[nextrs::cron(schedule = "...")]`; see [Cron Jobs](/docs/crons). ## What `nextrs generate` writes - **`.nextrs/vercel.json`** — generated framework state containing the Rust function, the catch-all rewrite to it, immutable caching for `/dist`, git auto-builds off, your regions/commands, and any Vercel-provider [crons](/docs/crons). It is overwritten atomically on every generation and passed to Vercel with `--local-config`. The adjacent README marks it as generated. `[vercel.extra]` is an escape hatch for Vercel keys the table does not model, but cannot override framework-owned `$schema`, regions, commands, functions, headers, rewrites, git, or crons. - **`.nextrs/cloudflare/`** — the Worker shim for cloudflare-provider crons (gitignored, regenerated on demand). The `[vercel]` table is optional; omitting it uses framework defaults. A root `vercel.json` is never read or mutated, so it cannot become a second source of deployment or cron configuration. If one exists, generation warns that it is ignored and explains how to copy its settings into `nextrs.toml`. ## Moving settings from `vercel.json` Copy settings that NextRS models into `[vercel]`: | `vercel.json` | `nextrs.toml` | |---|---| | `regions` | `regions` | | `installCommand` | `install_command` | | `buildCommand` | `build_command` | | `functions.api/index.rs.runtime` | `runtime` | | `git.deploymentEnabled` | `git_deploys` | Put other non-framework top-level settings under `[vercel.extra]`. For example, `"trailingSlash": false` becomes: ```toml [vercel.extra] trailingSlash = false ``` Do not copy `$schema`, `functions`, `headers`, `rewrites`, `git`, `crons`, or the other framework-owned keys listed above into `[vercel.extra]`; NextRS generates those from its typed settings and route declarations. The original file is left untouched. `nextrs deploy` and `nextrs cron deploy` both run `generate` first, so the provider files can't drift from the config. ## Server bundle settings Colocated `bundle.toml` files assign routes and subtrees. `[bundles.]` in `nextrs.toml` supplies the named bundle's Cargo `features` and private runtime `assets`; `[deployment].features` supplies common features. See [Server Bundles](/docs/server-bundles) for the complete build and deploy flow. --- # Deploy: Build Locally, Ship Artifacts > Run the complete Vercel build locally, verify the Rust function exists, and upload prebuilt output Vercel cloud builds can spend minutes compiling Rust and longer waiting for an account build slot. A prebuilt deploy runs the same configured Vercel build on your machine, then uploads only `.vercel/output`. New NextRS apps make explicit prebuilt deployment the supported path and generate `scripts/deploy-prebuilt.sh`. A Git push is not a supported deploy: disable automatic deployments for connected repositories in the Vercel project settings. See the [Git-preview FAQ](/docs/faq#do-vercels-automatic-git-and-pull-request-previews-work). ## One-time setup ```bash npm install --global vercel vercel login cargo install cargo-zigbuild pip install ziglang # or install Zig another way cd your-app vercel link ``` `cargo-zigbuild` targets the older glibc available in the function runtime. Without it or Zig, the community runtime can finish without producing a Rust function. The generated script explicitly checks the output before upload. ## Deploy a scaffolded app From the application root: ```bash nextrs deploy # production (+ cron triggers, if any are declared) nextrs deploy --preview # preview; skips cron triggers ``` This explicit preview is supported; Vercel's automatic pull-request previews are not, because `.nextrs/vercel.json` is generated locally and ignored by Git. `nextrs deploy` first runs [`nextrs generate`](/docs/config), then the prebuilt deploy, then `nextrs cron deploy` when cloudflare-provider [crons](/docs/crons) are declared (`--skip-cron` to leave those alone). Scaffolded apps also carry `scripts/deploy-prebuilt.sh`, the same steps as a shell script for environments without the CLI. Either performs the equivalent of: ```bash vercel pull --yes --environment=production vercel build --local-config .nextrs/vercel.json --prod vercel deploy --prebuilt --prod ``` The application and cron phases are independently retryable. If Vercel succeeds but Cloudflare fails, the command reports that the application is already deployed. Fix the credential, preflight, or provider error and run: ```bash nextrs cron deploy ``` That deploys only the Cloudflare cron plumbing and does not rebuild or redeploy the application. For preview mode, it omits `--prod` from build and deploy. `vercel build` runs the `installCommand` and `buildCommand` from the managed `.nextrs/vercel.json`. For a generated nextrs app that means: 1. root `npm ci` links `.nextrs/client` and installs React/Orval/TypeScript; 2. the current Rust OpenAPI contract generates fetch and React Query clients; 3. release Cargo compilation builds the Vercel adapter and browser bundles; 4. TypeScript emits client JavaScript and declarations. Generated assets do not need to be committed. A prebuilt deployment changes where this complete build runs, not what the build contains. ## Monorepo projects The Vercel project Root Directory and the directory where you run `vercel build` must agree. If the project declares a root directory such as `site`, link that project and run the repository's deployment wrapper as documented by the repository. If the app itself is the Vercel root, run its generated script inside the app. Workspace Cargo builds also need their produced function to remain inside the uploaded project. A deployment wrapper can set a project-local `CARGO_TARGET_DIR` when the workspace default would point outside the upload root. ## Required verification Before `vercel deploy --prebuilt`, confirm that the build actually contains a function: ```bash find .vercel/output/functions -name '*.func' -type d ``` The generated script refuses to deploy when this finds nothing. This catches the most dangerous failure mode: a successful-looking static output with no Rust function. ## Other gotchas - Exclude Cargo targets and unrelated `node_modules` from Vercel source uploads; large build trees can exceed file-count or file-size limits. - Pin `framework: null` in project settings if Vercel misidentifies the app as Next.js. - Keep tests and smoke checks in CI. A prebuilt CLI upload does not imply that your normal pull-request checks ran. - Install application dependencies only at the project root. Never run npm installation inside `.nextrs/client`. ## Prebuilt versus cloud builds | | Cloud build | Prebuilt build | |---|---|---| | Trigger | git integration | deployment script | | Build location | Vercel infrastructure | your machine or CI runner | | Build steps | root install + generation + Cargo + client build | the same steps via `vercel build` | | Queue | subject to account build slots | none | | Upload | source, then build | Build Output only | To choose cloud builds, re-enable Vercel's git deployment setting. The generated build is self-contained in either mode; do not revive the old workflow of skipping frontend bundling and committing `public/dist`. ## Keeping this documentation site current The docs application depends on the workspace framework source, with its version constraint checked by Cargo. It does not wait for a crates.io publish. Its header and landing page show the linked framework version, and `GET /__nx/version` returns that version plus `NEXTRS_BUILD_REVISION` (the source commit, or `local` outside deployment). In this repository, `.github/workflows/ci.yml` deploys docs after successful main branch tests and browser smoke. It uses the same `scripts/deploy-prebuilt.sh site` command as local deployment, which runs the CLI from that checkout. The final step checks that production reports the expected version and commit and serves the landing page and server bundles guide. Configure the GitHub `docs-production` environment with `VERCEL_TOKEN`, `VERCEL_ORG_ID`, and `VERCEL_DOCS_PROJECT_ID` secrets. The Vercel project must keep Root Directory `site` and allow source files outside that directory. Set the `NEXTRS_DOCS_URL` Actions variable if verifying a different production domain. PRs run tests without production credentials; only a successful push to `main` reaches deployment. Missing credentials fail the deploy job explicitly. --- # Cron Jobs > Declare schedules on protected Rust routes; nextrs generates the Vercel and Cloudflare trigger plumbing Vercel Hobby allows one imprecise cron per day. Cloudflare Workers' free tier handles minutely schedules. nextrs lets you use both without leaving your app: declare every schedule on its protected route, and the CLI generates the trigger for the selected provider. Your logic always runs in your Rust app on Vercel—the Cloudflare Worker (when one is generated) is a dumb trigger that fetches your route with a bearer secret. Delete it and you lose nothing but the schedule. ## Declare the protected route and schedule A cron target is an ordinary API route with `#[nextrs::cron(schedule = "...")]` in place of `#[nextrs::api]`. The macro is `api` plus the auth gate: both trigger providers send `Authorization: Bearer $CRON_SECRET`, and the handler answers with a structured 401 before body extraction unless the secret matches. If `CRON_SECRET` is unset, it fails closed with a structured 503. ```rust // app/api/cron/refresh/route.rs use axum::http::StatusCode; use axum::Json; #[nextrs::cron(schedule = "0 6 * * *")] pub async fn get() -> Result, StatusCode> { // ... the actual work ... } ``` Vercel is the default provider. For a subdaily schedule, generation warns that Vercel Hobby supports only daily crons. Opt into Cloudflare's more flexible free scheduling explicitly: ```rust #[nextrs::cron(schedule = "*/10 * * * *", provider = "cloudflare")] pub async fn get() -> Result, StatusCode> { // ... the actual work ... } ``` To keep a protected route ready without scheduling it yet, disable the declaration while preserving its intended schedule: ```rust #[nextrs::cron(schedule = "0 6 * * *", disabled = true)] pub async fn get() -> Result, StatusCode> { // ... the actual work ... } ``` Disabled declarations are validated but omitted from generated Vercel and Cloudflare schedules. Remove `disabled = true` to enable the trigger. Fresh scaffolds use this form for the heartbeat example and include an empty `CRON_SECRET` entry in `.env.example`. Schedules are five-field UTC cron expressions. Scheduled handlers are GET routes because both Vercel and the generated Cloudflare trigger send GET. Delivery is at-least-once and imprecise, and redundant delivery from both providers must be harmless — write handlers idempotently (compute a deterministic time slot and tolerate redelivery rather than assuming exactly one call per tick). **Do the work foreground and let the status code tell the truth.** A cron has no user waiting, so there is no reason to respond early: run the job inline and return 200 only when it actually completed. The status code is your delivery receipt — it lands in the Worker's log and your Vercel logs, so a failing job shows up as a failing tick. Responding 200 immediately and pushing the work into `WaitUntil` makes every tick report success even when the job blew up. (Cost is a wash: Vercel bills active CPU, not wall clock.) Reach for background execution only when the work can exceed the function's execution window or needs retry semantics of its own. ## Generate and deploy ```bash nextrs generate # writes .nextrs/cloudflare/{worker.js,wrangler.toml}, # and .nextrs/vercel.json (with Vercel-provider crons) nextrs cron deploy # generate + `wrangler deploy` + sync CRON_SECRET ``` `cron deploy` discovers annotated routes, reads `CRON_SECRET` from the environment, deploys the Worker, and stores the secret with it. It talks to Cloudflare one of two ways: - **API-direct (no wrangler, no Node):** set `CLOUDFLARE_API_TOKEN` (an API token with the *Workers Scripts: Edit* permission) and `CLOUDFLARE_ACCOUNT_ID`. The CLI uploads the Worker with the secret as a binding and sets the schedules over HTTPS. This is the CI path. - **wrangler:** with neither variable set, the CLI shells out to [wrangler](https://developers.cloudflare.com/workers/wrangler/) and uses its login (`wrangler login`). Convenient on a workstation. Set the same `CRON_SECRET` on the Vercel project (`vercel env add CRON_SECRET`) so the app can verify what the Worker sends. Before touching Cloudflare, `cron deploy` runs a preflight: it fetches each cloudflare-provider route at `app.url` **without** credentials and expects a 401. A 404 means the route isn't deployed there (wrong `app.url` or stale deploy); a 200 means the route is missing its `#[nextrs::cron]` gate; unreachable means the URL is wrong. Any of those aborts the deploy with the specifics — `NEXTRS_CRON_SKIP_PREFLIGHT=1` overrides when you know better. The generated `.nextrs/cloudflare/` directory is disposable — gitignore it and regenerate on demand. Vercel-provider crons deploy with the app itself; the Worker redeploys with `nextrs cron deploy` whenever schedules change. Scaffolded apps ship with a disabled daily heartbeat starter. After setting `CRON_SECRET`, remove `disabled = true` to generate its native Vercel trigger; it needs no Cloudflare account. Use `app/api/cron/heartbeat/route.rs` as the gated route to copy from. The worked example `examples/react-todos` runs the same route every 10 minutes through the Cloudflare shim. See [dependencies](/docs/dependencies) for the full tooling list. --- # Tooling Dependencies > The external tools a nextrs project uses, and which workflows need each one nextrs itself is a Cargo dependency, but the surrounding workflows lean on a few external tools. None are needed at runtime — they are all build/deploy machinery on your workstation or CI. | Tool | Needed for | Install | | --- | --- | --- | | **Rust** (1.85+) | everything — the app is a Cargo project | [rustup.rs](https://rustup.rs) | | **Node.js + npm** | the generated TypeScript client, TSX bundling, dev loop | [nodejs.org](https://nodejs.org) (LTS) | | **Vercel CLI** | deploys (`vercel deploy --prebuilt`), env management (`vercel env`) | `npm i -g vercel` | | **cargo-zigbuild + zig** | [prebuilt deploys](/docs/deploy-prebuilt) — cross-compiling the Vercel function locally for `x86_64-unknown-linux-gnu` | `cargo install cargo-zigbuild` + [ziglang.org](https://ziglang.org/download/) | | **wrangler** (optional) | [Cloudflare cron triggers](/docs/crons) on a workstation — `nextrs cron deploy` without `CLOUDFLARE_API_TOKEN`; with a token the CLI calls the API directly | `npm i -g wrangler` | Day-to-day development needs only Rust and Node — `nextrs dev` covers the loop. The rest come in when you deploy: the Vercel CLI plus cargo-zigbuild for the prebuilt path, and wrangler only if you declare Cloudflare-provider crons and prefer its login flow over a `CLOUDFLARE_API_TOKEN`. --- # Server Bundles > Give expensive routes their own Rust executable and deployed function using bundle.toml Server bundles keep expensive dependencies and runtime files out of ordinary functions. Each bundle is compiled independently and deployed as one function. Public URLs and generated browser clients stay the same. This feature currently requires the framework and CLI from this repository's source; it is not part of the published 0.6.1 framework / 0.3.0 CLI. Until the next coordinated release, run the checkout's CLI, for example: ```bash cargo run -p cargo-nextrs --bin nextrs -- bundles plan --root examples/react-todos ``` The shorter `nextrs` commands below assume that source-built CLI is on your PATH. ## Assign a route or subtree Put a `bundle.toml` beside your route conventions: ```text app/ page.tsx # default bundle api/ documents/ bundle.toml # documents bundle route.rs [id]/route.rs # inherits documents status/ bundle.toml # can override the parent route.rs ``` ```toml # app/api/documents/bundle.toml bundle = "documents" ``` Assignments inherit down the directory tree. The nearest named assignment wins; without an assignment, an endpoint belongs to `default`. All HTTP methods in a `route.rs` stay together. A colocated page also belongs to the same bundle. Directory names and public URLs do not change. To return a nested route to the default function: ```toml # app/api/documents/status/bundle.toml bundle = "default" ``` ## Isolate one endpoint ```toml # app/api/video/render/bundle.toml isolate = true features = ["video"] assets = ["resources/video"] ``` This creates a stable, private bundle name for this endpoint. It includes its methods and required ancestor conventions. Unlike a named assignment, `isolate = true` applies only to the colocated endpoint; descendants continue to inherit the nearest named ancestor. Use a named bundle to group a subtree. Names use lowercase letters, digits, and hyphens. The `route-` prefix is reserved for generated isolated names. A declaration without an endpoint is an error. ## Keep dependencies separate A routing tag alone cannot remove an unconditional Cargo dependency or shared initialization code. Make heavy dependencies optional: ```toml # Cargo.toml [features] default = ["documents"] # convenient for ordinary local development documents = ["dep:pdf-engine"] [dependencies] pdf-engine = { version = "1", optional = true } # replace with your actual engine ``` Declare features and private runtime files for named bundles centrally: ```toml # nextrs.toml (alongside the existing [app] and [vercel] tables) [deployment] features = [] # Cargo features common to every bundle [bundles.documents] features = ["documents"] assets = ["resources/fonts", "resources/templates/invoice.html"] ``` Assets are explicit files or directories relative to the application root; globs and symlinks are not supported. They retain their relative paths inside the function. For example, the handler opens `resources/templates/invoice.html`. Keep private runtime resources outside `public/`, which is served by the CDN. Each bundle gets a separate Cargo invocation with `--no-default-features` and only its declared features. The framework excludes other endpoints' Rust modules before compiling. Gate heavy modules in `src/` and their initialization with `#[cfg(feature = "documents")]` too: shared application code is compiled in every bundle. Ordinary Cargo builds do not select a bundle and still contain all routes. ## Inspect and build ```bash nextrs bundles plan nextrs bundles build # native release binaries nextrs bundles build --dev # native debug binaries for testing nextrs bundles build --bin my-server # choose the process adapter nextrs bundles build --vercel # Linux executable functions + routing ``` Every command accepts `--root path/to/app`. Native builds use the package's `default-run` binary, falling back to the package name. Vercel builds use `index`, require `cargo-zigbuild` and Zig, and target x86-64 Linux. Native output lives in `.nextrs/bundles//executable`. Run each process from its bundle directory so relative runtime asset paths resolve. Configure `NEXTRS_PUBLIC_DIR` if serving public assets from a separate directory. Use the manifest's route ownership to configure your self-hosted reverse proxy. The builder checks a receipt from the application build script against the route plan and refuses to package an older framework that ignored bundle selection. Vercel output lives in `.vercel/output/functions/__nextrs_functions/.func`. The output also contains `routing.json` (the generated dispatch rules), `bundle-manifest.json` (ownership/features/assets) and `bundle-artifacts.json` (executable sizes and declared inputs). These files are build reports, not public assets. Frontend client generation must run before a standalone bundle build if the app uses a generated client. ## Deploy and route requests ```bash nextrs deploy --preview nextrs deploy ``` For apps with multiple bundles, `nextrs deploy` prepares the complete browser client once, builds each server executable, copies public assets, and uploads one prebuilt deployment. It preserves configured Vercel regions and cron paths. Custom `[vercel]` build/install commands and raw `extra` settings are currently rejected for split deployment rather than silently dropped. Advanced deployments can explicitly adapt output from `nextrs bundles build --vercel`. The platform routes directly to the owning function. All methods for a URL go to that owner, allowing Axum to preserve HEAD and method-not-allowed behavior. Static routes precede dynamic and catch-all routes. Unknown paths reach the default function for 404 rendering. Internal function URLs are blocked from public dispatch. Cookies, authorization, query strings, request bodies, response headers, and streaming continue through the normal executable runtime adapter. The generated Vercel project configuration deliberately rejects stock cloud builds for split apps: use the prebuilt commands so a monolithic executable cannot accidentally be deployed in place of the selected bundles. ## Shared behavior and current limits Ancestor middleware, layouts, loading states, and not-found conventions required by a selected route are included with it. Shared state is process-local: use an external database or service when bundles must share data. Prefetch-backed React pages must remain in `default` for this first version. Moving one into a separate bundle produces a build error. Direct Rust calls across a bundle boundary are not converted into RPC; cross-bundle prefetch and remote server calls are future work. Full OpenAPI/client generation runs against the unpartitioned application before building server bundles. The [React Todos example](https://github.com/drewhirschi/nextrs/tree/main/examples/react-todos) demonstrates `/api/exports` in an `exports` bundle with an optional CSV dependency and a private resource directory. The default function omits both. --- # FAQ > Short answers about NextRS development and deployment behavior ## Do Vercel's automatic Git and pull-request previews work? Not currently. NextRS generates Vercel configuration at `.nextrs/vercel.json`, keeps it out of Git, and passes it explicitly to the Vercel CLI during `nextrs deploy`. Vercel's Git integration clones the repository and looks for committed configuration before NextRS has generated that file, so an automatic deployment can miss the Rust function, rewrites, headers, build commands, and cron declarations. Use the supported explicit preview command instead: ```bash nextrs deploy --preview ``` It generates the provider configuration locally and uploads a prebuilt preview. Preview deployments intentionally skip cron triggers. If the project is connected to a Git repository in Vercel, disable automatic deployments in the Vercel project settings. A future Git-integration design may add a committed bootstrap file, but NextRS does not generate one today. --- # Generated TypeScript Client > How typed Rust routes become a linked fetch and React Query package with editor-ready declarations nextrs generates client code from the API contract already expressed by your Rust routes. Rust is the source of truth, OpenAPI is the intermediate format, and a genuine linked npm package is the application-facing result. If you want to build one endpoint and call it immediately, start with [Client Generation: Step by Step](/docs/client-codegen). This page explains the package and inference contract behind that example. ## The pipeline ```text app/**/route.rs + #[nextrs::api] | v generated_openapi() | v .nextrs/openapi.json | v .nextrs/client/src/generated/ | fetch | react-query v v TypeScript emits JavaScript + .d.ts | v @my-app/client (linked in root node_modules) ``` Run generation at the application root: ```bash cargo nextrs client generate # equivalent: nextrs client generate ``` The command installs application dependencies at the root if `node_modules` is absent, dumps the current Rust contract, invokes Orval, builds the browser bundle, and emits the client package's JavaScript and declarations. Do not run `npm install` inside `.nextrs/client`; it is a generated workspace owned by the root project. `cargo dev`, `cargo nextrs dev`, and `nextrs dev` refresh the client before starting the watcher. Run the explicit generate command when you want a type-only refresh without starting the app. ## What defines the contract `#[nextrs::api]` marks a handler as part of the generated-client contract. An unannotated Axum handler still routes normally, which lets internal callbacks or health endpoints remain outside the client. | Contract part | Source in Rust | |---|---| | URL | File location, such as `app/api/todos/[id]/route.rs` | | HTTP method | Handler name such as `get`, `post`, or `patch` | | Client name | `operation_id`, or a name derived from method and path | | Path parameters | Axum `Path` extractor and bracketed route segments | | Query parameters | Axum `Query` extractor | | Request body | Axum `Json` extractor / documented request body | | Success body/status | Concrete response type and response declarations | | Error statuses | `responses(...)` declarations | | Object schemas | Rust types deriving `utoipa::ToSchema` | Moving a convention file changes both the route and generated contract. The URL is not repeated in a separate TypeScript definition. Document additional statuses because an error's `IntoResponse` implementation can choose its status at runtime. Successful responses narrow on `response.status`; non-success statuses reject with `HttpError` instead of resolving as successful query data. ## Two stable package entry points The root export is framework-independent. It contains typed fetch functions, request/response types, and URL helpers: ```ts import { getApiTodosById, updateTodo, type GetApiTodosByIdParams, type UpdateTodoRequest, } from "@my-app/client"; const response = await getApiTodosById(42, { neighbors: true }); if (response.status === 200) { console.log(response.data.title); } await updateTodo(42, { done: true }); ``` The `/react-query` export contains hooks, option factories, mutation helpers, query keys, generated URL-bound hooks, and the same wire types: ```tsx import { getGetApiTodosByIdQueryOptions, useGetApiTodosById, useUpdateTodo, } from "@my-app/client/react-query"; const options = getGetApiTodosByIdQueryOptions(42, { neighbors: true }); const todo = useGetApiTodosById(42, { neighbors: true }); const update = useUpdateTodo(); update.mutate({ id: 42, data: { done: true } }); ``` All values above are inferred from Rust: - path and query arguments; - request bodies and mutation variables; - success response unions and documented HTTP error bodies; - query `data` and mutation results. Application code should not annotate generated data or mutation variables as `any`. Let the generated signatures flow through callbacks and JSX. ## Why imports work in every new file The scaffold's root `package.json` declares both an npm workspace and a file dependency on `.nextrs/client`. A root `npm install` therefore links the generated package into `node_modules/@my-app/client`. The generated `package.json` publishes explicit exports: ```json { "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }, "./react-query": { "types": "./dist/react-query.d.ts", "import": "./dist/react-query.js" } } } ``` That is ordinary package resolution, not a nextrs-bundler-only alias. VS Code, `tsc`, checked JavaScript, and the browser bundler all see the same entry points. The generated package emits real JavaScript plus `.d.ts` and source maps; it does not depend on consumers importing raw TypeScript. A checked JavaScript module gets completion from the same declarations: ```js // @ts-check import { getApiTodosById } from "@my-app/client"; const response = await getApiTodosById(42, { neighbors: true }); console.log(response.data.title); // Non-success statuses reject. ``` You do not need: - `tsconfig.paths` entries; - a handwritten `declare module` shim; - relative imports into `.nextrs`; - an `npm install` inside generated output. ## Generated package ownership Do not edit these by hand. The scaffold's `.gitignore` ignores the complete generated package, contract, and browser bundle: ```text .nextrs/client/ .nextrs/openapi.json public/dist/ ``` The tracked `.nextrs/template/client` wiring is framework-owned and recreates the ignored workspace target. Generation then emits current JavaScript and declarations before validating both public package exports. Edit the Rust route or schema and regenerate. Put application React code in `app/` or `components/`, JavaScript dependencies in the root `package.json`, and Rust domain logic in `src/`. ## When to regenerate Regenerate after changing an annotated handler's: - path or HTTP method; - path or query parameters; - request body; - success or error response; - referenced schema. ```bash cargo nextrs client generate ``` ## Troubleshooting If a generated operation is missing: 1. Confirm the handler has `#[nextrs::api]`. 2. Confirm request and response schemas use the relevant serde/utoipa derives. 3. Run generation from the app root. 4. Inspect `.nextrs/openapi.json`: if the operation is absent, fix the Rust contract; if present, inspect generator output. If an import does not resolve: 1. Confirm the root `package.json` depends on `file:./.nextrs/client` and lists `.nextrs/client` as a workspace. 2. Run `npm install` once at the root, then generation. 3. Confirm `.nextrs/client/dist/index.d.ts` and `react-query.d.ts` exist. 4. Restart the TypeScript server only after the package is correctly linked; do not mask the problem with a `paths` entry. If `cargo nextrs` is missing: ```bash cargo install cargo-nextrs ``` ## Why OpenAPI Data-type conversion alone cannot describe URLs, parameter serialization, request bodies, status-specific errors, or framework integrations. OpenAPI captures the whole HTTP contract while keeping standard API tooling available. ## HTTP errors Both package entry points export `HttpError`. Generated fetch functions and React Query hooks reject non-success responses with an error containing `status`, parsed `data`, and `headers`. Hooks infer the documented error body through Orval's `ErrorType` mutator contract. Success responses retain their `{ data, status, headers }` shape. JSON error bodies are parsed; text and malformed JSON error bodies remain text. Network failures and cancellation retain the native fetch error. A malformed JSON success response is a parsing error. This behavior is owned by the scaffolder. For an existing app, create a fresh scaffold using the desired framework revision and update its framework-owned client template and generation scripts from that output. `--adopt` preserves existing files; `client generate` materializes the checked-in template and does not upgrade that template. Preserve application routes and dependencies. ### Before and after Previously, the default generated fetch code resolved a `404` or `500` response with `{ data, status, headers }`. React Query treated that resolved promise as success unless the caller checked the status and threw. The generated transport now throws `HttpError`; queries enter the error state and mutations call `onError` instead of `onSuccess`. Successful response data is unchanged. React Query's configured retry policy still applies before final error handling. ```ts import { getApiTodosById, HttpError } from "@my-app/client"; try { const result = await getApiTodosById(42); console.log(result.data.title); } catch (error) { if (error instanceof HttpError) { console.log(error.status, error.data, error.headers); } else { throw error; // Network failures, cancellation, or parsing errors. } } ``` HTTP error body types describe the documented contract, not runtime validation. A proxy can return text instead of the documented JSON. Narrow a caught error with `instanceof HttpError` before reading HTTP-specific properties; TypeScript catch variables remain `unknown`. A generated hook infers its documented error body automatically. ### Adopting this in an existing app Use a CLI revision containing this change to create a temporary scaffold with the same app name. Copy its `.nextrs/template/client/` and `.nextrs/ensure-client.mjs` into the existing app, reconcile the root `client:*` scripts with the scaffold (including `normalize-esm.mjs`), refresh the root lockfile, then run `nextrs client generate` and the app's typechecks/tests. Do not replace application routes or the root dependency list with demo files. Merely bumping the Rust dependency does not refresh checked-in client templates. Update consumers that handled `404` inside successful `data` to use `catch`, query error state, or mutation `onError`. If the application already supplied a transport with this behavior, replace that customization with the generated transport and verify that its error handling remains equivalent. --- # Client Generation: Step by Step > Turn typed Rust path, query, body, response, and error contracts into fetch functions and React Query hooks This walkthrough starts with one Rust endpoint, then adds the inputs and error cases that demonstrate end-to-end inference. The generated client requires no `any`, handwritten interface, relative generated import, or module shim. ## 1. Write a typed Rust endpoint Create `app/api/todos/[id]/route.rs`: ```rust use axum::{extract::{Path, Query}, http::StatusCode, Json}; use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; #[derive(Deserialize, IntoParams)] pub struct TodoQuery { pub neighbors: Option, } #[derive(Serialize, ToSchema)] pub struct Todo { pub id: u64, pub title: String, pub done: bool, } #[nextrs::api( get, responses( (status = 200, description = "The todo", body = Todo), (status = 404, description = "Not found"), ), )] pub async fn get( Path(id): Path, Query(query): Query, ) -> Result, StatusCode> { find_todo(id, query.neighbors.unwrap_or(false)) .await .map(Json) .ok_or(StatusCode::NOT_FOUND) } ``` The `[id]` directory and `Path` define the path argument. `Query` defines query options. Success statuses form the resolved response type; error statuses reject with `HttpError`. The default operation ID is derived from method and path; set `operation_id` in the annotation when you want a shorter public name. This remains an ordinary Axum handler. The attribute adds it to the OpenAPI document; it does not create a second RPC runtime. ## 2. Generate from the app root Install the unified CLI once: ```bash cargo install cargo-nextrs cargo nextrs client generate ``` `nextrs client generate` is equivalent. Generation refreshes the Rust contract, produces both fetch and React Query surfaces, runs the application build, and emits JavaScript and `.d.ts` files for the linked client package. Never run `npm install` in `.nextrs/client`. The root project owns dependencies and links this generated workspace. ## 3. Call the framework-independent client ```ts import { getApiTodosById, HttpError } from "@mysite/client"; try { const response = await getApiTodosById(42, { neighbors: true }); console.log(response.data.title); } catch (error) { if (error instanceof HttpError && error.status === 404) { console.log("Todo was not found"); } else { throw error; } } ``` The path argument must be a number; `neighbors` must be a boolean when present; and the `200` branch carries `Todo`. TypeScript rejects invalid calls at the call site. The package root uses the platform `fetch` API and has no React dependency in its public surface. Use it in browser modules, event handlers, or another UI framework. ## 4. Use React Query integration React-specific APIs live at the explicit subpath: ```tsx import { getGetApiTodosByIdQueryOptions, useGetApiTodosById, HttpError, } from "@mysite/client/react-query"; export function TodoDetail({ id }: { id: number }) { const todo = useGetApiTodosById(id, { neighbors: true }); if (todo.isPending) return

Loading…

; if (todo.isError) { return

{todo.error instanceof HttpError && todo.error.status === 404 ? "Not found" : "Could not load the todo"}

; } return

{todo.data.data.title}

; } const options = getGetApiTodosByIdQueryOptions(42, { neighbors: true }); ``` Query data is inferred from the fetch function. There is no need to write a response generic or annotate callback data. ## 5. Add a typed request body and mutation Add a patch handler to the same `route.rs`: ```rust #[derive(Deserialize, ToSchema)] pub struct UpdateTodoRequest { pub done: bool, } #[nextrs::api( patch, operation_id = "updateTodo", request_body = UpdateTodoRequest, responses((status = 200, description = "Updated todo", body = Todo)), )] pub async fn patch( Path(id): Path, Json(body): Json, ) -> Json { Json(update_todo(id, body.done).await) } ``` Regenerate and use the direct client: ```ts import { updateTodo } from "@mysite/client"; await updateTodo(42, { done: true }); ``` Or let the generated mutation infer its variables: ```tsx import { useUpdateTodo } from "@mysite/client/react-query"; const update = useUpdateTodo({ mutation: { onSuccess: (_response, variables) => { console.log(variables.id, variables.data.done); }, }, }); update.mutate({ id: 42, data: { done: true } }); ``` `variables.id` and `variables.data` are inferred from `Path` and `UpdateTodoRequest`. Do not annotate either as `any`. ## 6. Watch a Rust change reach TypeScript Rename `Todo.title` to `Todo.label`, then regenerate: ```bash cargo nextrs client generate ``` Every stale `.title` use now fails at the exact consumer. That is the intended feedback loop: one Rust-owned contract drives fetch calls, query results, mutation variables, success responses, typed errors, and editor completion. ## 7. Use imports from any nested file The generated package is a linked root dependency with explicit exports. A new file such as `app/todos/[id]/details/page.tsx` uses the same stable imports: ```tsx import { getApiTodosById } from "@mysite/client"; import { useUpdateTodo } from "@mysite/client/react-query"; ``` TypeScript reads `.nextrs/client/dist/index.d.ts` and `dist/react-query.d.ts`; JavaScript and the browser bundler read the matching `.js` files. Resolution does not depend on a page already existing, a nextrs runtime alias, or user-authored `tsconfig.paths`. ## The rule to remember After changing a `#[nextrs::api]` contract, regenerate from the application root: ```bash cargo nextrs client generate ``` For package ownership, troubleshooting, and contract mapping, read the [Generated TypeScript Client](/docs/typesafe-client) reference. --- # Deploy to Vercel > Run the shared Rust app as one Vercel function, regenerate frontend assets during the build, and preserve streaming A nextrs app deploys to Vercel as one Rust binary behind a catch-all rewrite. Static files and generated browser bundles are served from `public/`; dynamic requests reach the Axum router through a thin Vercel adapter. The scaffold includes this target by default. It also defaults to prebuilt deploys with git auto-builds disabled, because local Rust builds avoid the cloud build queue. The `vercel.json` build remains self-contained if you choose to re-enable cloud builds. ## One application, two process adapters Application construction belongs in `src/app.rs`: ```rust // src/app.rs include!(concat!(env!("OUT_DIR"), "/nextrs_routes.rs")); pub fn app() -> axum::Router { let public = concat!(env!("CARGO_MANIFEST_DIR"), "/public"); nextrs::router::build_router_with_public(generated_registry(), public) .merge(nextrs::openapi::spec_router(generated_openapi())) } ``` `src/main.rs` starts that app locally. Vercel currently requires its Rust function at `api/index.rs`, so the scaffold supplies a second, deliberately thin process adapter: ```rust // api/index.rs -- do not put application logic here use nextrs::vercel::StreamingVercelLayer; use tower::ServiceBuilder; #[tokio::main] async fn main() -> Result<(), vercel_runtime::Error> { let app = ServiceBuilder::new() .layer(StreamingVercelLayer::new()) .service(my_app::app()); vercel_runtime::run(app).await } ``` Both processes call the same `app()`, so routes and application layers cannot drift. The corresponding Cargo targets and runtime dependencies are: ```toml [lib] path = "src/app.rs" [[bin]] name = "my-app" path = "src/main.rs" [[bin]] name = "index" path = "api/index.rs" [dependencies] nextrs = { version = "0.5", features = ["vercel"] } tower = "0.5" vercel_runtime = { version = "2", features = ["axum"] } ``` ## Vercel configuration `vercel.json` is generated from the `[vercel]` table in [`nextrs.toml`](/docs/config) by `nextrs generate` (which `nextrs deploy` runs first) — edit the TOML, not the JSON. What it renders installs and builds from the application root: ```json { "$schema": "https://openapi.vercel.sh/vercel.json", "installCommand": "npm ci", "buildCommand": "npm run client:prepare && cargo build --release --bin index && npm run client:build", "functions": { "api/index.rs": { "runtime": "vercel-rust@4.0.11" } }, "headers": [ { "source": "/dist/(.*)", "headers": [ { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } ] } ], "rewrites": [ { "source": "/(.*)", "destination": "/api/index" } ], "git": { "deploymentEnabled": false } } ``` The `functions` block is mandatory because `vercel-rust` is a community runtime and is not selected automatically. The catch-all passes the original path to Axum, including dynamic segments. The build sequence is intentional: 1. `npm ci` installs root dependencies and links `.nextrs/client`. 2. `client:prepare` dumps `.nextrs/openapi.json` and generates both client surfaces. 3. the release Cargo build discovers routes and bundles React pages into `public/dist` while compiling the Vercel function; 4. `client:build` emits the package's JavaScript and `.d.ts` files. Do not set `NEXTRS_SKIP_BUNDLE` for a normal deploy. `public/dist`, `.nextrs/openapi.json`, and the entire `.nextrs/client` package are disposable build output. The build materializes the ignored workspace package from its tracked framework template, then verifies its JavaScript and declarations. Dependencies are installed once at the app root, never inside the generated client. `rust-toolchain.toml` pins the toolchain used by the TSX bundler dependency tree. The current scaffold uses Rust 1.96: ```toml [toolchain] channel = "1.96.0" ``` If the deploy root has `.cargo/config.toml`, keep an explicit `[build]` table alongside the `cargo dev` alias. Some `vercel-rust` versions read `config.build.target` during setup: ```toml [alias] dev = "nextrs dev --bin my-app" [build] ``` ## Deploy The default scaffold disables git-triggered builds. Deploy with the CLI: ```bash nextrs deploy # production (+ cron triggers, if declared) nextrs deploy --preview # preview ``` This regenerates config from `nextrs.toml`, runs `vercel build` on your machine, and uploads its Build Output (`scripts/deploy-prebuilt.sh` is the same Vercel steps as a plain script). See [Build Locally, Ship Artifacts](/docs/deploy-prebuilt) for setup. If you prefer Vercel cloud builds, delete the `git.deploymentEnabled: false` setting (or enable it in project settings) and push normally. The same root install/build commands regenerate everything in the cloud; no prebuilt bundle needs to be checked into git. ## Streaming through the adapter The stock `vercel_runtime::axum::VercelLayer` only streams a limited set of content types. nextrs streams `text/html`, so the stock layer can buffer the loading shell until the full page is ready. `nextrs::vercel::StreamingVercelLayer` streams the Axum response body without that content-type restriction. Non-streaming responses continue to work. If a deployed loading route has `TTFB` approximately equal to total time, first confirm that `api/index.rs` installs this layer. ## Background work after the response Detached `tokio::spawn` work is unsafe in a serverless invocation because the instance can freeze after sending the response. Use `nextrs::WaitUntil`: ```rust use nextrs::WaitUntil; pub async fn post(wait: WaitUntil, Json(req): Json) -> Json { let todo = add(req.title).await; let audit = todo.clone(); wait.wait_until(async move { audit_log(&audit).await; }); Json(todo) } ``` Behind `StreamingVercelLayer`, the future is registered with the runtime's shutdown drain. Local and container execution fall back to spawning it. Log failures inside the future because its output is discarded. ## Static assets Vercel serves root-level `public/` files before applying the catch-all rewrite. That includes user assets such as `/logo.svg` and the content-addressed files under `/dist/`. The generated immutable cache header is safe for `/dist/` because changing content produces a different filename. ## Verify after deploying ```bash curl -o /dev/null \ -w "TTFB=%{time_starttransfer}s total=%{time_total}s\n" \ https://your-deployment.vercel.app/slow ``` For a route with a delayed server prefetch, `TTFB` should be meaningfully less than total time. Preview URLs protected by Vercel authentication require the corresponding protection-bypass header. ## Removing Vercel support If Vercel is not a target, remove the whole adapter surface together: - `api/index.rs`; - the `index` Cargo target; - `vercel_runtime`, `tower` if otherwise unused, and the nextrs `vercel` feature if otherwise unused; - `vercel.json`, the `[vercel]` table in `nextrs.toml`, and the prebuilt deployment script. Keep `src/app.rs`, `src/main.rs`, and `build.rs`: they are the shared application, local process, and Rust build infrastructure, not Vercel code. --- # Deploy with Docker > Run a nextrs app on any container host — Fly.io, Railway, ECS, or a VPS A nextrs app is a plain Axum binary, so serverful deployment is the boring kind: build a release binary, ship it with the `public/` directory, run it behind a reverse proxy. A container works on any host — Fly.io, Railway, Render, ECS, Cloud Run, or a VPS with Docker installed. ## The Dockerfile A standard two-stage build (the repo ships this at the workspace root): ```dockerfile FROM rust:1-bookworm AS builder WORKDIR /build COPY . . RUN cargo build --release -p site FROM debian:bookworm-slim RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates \ && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY --from=builder /build/target/release/site /app/site COPY site/public /app/public ENV NEXTRS_PUBLIC_DIR=/app/public EXPOSE 3000 CMD ["/app/site"] ``` One detail worth knowing: **`NEXTRS_PUBLIC_DIR` points the binary at the shipped assets.** The default asset path is compiled in via `CARGO_MANIFEST_DIR`, which only exists on the build machine. The env var overrides it at runtime — set it anywhere the binary runs away from its source tree. Add a `.dockerignore` with at least `target/` and `node_modules/` so the build context stays small. ## Build and run ```bash docker build -t mysite . docker run --rm -p 3000:3000 mysite curl -i http://localhost:3000/ ``` The server binds `0.0.0.0:3000`. Map whatever host port you like. ## Streaming and the reverse proxy There's no Vercel adapter in this picture — axum streams chunked `text/html` natively, so loading shells work out of the box. The one thing that can break streaming is a **buffering reverse proxy** in front of the container. If you put nginx in front, disable response buffering for the app: ```nginx location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_buffering off; } ``` Caddy and Traefik stream by default. After deploying, run the smoke test from [Streaming](/docs/streaming#verifying-streaming-works) — if TTFB equals total time on a loading route, something in the path is buffering. ## Static assets Serverful, the binary serves `public/` itself via a router fallback (`tower-http` `ServeDir`) — same URLs as the Vercel CDN path, no extra configuration. If you want a CDN in front, point it at the same root URLs; everything under `public/` is safe to cache. ## Logs and environment The binary reads `.env` if present (via `dotenvy`) and respects `RUST_LOG` for tracing verbosity (`RUST_LOG=info` is the default). Container hosts that capture stdout get structured logs with no extra setup. --- # Case Study: Porting a 1.37M-LOC Next.js App > A 205-route production Next.js app ported to nextrs — every dev-loop dimension measured, honestly, including the two Next still wins > **The full report, with methodology and raw numbers, is served here: > [nextrs vs Next.js — the dev loop, measured](/case-studies/port-at-scale.html).** > This page is the summary. One machine, one app, same database, same day; > every number from a reproducible harness. nextrs numbers are from the > **debug** build — a conservative floor. ## The app A production SaaS dashboard: **1.37M lines of first-party TypeScript**, 205 routes, 404 models / 360 enums, ~1,935 API procedure signatures, and a ~768k-LOC React UI. The port is structurally complete (100% of routes, schema, and dispatch wired and type-checked; the React UI reused byte-for-byte via zero-copy) and behaviorally partial (~40% of procedures have real `sqlx` bodies; ~7% of the 22,680 backend tests converted, with 681 documented `PORT-GAP`s instead of fake greens). ## Headline numbers | Dimension | Next.js 16 dev | nextrs | Edge | |---|---|---|---| | First open of an unseen page (median) | 7.1 s (≤22 s) | 3.6 ms | ~1,975× | | …in a real browser | 18.7 s | 45 ms | ~400× | | Warm page | 682 ms TTI | 2.2 ms | ~300× | | Dev-server RAM (load-tested) | 16–27 GB | 46→75 MB | ~210–600× | | Minimum RAM to run at all | ~14 GB (OOM below) | <48 MB | ~300× | | Production build | 238 s → 5.7 GB | 46 s → 47 MB binary | ~5× / 120× | | Type-check | tsc 117 s (12 GB heap) | cargo check 27.5 s | ~4× | | ~1,200 DB-backed tests | vitest+Prisma ~141 s/shard | cargo-nextest 1.6 s | ~85× | | Cold start (live on Vercel) | 11.2 s to first byte | app-init ~9 ms locally | — | | React hot-edit (HMR) | **0.3–0.7 s ✅** | 2.5 s | **Next** | | Lint | **Biome 3.8 s ✅** | clippy 17.7 s | **Next** | Eleven dimensions favor nextrs; two favor the JS toolchain, stated plainly. The trade: give up ~2 s on the cheapest loop (HMR) to erase 7–22 s on the most expensive one, plus two orders of magnitude of memory. ## The recurring villain: the JS module graph The same root cause dominates three symptoms. Next's **cold start (11.2 s live on Vercel)** is Node resolving and evaluating an enormous import graph before the first byte. The **test suite** spends 445 s of CPU on `collect` (rebuilding that module graph per test file, per worker) versus 78 s actually running assertions — the real Postgres queries are *not* the bottleneck. And **dev-server memory** holds that graph resident: kernel-OOM at 4 GB and 8 GB caps; needs ~14 GB to render a page. A compiled binary has no module resolution at runtime — the graph was linked at build time. ## Does it scale? The workspace was artificially inflated 158k → 278k LOC with handler-shaped code and re-measured: the compile slope is **~24 ms per 1,000 LOC**, because dependencies (63% of the work) compile once and cache. The schema crate — the heaviest, most serial part — is 100% ported already; what remains is cheaper-per-line leaf-crate logic. Projected full-port cold build: ~45 s best case, ~2 min worst — versus `next build` at 238 s *today*. ## What it unlocks - **~7 engineer-weeks/year across 20 devs** recovered from cold-page spinners alone. - No more 32 GB-machine floor (~$0 vs $60–280/mo/dev of cloud dev boxes). - One 47 MB function instead of per-route bundles creeping toward Vercel's 250 MB ceiling — React ships as static JS on the CDN, not in the function. - The foreclosed-today unlock: **a live preview env per open PR, and agent-scale dev** — dozens of full app instances per box. Impossible at 27 GB per instance; trivial at 48 MB. ## Honesty ledger The comparison's imperfections all run *against* nextrs: the 24-core benchmark box flatters Next's heavier compile and memory; nextrs's numbers are the understating debug build; the behavioral port is partial and says so with counted, documented gaps. Where the JS toolchain wins (HMR, lint), the report says that too. Full methodology in the [complete report](/case-studies/port-at-scale.html). --- # Case Study: A Production Booking App in 6 Hours > A real gym-management app — Next.js to nextrs at full behavioral parity in one evening, then measured on every dimension > The companion to the [1.37M-LOC port](/docs/case-study-port-at-scale): same > scrutiny, opposite end of the size spectrum. A ~20k-LOC production > booking/admin app (better-auth, Postgres, S3 avatars, shadcn/radix, > drag-and-drop scheduling) converted to nextrs — **completely**, not > structurally: 24 routes × 3 personas verified flow-by-flow, money flows > step-identical, byte-level wire parity on representative endpoints. ## The conversion: ~6 hours, most of it parallel agents From first survey to verified, benchmarked conversion took **one evening** (~4.5 h wall-clock to code-complete-and-verified; ~6 h including the full benchmark suite). The React frontend was kept byte-for-byte; the entire Node backend — including all **68 React Server Actions** across 12 modules — became typed Rust endpoints with same-signature TypeScript shims, so no component noticed. better-auth was first bridged by a sidecar, then ported natively (scrypt golden vectors, PKCE, OAuth state machine; 48/48 oracle diffs against the live sidecar before deletion) — the deployed app is **one Rust binary, zero Node functions**. The conversion also *found* three latent bugs in the original: a real overbooking race (fixed with `FOR UPDATE` + count-subquery in one statement), a timezone dependency that made the JS test suite green only under `TZ=UTC`, and a credit-FIFO path with no test coverage at all. Porting is an audit. ## Dev loop: honestly, a wash at this size Measured on both variants, same machine (20 cores / 31 GB — full table in the repo's `benchmarks/results/hhh-devloop.md`): | Dimension | Next.js 16 | nextrs | |---|---|---| | Production build (cold) | **7.5 s** → 36 MB `.next` | 68.8 s → **31 MB self-contained binary** | | Type-check | tsc 1.1 s | cargo check 0.5 s (incremental) | | Tests | 374 unit tests / 0.23 s (no DB) | 115 tests **incl. real-Postgres integration** / 1.2 s | | Dev boot + first page | 3.1 s | < 0.1 s | | Unseen page in dev | 0.2–0.5 s | ~0 ms | | Dev-server RSS | **1,240 MB** | **14 MB** | At 20k LOC, Turbopack is genuinely fast: sub-second page compiles, 3 s boot. The large-app report's 7–22 s page opens are what this toolchain grows into at 1.37M LOC; this app hasn't reached the pain. If your app is this size and your dev loop is your complaint, nextrs is not the fix — apart from the two orders of magnitude of dev-server memory. ## Runtime: not a wash at any size The production gap does **not** wait for scale — measured live on Vercel, same region, same minutes, both apps self-reporting cold starts: | | nextrs | Next.js | |---|---|---| | Cold start p50 | **215 ms** (≈ its own warm: 209 ms) | 4,323 ms | | Cold start p95 | 582 ms | 4,812 ms | | Warm p50 | 209 ms | 342 ms | | Instances to serve 150-worker load | **43** | 100 | | Cold starts per 1k requests | 2.47 | 4.75 | | Local throughput, public page | 340,589 req/s | 652 req/s | | Local throughput, authed page + DB | 38,351 req/s | 389 req/s | | Serving RSS | 91.7 MB | 235.8 MB | The two effects compound: Next.js cold starts are ~2× more frequent *and* ~20× more expensive, surfacing as a 5.5 s p95 TTFB under load. nextrs's cold start is statistically indistinguishable from a warm request — loading a 31 MB static binary just isn't measurably worse than reusing a warm instance. The same module-graph cost that dominates the big app's *dev loop* dominates this small app's *production cold start*: it's one root cause with two symptoms, and it's why the runtime gap shows up long before the dev-loop gap does. ## Honesty ledger - Dev-loop numbers are single runs on a 20-core box (the large-app report used a 24-core/125 GB machine; numbers aren't cross-comparable between reports). Runtime/cold-start numbers are from the 2026-06-12 measured rounds documented in the repo's `benchmarks/results/results.md`. - The test suites aren't like-for-like: bun's 374 tests are pure in-process units; the Rust 115 include DB-backed engine tests with no bun equivalent. - `next build` beats `cargo build --release` by ~9× at this app size, and Turbopack HMR beats the rebuild-and-reload loop. Stated plainly, same as the big report. - Not verified in the conversion: Stripe webhooks, Google OAuth against live Google, SMTP (no creds in the bench environment). - The Next.js baseline has 8 pre-existing tsc errors confined to test files; its app code type-checks clean.