# Why nextrs over Next.js > 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). --- # Getting Started > Set up a nextrs app: the app/ tree, build-time codegen, and the dev loop nextrs is a Next.js-style routing framework for Rust. You write convention files (`page.rs`, `layout.rs`, `loading.html`, `middleware.rs`, `route.rs`) in an `app/` directory; a build step discovers them and wires the router. No client-side framework — pages are server-rendered HTML, streamed when a route has a loading state. ## The pieces A nextrs app is a normal Rust binary crate plus three things: ``` mysite/ ├── Cargo.toml # depends on nextrs; build-dep on nextrs with "build" feature ├── build.rs # one call: emit_registry ├── askama.toml # points Askama at app/ for templates ├── app/ # your routes (the convention tree) │ ├── layout.rs # root layout (+ layout.html Askama template) │ ├── page.rs # / │ └── hello/ │ └── page.html # /hello — static HTML needs no Rust at all ├── public/ # static assets, served at the root URL path └── src/ └── main.rs # ~15 lines: include the registry, serve it ``` `Cargo.toml`: ```toml [dependencies] nextrs = "0.1" axum = "0.8" tokio = { version = "1", features = ["full"] } askama = "0.15" [build-dependencies] nextrs = { version = "0.1", features = ["build"] } ``` `build.rs`: ```rust fn main() { nextrs::build::emit_registry("app", "src/main.rs", "nextrs_routes.rs") .expect("nextrs::build::emit_registry failed"); } ``` `emit_registry` scans `app/`, and writes a generated `generated_registry()` function into `$OUT_DIR`. It also tells cargo to rerun whenever anything under `app/` changes, so adding a file is enough — no manual wiring, ever. (A copy of the generated code is dumped to `target/nextrs/` if you want to read it.) `src/main.rs`: ```rust include!(concat!(env!("OUT_DIR"), "/nextrs_routes.rs")); #[tokio::main] async fn main() { let app = nextrs::router::build_router_with_public( generated_registry(), concat!(env!("CARGO_MANIFEST_DIR"), "/public"), ); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); axum::serve(listener, app).await.unwrap(); } ``` You own `main.rs` — pick the address, attach tower layers (the demo site adds `tower-livereload` in debug builds), read env vars. The framework only owns the router. `askama.toml`: ```toml [general] dirs = ["app"] ``` ## Your first page `app/page.rs` plus an Askama template `app/page.html`: ```rust use askama::Template; #[derive(Template)] #[template(path = "page.html")] pub struct HomePage; pub async fn render(_req: http::Request) -> String { HomePage.render().unwrap() } ``` Pages are async functions from a request to an HTML string. They can await anything — database calls, upstream APIs — and read headers, the URI, and extensions set by middleware from the request. If a page doesn't need Rust at all, skip the `.rs` file and write just `page.html`; the build step serves it statically. Run it: ```bash cargo run # Listening on 0.0.0.0:3000 ``` ## The dev loop The repo ships a file watcher that restarts the server when anything relevant changes (source, templates, content, assets): ```bash cargo run --bin nextrs-dev ``` It polls for changes, debounces, SIGTERMs the server cleanly, and restarts. Combined with `tower-livereload`, the browser refreshes itself after the rebuild. ## Where to go next - [Routing Conventions](/docs/conventions) — every file type the framework understands. - [Streaming](/docs/streaming) — how `loading` slots stream the shell before the page resolves. - [Deploy to Vercel](/docs/deploy-vercel) or [Deploy with Docker](/docs/deploy-docker). --- # Routing Conventions > page, layout, loading, middleware, and route files — what each does and how they compose Every directory under `app/` is a URL segment. Five file names have meaning inside a segment: | File | Role | Signature | |---|---|---| | `page.{rs,html}` | The content for this URL | `pub async fn render(Request) -> String` | | `layout.{rs,html}` | Wraps this segment's children (and nested routes) | `pub fn render(children: &str) -> String` | | `loading.{rs,html}` | Skeleton streamed while the page computes | `pub fn render() -> String` | | `middleware.rs` | Guard that runs before anything renders | `pub async fn handle(Request) -> MiddlewareResult` | | `route.rs` | API handlers (JSON etc.) | `pub async fn get/post/put/patch/delete/...` | For `page`, `layout`, and `loading`, both `.rs` and `.html` are accepted; if both exist, **`.rs` wins**. An `.html` file is served as-is (for layouts, `{{ children }}` is substituted literally) — zero Rust required for static segments. ## Pages ```rust use askama::Template; #[derive(Template)] #[template(path = "users/page.html")] pub struct UsersPage { pub names: Vec } pub async fn render(req: http::Request) -> String { let names = fetch_users().await; UsersPage { names }.render().unwrap() } ``` Pages receive the full request: headers, URI, and any extensions middleware inserted. They return the rendered HTML string; the framework wraps it in the layout chain and the HTTP response. ## Layouts Layouts nest: a request to `/a/b` renders `app/layout` around `app/a/layout` around `app/a/b/page`, root to leaf. ```rust use askama::Template; #[derive(Template)] #[template(path = "layout.html")] pub struct RootLayout<'a> { pub children: &'a str } pub fn render(children: &str) -> String { RootLayout { children }.render().unwrap() } ``` **Askama layouts must use `{{ children|safe }}`.** Without `|safe`, Askama HTML-escapes the children — which breaks both your page markup and the framework's internal content marker (see [Streaming](/docs/streaming) for why that marker exists). This is the most common first-run mistake. ## Loading A `loading.{rs,html}` file opts the route into streaming: the loading skeleton is sent immediately, the page handler runs concurrently, and the resolved page is swapped in on the same response. Routes without a loading slot return one synchronous response. Details in [Streaming](/docs/streaming). ## Middleware `middleware.rs` files compose root-to-leaf along the matched path and run **before** layouts, loading, 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) } ``` `MiddlewareResult::next(req)` continues (pass the request along — you may have mutated it); `MiddlewareResult::response(...)` short-circuits with a real HTTP response. Because middleware runs before the loading shell is sent, redirects and auth failures get correct status codes and headers even on streaming routes. Downstream pages read what middleware inserted via `req.extensions().get::()`. ## API routes `route.rs` exports one public async function per HTTP method. Handlers are ordinary Axum handlers — extractors in, `impl IntoResponse` out: ```rust use axum::Json; use serde::{Deserialize, Serialize}; #[derive(Serialize)] pub struct Pong { pub message: String } pub async fn get() -> Json { Json(Pong { message: "pong".into() }) } ``` The build step detects which methods a `route.rs` exports by name. A segment can have both `page.rs` and `route.rs` — the page owns GET, the route file handles the rest. **Exporting `get()` from a `route.rs` next to a page is a compile error** (the build emits `compile_error!` with the conflicting path), so the conflict can't ship. To generate a typesafe TypeScript client from your `route.rs` handlers, see [Typesafe Client Generation](/docs/typesafe-client). ## Dynamic segments A directory named `[param]` matches one path segment: ``` app/users/[id]/page.rs → /users/{id} ``` Inside the handler, extract the parameter with Axum's `Path` extractor: ```rust use axum::extract::Path; use axum::RequestPartsExt; pub async fn render(req: http::Request) -> String { let (mut parts, _body) = req.into_parts(); let Path(id): Path = parts.extract().await.unwrap(); format!("

user {}

", id) } ``` ## Static assets Files in `public/` (sibling of `app/`) are served at the root URL path: `public/style.css` → `/style.css`. Locally they're a router fallback (routes win over files); on Vercel the CDN serves them before the function is invoked (files win over routes). Don't give a route and a file the same name and the asymmetry never matters. --- # Streaming > How loading slots stream the shell before the page resolves — and how to verify it Streaming is the framework's central UX feature. When a route has a `loading.{rs,html}` slot, the server sends the loading shell to the browser **before** the page handler has finished computing — then sends the page content as a second chunk on the same response, swapping the shell out with a tiny inline script. One HTTP request, no client-side framework, no htmx. ## The model A request to a route with a `loading` slot produces a chunked response shaped like this: ``` [layout-open]
…loading content…
← server awaits the page handler here (could be 100ms, could be 2s) [layout-close] ``` The browser parses incrementally as bytes arrive: the user sees the loading shell as soon as it paints (typically under 300ms TTFB). When the page handler resolves, its content arrives inside a `