An explorable map of one request — from a packet on the wire, through routing and the
two-pass RSC and SSR render, back out as a stream, and into the hydrated client router.
Pick any stage on the map to open its deep dive.
Seven acts trace one request from the socket to a soft navigation. Each numbered stage is a
self-contained deep dive — open it to read the walkthrough and step through its diagrams. Move between
stages without leaving the map.
Act IThe mapOne request, the whole way down — before we zoom into any single stage.
Act IIFrom socket to first paintThe spine of a cold load: bytes arrive, the server boots, the router branches, React renders twice, the browser hydrates.
Act IIIThe four kinds of requestThe same URL behaves four different ways. The router's first real decision is figuring out which one it's looking at.
Act IVStreaming & SuspenseNeither render blocks on slow data. A shell flushes first; holes stream in as they resolve, in any order.
Act VSoft navigationOnce hydrated, the client router owns every move — fetching only the segments that actually change.
Act VIDown to the wireEverything before your code runs: the handshake, the transport, and the edge cache that may end the story without you.
Act VIIInside the serverRecap, then the machinery that makes the hot path cheap: manifests, the pipeline, the normalizers, the matcher.
01Overview
Overview — the request lifecycle, end to end
I'll ground a couple of the version-specific details (caching defaults, status) before going deep, since those are the parts most likely to have drifted. The core architecture I'll explain from knowledge.
I've got the current state grounded — the architecture is stable across versions, but a few names and defaults changed in Next.js 16 (which I'll fold in). Let me build this out with diagrams.
Next.js App Router request lifecycle, end to end. I'll trace a request from the socket, through the router, into React's , and back out to the hydrated client — then cover soft navigation and caching. The deep architecture (RSC + SSR passes, the , ) has been stable since the App Router shipped; I'll flag the Next.js 16 renames where they matter.
The map
Here's the whole journey before we zoom into each stage.
FIG 01App router request lifecycle overview
1. Network → server entry
A request arrives at whatever is hosting the build: a long-lived Node HTTP server from next start, a serverless function, or an edge function. Under next start the entry is NextNodeServer (a subclass of BaseServer), and its request handler does the unglamorous-but-critical work first: normalize the URL (collapse repeated slashes, strip the base path, apply i18n), then check whether the path is a static asset. Anything under /_next/static/* (the immutable, content-hashed JS/CSS chunks) or /public/* is served straight from disk without ever touching React or the router.
FIG 01·AServer entry — normalize, then the static fast-path · tap any node
The server doesn't discover routes by scanning the filesystem at request time. The build emits it loads into memory: app-paths-manifest.json maps route entries to their compiled modules, routes-manifest.json holds the rewrite/redirect/header rules and the dynamic-route regexes, and a per-route client-reference-manifest records which "use client" modules each route needs (this is what lets the server emit references to client bundles instead of their code). As of Next.js 16, Turbopack is the default bundler in dev and stable for builds, so these client references are wired through react-server-dom-turbopack rather than the old webpack loader — the manifest mechanism is the same.
FIG 01·BBuild emits the manifests — the server reads them from memory · tap any node
2. The edge layer: proxy.ts (formerly middleware.ts)
If the project defines edge interception code, it runs before routing resolves, in the lightweight (a Web-APIs-only sandbox, not full Node). It can rewrite, redirect, short-circuit with a Response, or attach headers/cookies. In Next.js 16 this file was renamed: middleware was replaced by proxy.ts to clarify the network boundary. Same position in the pipeline, clearer name — it sits between the network and the router, which is exactly where auth gating, A/B rewrites, and geolocation logic belong. Because it runs on every matched request and before render, keeping it cheap matters: heavy work here taxes every navigation.
FIG 01·Cproxy.ts sits between the network and the router · tap any node
3. Entering the router: what kind of request is this?
Once the path matches a route, the App Router's most important decision is what the client is actually asking for. The same URL behaves completely differently depending on method and a couple of Next-specific headers. This branch is the real "entering the router" moment.
FIG 02App router request type discrimination
The four cases:
A route.ts file is a — it bypasses React entirely and returns a Web Response (JSON, a stream, a redirect). This is your API layer. A POST carrying a Next-Action header is a invocation: Next deserializes the arguments, runs the referenced server function, and the response is an RSC payload describing any re-rendered UI plus the action's return value. A GET carrying RSC: 1 is a soft navigation from the client router — it wants the serialized component tree (Flight), not a full HTML page. And a plain GET with no RSC header is a hard request (first load, refresh, crawler) — it gets a full HTML document with the Flight payload inlined. That last case is where the interesting machinery lives.
4. The heart: two React renders, one request
The single most important architectural fact about the App Router is that a hard request triggers two distinct React renders, in two separate React module instances, inside the same process. People expect "SSR." What actually happens is a pipeline.
FIG 03Rsc then ssr two pass render
Walking the diagram: the router hands the renderer a loaderTree (the nested segment tree built from your layout.tsx / template.tsx / loading.tsx / page.tsx files). The RSC render runs this tree using react-server-dom-turbopack/server, in a React build compiled under the react-server export condition — a server-only React that knows how to render async components, await data, but deliberately cannot use useState/useEffect. Its output is not HTML. It's the Flight payload: a streaming, line-oriented serialization of the rendered tree. are serialized as their resulting element trees; are serialized as references into the client manifest plus their serialized props. Conceptually a row looks like:
flight.txtWIRE
1:I["chunk-abc.js","default"] // a client component reference (lazy import)0:["$","div",null,{children:["$","$L1",null,{label:"Buy"}]}] // tree referencing it
(The exact prefix letters are internal and have shifted across versions — don't depend on them.) The second render, the , runs in a normal React instance (react-dom/server) whose root reads that Flight stream via react-server-dom-turbopack/client. This pass actually executes the initial render of your Client Components on the server to produce HTML. So Client Components run twice on a cold load — once on the server for first paint, once in the browser to .
Why two instances instead of one? Isolation. The react-server realm must not expose client hooks; the SSR realm must behave exactly like the browser will. They're different module graphs by design, a bit like two address spaces sharing data through a serialized channel — Flight is the wire protocol between them. Crucially, the SSR render embeds the Flight payload into the HTML itself as a series of self.__next_f.push([...]) script calls, so when the browser hydrates it already has the component tree and needs no second round trip to reconstruct it.
5. Streaming and
Neither render blocks on slow data. A <Suspense> boundary — or its file-convention form, loading.tsx — splits the output into a shell that flushes immediately and holes that stream in as their data resolves, all over a single chunked HTTP response.
FIG 04Streaming suspense flush timeline
The shell (everything outside a boundary) reaches the browser first as paintable HTML containing the fallback markup. As each boundary's data resolves on the server, React flushes another chunk: the real content plus a tiny inline script that swaps it into the right slot. Boundaries can resolve out of order — React reconciles placement. This is the lever that kills server-client waterfalls: independent data sources stream in parallel rather than blocking the whole page on the slowest query.
Next.js 16 folds this into a unified model. The old experimental Partial Prerendering flag is gone; Next.js 16 removed the experimental PPR flag and the route-level experimental_ppr config, replaced by (cacheComponents: true), under which rendering is dynamic by default, you opt specific pieces into caching, and PPR becomes the default behavior — a statically prerendered shell served instantly, with dynamic Suspense holes streaming at request time. You opt content into the cache with the use cache directive, and cacheLife and cacheTag are now stable (no unstable_ prefix).
6. Back to the client, then soft navigation
The browser paints the streamed HTML, then loads the route's JS chunks and hydrates — React attaches to the existing DOM using the inlined Flight tree, so Client Components become interactive without re-rendering from scratch. At that point the client-side App Router boots up, holding the current route tree and a cache. Every subsequent in-app navigation is a soft navigation that never fetches HTML again.
FIG 05Soft navigation partial render loop
When you click a <Link>, the router intercepts the event (no full page load). It first checks the client ; on a hit, the segment renders instantly. On a miss it fetches the new URL with the RSC: 1 header. The key optimization is partial rendering: the server compares the current segment tree against the target and re-renders only the segments that actually change. If /dashboard/settings and /dashboard/billing share the dashboard layout, that layout is not re-rendered or re-sent — only the leaf segment's Flight comes down the wire. The client then reconciles that partial payload into its existing tree, preserving the untouched layouts (and their state). <Link> also in the background as it enters the viewport, so the cache is often already warm by click time.
Next.js 16 reworked this navigation layer: prefetching now deduplicates shared layouts across URLs and fetches only the parts not already cached, rather than whole pages — you'll see more, smaller prefetch requests. And with Cache Components on, Next uses React's <Activity> component to preserve the previous route's state by hiding it rather than unmounting on navigation.
The caching layers that gate all of this
Whether render even happens depends on four caches at different scopes:
— per-render, in-memory: dedupes identical fetch calls within one render pass so a value fetched in a layout and a page hits the network once. Lives and dies with the request.
— server-side, persistent across requests and deploys: caches fetch results, revalidated by time or by tag.
— server-side: the rendered RSC payload + HTML for statically renderable routes, produced at build/revalidation. A hit here skips the entire two-pass render and serves the prerendered output.
Router Cache — client-side, in-memory for the session: the prefetched/visited RSC payloads from the soft-navigation loop above.
The mental model shifted in v16. Under Cache Components, data is dynamic by default and you explicitly cache with use cache / cacheLife / cacheTag, which inverts the v13–v15 "cached unless opted out" default that caused so much confusion. The pipeline is the same; what changed is which branch you land on without configuration.
That's the full loop: socket → server entry → proxy.ts → router branch → RSC render → SSR render → streamed HTML+Flight → hydrate → soft-nav partial renders, with four caches deciding how much of it runs. If you want to go a level deeper on any one stage, the Flight wire format and the client-reference-manifest linkage, or the <Activity>/prefetch internals in v16, are the two richest rabbit holes.
02Network ingress
Explain the network ingress step in Next.js
"Network ingress" is everything that happens to a request before Next.js's own request handler ever sees it — and the most important thing to understand is that the shape of this stage depends entirely on how you deployed. The same app/page.tsx can be reached through wildly different paths: a pure CDN cache hit that never runs a line of your code, a at an edge PoP, or a full Node process. So "ingress" is less one step than a fork in the road.
Let me first walk the part that's identical everywhere — the raw connection — then the part that's Next-specific.
Getting bytes onto the wire
The browser resolves your hostname through DNS (often to an anycast IP that routes to the nearest edge PoP), opens a TCP connection, and runs the TLS handshake. In modern production the client almost always speaks HTTP/2 or HTTP/3 (QUIC over UDP) to the edge, even though the connection from the edge back to your origin is frequently plain HTTP/1.1. That asymmetry matters: Node's built-in http server is HTTP/1.1, so when you self-host, the multiplexing and 0-RTT benefits live in the proxy/CDN in front of Node, not in Node itself.
Since you're into OS internals, here's where it gets concrete on the Node side. The kernel accept()s the connection on the listening socket; libuv hands the readable socket to Node's networking layer; incoming bytes are fed to llhttp (the C HTTP parser that replaced the old http_parser), which parses the request line and headers incrementally. Node then constructs an http.IncomingMessage — which is a readable stream — and an http.ServerResponse, and fires the server's 'request' event. That (req, res) pair is the object Next.js's handler is built around. The body isn't buffered yet; it's still a stream you pull from, which is exactly why and can consume large uploads without materializing them in memory.
Where the request actually lands
Before any of your code runs, the edge decides whether your code needs to run at all.
FIG 06Nextjs network ingress topology
The two leftmost paths never touch your code. Static assets under /_next/static/* are content-hashed and immutable (Cache-Control: public, max-age=31536000, immutable), so the edge serves them forever without revalidating, and /public/* files are served the same way. The cached-route path is the one most production traffic to your pages actually takes: a statically rendered or route lives in the CDN as prerendered HTML, emitted with Cache-Control: s-maxage=<n>, stale-while-revalidate=<m>. A hit returns that HTML straight from the PoP and origin compute is never invoked; a stale entry is served immediately while a background revalidation refreshes it. You can see which happened in response headers like x-vercel-cache or x-nextjs-cache (HIT / MISS / STALE / REVALIDATED).
There's a subtlety here that ties straight back to the request-type fork from the previous answer. The same URL is cached as two different artifacts: the HTML document and the .rsc that soft navigations request. The CDN can't blindly serve one for the other, so Next sets a Vary on the routing headers (RSC, Next-Router-State-Tree, Next-Router-Prefetch, and the segment-prefetch header in v16). The cache key therefore includes those headers — which is exactly what lets one route be a cached HTML hit for a hard load and a separately cached Flight hit for a , both at the edge.
The three places your code can run
When the edge decides compute is needed, the request lands in one of three runtimes, and the req/res shape differs in each.
In a self-hosted next start, startServer creates a Node http.Server whose listener calls the handler from app.getRequestHandler(), i.e. NextNodeServer.handleRequest. You typically run this behind nginx or a load balancer that terminates TLS and forwards plain HTTP with X-Forwarded-Proto / X-Forwarded-Host / X-Forwarded-For. Next reads those to reconstruct absolute URLs and the client protocol, so misconfiguring the proxy (or trusting forwarded headers from an untrusted hop) is a real source of redirect-loop and wrong-origin bugs.
In a serverless deployment, there's no long-lived listener. The platform converts its native event into a Node-compatible IncomingMessage/ServerResponse through an adapter the build emits, then invokes the same Next handler. The cost you pay is cold starts — the first request to an idle instance has to boot the runtime and your module graph before any rendering begins — which is one reason the cached-route path above carries so much production load.
The edge runtime is different in kind: it runs in a V8 isolate (not Node), with only Web APIs available, and it speaks Request → Response rather than req/res. This is where proxy.ts (the file formerly called middleware.ts) and any edge-runtime routes execute. Isolates have near-zero cold start and run physically close to the user, which is why this layer is the right home for auth checks, rewrites, and geolocation — work you want on every request without an origin round trip.
The first microseconds inside Next
Whichever runtime catches the request, the handler does a small amount of normalization before routing can even begin: collapsing repeated slashes, stripping basePath, applying trailingSlash and i18n locale rules, and accounting for assetPrefix. It's also here that the static-asset short-circuit runs for self-hosted setups (on a CDN-fronted deploy that already happened at the edge). Only after the URL is canonicalized does the request cross the threshold into the router and the request-type fork — which is exactly where the earlier walkthrough picked up.
So the one-line summary of ingress: by the time your layout.tsx runs, the request has already survived DNS, a TLS handshake, an edge cache decision that may have ended the whole story without you, a runtime-specific translation into a request object, and URL normalization. If you want, I can go deeper on any single branch — the ISR/stale-while-revalidate revalidation mechanics, the edge isolate's constraints versus Node, or how X-Forwarded-* trust is configured for self-hosting.
03Server entry
What does the Next.js server entry do first?
The entry runs on two completely different clocks, and "first" applies to both. There's the work it does once, when the server instance boots, and the work it does at the top of every request before routing. The boot work is literally first, and it's what makes the per-request work cheap — so let me take them in that order.
At boot: turning the build output into an in-memory server
When next start runs, the CLI resolves to startServer, which loads and normalizes next.config.js (this is where basePath, i18n, trailingSlash, output: 'standalone', and experimental flags like cacheComponents get baked into runtime behavior), then creates the Node http.Server and instantiates NextNodeServer, the subclass of BaseServer that actually handles requests.
The single most important thing the entry does first is read the build into memory. Next does not walk your app/ directory on each request — the filesystem was already crawled at build time and compiled into JSON tables that the server loads once. The ones that matter:
routes-manifest.json — the static routes, the dynamic routes with their precompiled path regexes and param groups, plus your rewrites, redirects, and headers rules.
prerender-manifest.json — which routes are prerendered or , their revalidate windows, and the bypass/preview tokens. This is what the incremental cache consults.
app-paths-manifest.json (and pages-manifest.json) — maps a route path to its compiled server entry module.
app-build-manifest.json / build-manifest.json — which JS and CSS chunks each route needs, so the renderer can inject the right <script>/<link> tags.
middleware-manifest.json — the edge function entries and the matchers for proxy.ts.
server-reference-manifest and the per-route client-reference-manifest — the lookup tables for and "use client" references in the .
From these, the entry builds the route matcher pipeline (a RouteMatcherManager with providers for app pages, app route handlers, pages, and API routes) and initializes the incremental cache and response cache that back ISR, the , and the . All of this is amortized startup cost — and it's exactly what a serverless cold start pays for before it can answer its first request, which is why the CDN cached-route path from the ingress stage carries so much load.
FIG 07Nextjs server entry boot and per request
On each request: the prelude before routing
With everything preloaded, the per-request path is deliberately thin. The HTTP server's 'request' listener calls the handler from getRequestHandler(), which lands in BaseServer.handleRequest → handleRequestImpl. The first substantive work is normalization, and this is more involved than it looks. Beyond collapsing slashes and stripping basePath/locale, Next runs a chain of internal normalizers that strip its own routing suffixes — the .rsc Flight suffix, prefetch-RSC paths, the legacy _next/data paths — off the pathname. This matters because it's a second channel (alongside the RSC header from the request-type fork) by which the server figures out what kind of response a URL wants.
The state from all this normalization doesn't get crammed into the URL or globals; it's attached to a per-request request-meta side channel (getRequestMeta/addRequestMeta) hanging off the req object — matched locale, whether this is an RSC or request, eventually the matched params. The whole pipeline threads context through that side channel rather than re-parsing as it goes, which is how Next keeps a clean separation while building on plain Node req/res objects.
Next comes the static short-circuit: requests for /_next/static/*, /public files, and (for self-hosted deploys) _next/data are answered directly without ever consulting the router or React — the CDN already handled this for edge-fronted deployments, but the self-hosted server enforces it here. Only after a request survives normalization and the static check does the entry invoke the matcher pipeline, which runs the precompiled regexes against the pathname, attaches the matched route and params to request-meta, and hands control to the router and the request-type fork covered earlier.
The shape of this stage is a classic systems pattern you'll recognize from kernel and runtime work: do the expensive structural work once at init — parse config, load tables into memory, compile matchers into regexes — so the hot path per request is just lookups and a regex match rather than I/O and discovery. The build manifests are the build-time-to-runtime bridge that makes that possible.
If you want to keep drilling, the two richest sub-threads here are the normalizer chain (exactly how a .rsc path or prefetch request is detected and flagged) and the incremental cache handler that gets wired up at boot — the thing that actually decides ISR hits, revalidate, and tag invalidation.
04proxy.ts
What is proxy.ts and how does it differ from middleware?
This is a Next.js 16 change, and "how does it differ" deserves the specifics rather than just the headline — let me pull the official details.
The specifics here matter and a couple of them surprised me, so let me ground this properly. One thing up front: in my earlier diagrams I labeled proxy.ts as an — that's the old model, and it's wrong for Next.js 16. The runtime is exactly where the real difference lies.
proxy.ts is Next.js 16's renamed and repositioned successor to middleware.ts. It lives in the same place (project root or src/), runs at the same point in the lifecycle (before routing, on every incoming request including assets), and you write essentially the same code in it. proxy.ts replaces middleware.ts to make the app's network boundary explicit, and it runs on the Node.js runtime — you rename the file to proxy.ts, rename the exported function to proxy, and the logic stays the same.
That last detail is the correction I owe you: in my earlier diagrams I drew proxy.ts as an edge isolate. That was the old middleware model. The single most important difference in v16 is the runtime — and it goes the opposite direction from what you'd expect.
Aspect
middleware.ts (≤ v15)
proxy.ts (v16+)
File / export
middleware.ts, export function middleware
proxy.ts, export function proxy
Runtime
Edge runtime (V8 isolate)
Node.js runtime only, not configurable
Edge runtime support
Yes (default)
No — keep middleware if you need it
Conceptual framing
"middleware" (Express-style connotation)
network-boundary proxy / BFF layer
Config flag
skipMiddlewareUrlNormalize
skipProxyUrlNormalize
API surface
NextRequest / NextResponse, config.matcher
identical
Pipeline position
before routing, every request
identical
The logic you write
—
unchanged
The runtime change
Old middleware ran in the Edge Runtime — a V8 isolate with only Web APIs, near-zero cold start, executed at a PoP. proxy.ts does not. The edge runtime is not supported in proxy; the proxy runtime is nodejs and cannot be configured. If you want to keep using the edge runtime, you keep using middleware, and Vercel said they'd follow up in a minor release with edge-runtime instructions. So the practical trade is: proxy.ts gives you the full Node API surface at the boundary (real Node modules, fewer of the edge sandbox's restrictions), at the cost of the edge runtime's locality and instant cold starts. For most apps doing auth gating, rewrites, and header work, Node is fine and simpler; if your interception genuinely needed to run at the edge, that's the one case to stay on the deprecated middleware for now.
Why rename at all
The old name was the problem. "Middleware" collided with the Express-style mental model and made the feature sound more general-purpose than it is; the new name signals that this file sits at the network boundary and proxies requests before they reach your app. The rename clarifies intent in the App Router architecture — this layer handles edge-level routing, rewrites, and proxying, not in-app, Express-style logic. The framing nudges you toward treating it as a Backend-for-Frontend / network boundary concern (routing, redirects, rewrites, auth checks, header manipulation, request forwarding) rather than a place to stuff application logic.
Migration mechanics
It's mostly mechanical. Rename middleware.ts → proxy.ts, rename the exported function middleware → proxy (recommended even with a default export), and your NextRequest/NextResponse body, req.nextUrl, req.cookies, NextResponse.redirect/rewrite/next(), and config.matcher all stay exactly as they were. Config flags carrying the old name are renamed too — skipMiddlewareUrlNormalize becomes skipProxyUrlNormalize. The official codemod (npx @next/codemod@canary upgrade latest) handles most of this.
The gotcha worth flagging: a leftover middleware.ts is a silent failure mode. If you bump the version without renaming, the old middleware.ts still compiles and passes type-checks but does nothing at runtime — routes that should be intercepted aren't, and redirects that should fire don't, reportedly without an error. So don't leave the old file sitting next to the new one; delete it.
The short version: same job, same code, same position in the pipeline — different filename, different export name, and a real runtime shift from edge isolate to Node. If you want, I can show how this changes the picture for an auth-gating example, or what "keep middleware for edge" looks like concretely when you need both.
05Route matching
How does the App Router match a route?
Route matching happens in two acts: at build time the file system is compiled into precomputed route patterns, and at request time the normalized pathname is run against those patterns, the most specific match wins, params are extracted, and a nested segment tree is assembled for the renderer. The folder structure is the routing table — Next just precompiles it so request-time matching is regex lookups, not directory walking.
Folders are segments, files are behavior
Each folder under app/ is a URL segment; the special files inside a folder define what that segment does. A segment becomes publicly addressable only when it contains a page.tsx (a UI route) or a route.ts (a handler endpoint) — the two can't coexist at the same segment. The other conventions attach behavior to the subtree: layout.tsx wraps the segment and persists across navigation (the root one is required), template.tsx is like a layout but remounts each time, loading.tsx becomes the segment's fallback, error.tsx its error boundary, not-found.tsx its miss state, and default.tsx the fallback for unmatched parallel slots.
FIG 08App router filesystem to route mapping
Conventions that bend the mapping
A few folder syntaxes change what gets matched without following the plain folder-equals-segment rule:
[slug] matches exactly one path segment and exposes params.slug; [...slug] is a catch-all matching one or more trailing segments as a string array; [[...slug]] is an optional catch-all that also matches the parent path with no extra segments.
(marketing) is a route group — it organizes files and lets you scope a layout to part of the tree without adding a URL segment (as in the diagram, /blog/... not /marketing/blog/...).
_components is a private folder — the leading underscore opts the whole subtree out of routing entirely.
@analytics is a parallel route slot — it's matched independently and passed to its layout as a named prop, so one layout can render several simultaneously matched subtrees; default.tsx covers a slot that doesn't match on a hard load.
(.)photo, (..), (...) are intercepting routes — they match a route but render it inside the current layout context (the modal-overlay pattern), keyed by relative segment depth.
Precedence: when more than one pattern fits
Because static, dynamic, and catch-all folders can all sit beside each other, a single pathname can match several patterns. The matcher resolves this by specificity — the most specific match wins, which in practice means a literal segment beats a [slug], which beats a [...catchAll].
FIG 09App router match precedence
What runs at request time
By the time matching starts, the entry has already normalized the pathname (basePath, locale, and the .rsc/ suffixes stripped). The matcher then does roughly this: run the precompiled regexes from the against the pathname; among the candidates, pick the most specific per the precedence above; capture the regex groups into params; confirm the matched segment is addressable (has a page or route), and if nothing matches, resolve to the nearest not-found. The matched route and its params get stashed on the request-meta side channel rather than re-derived later.
The final product of matching isn't just "which page" — it's the , the nested structure assembled from the matched leaf back up to the root. Conceptually each node is [segmentName, { children, parallelSlots }, { layout, page, loading, error, template }], so the tree encodes the full composition: root layout wrapping the section layout wrapping the matched page, with Suspense boundaries inserted wherever a loading.tsx lives and error boundaries wherever an error.tsx does. That loaderTree is exactly the input the renderer walks in the RSC pass — which is where the from the start of this thread picks up. Route handlers (route.ts) and are matched against the same folder-derived patterns but through their own matcher providers, which is what the request-type fork selects between.
So the whole matching step reduces to: precompiled-regex lookup → specificity tiebreak → param capture → loaderTree assembly. If you want to go further, the two richest follow-ons are how parallel routes and default.tsx resolve on hard vs soft navigation, and how the loaderTree turns into the actual rendered component tree (where layouts compose and where the Suspense boundaries land).
06Two-pass render
Explain the two-pass RSC and SSR render in detail
The earlier diagram drew this as a straight line ( → Flight → HTML). The reality is more interesting: there are genuinely , running in two separate React module instances inside one server process, and the they share is reused — not regenerated — for the browser. Let me unpack all of it.
Why two React instances at all
React ships in two build flavors selected by the package's export conditions. Under the react-server condition you get a server-only React: it supports Server Components (async components, awaiting data, the use hook) and deliberately throws if you call useState/useEffect — it has no client reconciler. Under the default condition you get the React you already know, with hooks and the DOM renderers. These are different copies of the module with different capabilities, and they can't share component instances. They communicate only through serialized data. If you think in OS terms, it's two realms passing messages over a serialized channel rather than sharing memory — and Flight is that wire protocol.
The full cold-load flow
Here's everything that happens on a hard request, including the part the first diagram skipped — the browser side.
FIG 10Two pass render full coldload flow
Pass one: the RSC render
The router hands the to the renderer, which builds a React element tree (layouts wrapping the page, where loading.tsx lives) and renders it with renderToReadableStream from react-server-dom-turbopack/server (Turbopack is the default bundler in v16; the webpack variant works identically). Server Components execute here — they can be async, await their data, and touch backend resources — and then they disappear: only their rendered output is serialized. That's the mechanism behind "Server Component code never ships to the browser." What you get out is the Flight stream.
The one thing the RSC realm cannot do is run a 's code. When it encounters a "use client" module, the bundler has already replaced that import with a client reference — a small marker object carrying an id, not the implementation. The component becomes a reference plus its serialized props in the payload, and a per-route client-reference-manifest maps that id to the real chunk.
FIG 11Use client boundary reference
The Flight payload itself
Flight is a streaming, newline-delimited, row-based format. Each row has an id and a body; rows reference each other by id, which is what lets the stream resolve out of order for Suspense. A representative slice (exact prefix letters are internal and version-specific, so treat them as illustrative):
flight.txtWIRE
1:I["static/chunks/nav.js",["Nav"],"Nav"]0:["$","main",null,{"children":["$","$L1",null,{"label":"Buy"}]}]2:"$Sreact.suspense"3:["$","section",null,{"children":"…posts that finished awaiting…"}]
Reading it: I rows declare client references (row 1 points the at a chunk + export). ["$","tag",key,props] is React's element form; "$L1" is a lazy reference to row 1 — that's the Client Component slot, carrying its serialized label prop. The Suspense boundary (row 2) and the content that fills it (row 3) arrive as separate rows, possibly much later in the stream. Notice what's absent: there is no Server Component in here. Server Components rendered to host elements and references and then vanished — only their output is on the wire.
Pass two: SSR, and the tee
Now the second render. A new React tree is created whose root is createFromReadableStream (from react-server-dom-turbopack/client) wrapped around the Flight stream — so this React consumes the payload, resolving each client reference against the server-side module map and actually executing those Client Components' initial render. That tree is rendered to HTML by renderToReadableStream from react-dom/server. This is the realm with hooks, so useState initializers and render logic run here to produce markup.
The detail that matters most: the Flight stream is tee'd. One branch drives the HTML render above; the other is serialized straight into that HTML as <script>self.__next_f.push([...])</script> chunks, interleaved as rows resolve. So the browser receives the painted HTML and the exact Flight tree that produced it, from a single RSC render — no Server Component is ever rendered a second time for the client's benefit.
, and what renders twice
The browser paints the HTML immediately (Client Components are visible but inert). Then the JS loads, the client runtime drains self.__next_f to rebuild the Flight stream in the browser, createFromReadableStream reconstructs the element tree — now resolving client references to the freshly downloaded chunks — and hydrateRoot from react-dom/client attaches React to the existing DOM, wiring events and initializing state. The upshot of the whole pipeline: a Client Component renders twice on a cold load (once in the on the server for first paint, once in the browser to hydrate), while a Server Component renders exactly once, on the server, and never in the browser — it only exists downstream as Flight data.
What this forces on your code
The serialized boundary explains the App Router's hard rules, which aren't arbitrary. Props passed from a Server Component into a Client Component must survive Flight serialization — so plain data, Date, Map, Set, and promises pass, but functions (other than , which serialize as references) and class instances don't. React Context can't bridge the boundary because the two realms don't share a React instance; you pass props instead. Server Components can be async; Client Components can't, because hooks and async rendering don't mix. And the one move that lets you interleave them — a Server Component rendering a subtree and passing it as children to a Client Component — works precisely because that subtree is rendered to Flight in pass one and slotted in as serialized data, not executed inside the client.
That's the whole mechanism: one RSC render in a server-only React realm produces a Flight stream; that stream is reused to generate HTML in a second, client-React realm and is simultaneously embedded for the browser, which reconstructs and hydrates from it. If you want to keep going, the natural next layers are how Suspense boundaries flush and patch over this same stream (the streaming timeline), or how Server Actions ride the same Flight channel back to the server on a POST.
07Hydration
How does hydration and the client router work?
This is the last leg, and it closes the loop back to where the thread started. The streamed HTML the browser painted is, until now, essentially dead pixels — server-rendered markup with no event handlers and no state. brings it alive, and the moment it does, it hands the reconstructed component tree to the client router, which from then on owns every navigation without another full document load. So: hydration first, then the router.
Hydration: making the HTML live
The browser already painted the HTML, so the user sees content fast. Hydration is the separate step that makes it interactive: the route's JS chunks load (React, the Next client runtime, and the code), the inlined sitting in self.__next_f is drained and run back through the RSC client to reconstruct the exact element tree, and hydrateRoot from react-dom/client attaches React to the existing DOM — matching its tree against the server markup, wiring event listeners, initializing Client Component state, and firing effects. never re-run here; they're already baked into the reconstructed tree as data. Only Client Components execute, which is the second of their two cold-load renders.
Hydration isn't one monolithic blocking step, though. With , React hydrates progressively.
FIG 12Progressive selective hydration
React hydrates the shell first, then hydrates each Suspense boundary independently as its code and streamed content arrive — so the page becomes interactive in pieces rather than all-or-nothing. The clever part is selective hydration: if a user clicks a boundary that hasn't hydrated yet, React prioritizes hydrating that one synchronously and replays the captured event, so interaction never has to wait for the whole tree. The one failure mode to know is the hydration mismatch — if a Client Component's first client render diverges from the server HTML (from Date.now(), random values, locale differences, or reading browser-only APIs during render), React warns and falls back to a client render for that subtree.
The handoff
Hydration produces a live React tree, but it also seeds the client router. The reconstructed Flight tree isn't discarded after hydrateRoot — it becomes the router's initial state: the current segment tree plus the first entries in the . That's why the very first soft navigation already knows the current route and only needs to fetch what's changing.
The client router is a state machine
From here, the App Router client runtime is a reducer-driven state machine. It holds three things and mutates them in response to a small set of dispatched actions.
FIG 13Client router state machine
The actions, in detail
Prefetch is the quiet workhorse. A <Link> renders a real <a> but registers with an IntersectionObserver, and as it enters the viewport the router fetches the target's RSC payload with a Next-Router-Prefetch header, dropping it into the Router Cache so the eventual click is instant. Next.js 16 made this leaner: is now incremental (it fetches only the segments not already cached) and deduplicates shared layouts across multiple prefetched links, so you'll see more, smaller requests rather than whole-page prefetches.
Navigate is the soft-nav path. On a <Link> click or router.push, the router intercepts (preventDefault), then diffs the current segment tree against the target. It sends the current state tree to the server so the server knows what's already mounted; the server only the changed segments and streams back an RSC-only Flight payload (no HTML this time). The client reconstructs that subtree and merges it into the existing tree, preserving the untouched layouts and their state. A Router Cache hit skips the fetch entirely. The whole thing runs inside a React transition, so the old UI stays on screen and responsive while the new segments resolve, and you can surface pending state via useLinkStatus/useTransition.
Refresh (router.refresh()) refetches the current route from the server and reconciles the new server tree into the existing one without blowing away client component state or the DOM — the way to reflect server-side data changes after a mutation. Back/forward is the Restore action: a popstate event restores the route from the Router Cache rather than refetching, which is why history navigation feels instant. And with enabled in v16, the router uses React's <Activity> to keep a route you navigated away from in a hidden-but-mounted state, so returning to it restores its state instead of remounting.
Router Cache semantics
The Router Cache is in-memory and per-session — it holds the prefetched and visited RSC payloads keyed by segment, and it's what makes navigation feel local. Its staleness is tunable via staleTimes; notably, since v15 the default stale time for page segments is 0, so dynamic pages refetch on navigation while layouts and loading boundaries stay cached, and v16's navigation overhaul rebuilt the prefetch/cache interplay around the incremental + deduplicated model above.
And that closes the loop. A request came off the socket, survived an edge cache decision, hit the Node entry and its preloaded , was normalized and matched to a , rendered through the into streamed HTML plus an embedded Flight tree — and that same Flight tree, on arrival, hydrated into a live app and seeded a client router that now handles every further navigation by fetching only the segments that change. From here the only outbound paths left are the ones that ride this same machinery in reverse: sending a POST back up the Flight channel, and router.refresh() pulling a fresh server tree down it. Happy to trace either of those if you want to keep going.
08Request-type fork
How does Next.js decide the request type from headers?
This one turns on exact header names and behavior, which are the kind of internal detail worth confirming rather than reconstructing — let me verify before diagramming.
Let me confirm the side too, since that's the other header-driven branch.
Good — confirmed. The key thing is that "request type" isn't decided by a single header; it's a small ordered set of checks combining the matched route's kind, the HTTP method, and a handful of Next-*/RSC headers (with the path/query as a backup signal). Here's the actual decision sequence.
FIG 14Request type decision ladder
The first split is route kind, not a header: a request matched to a route.ts runs your handler for that HTTP method, and the RSC machinery doesn't apply. Everything below is how page requests get discriminated.
The headers that drive it
RSC: 1 — the marker that this is a Flight request rather than a document. The router sets it on every soft-navigation and fetch; its absence on a GET means "give me the full HTML page." RSC requests also carry Accept: text/x-component, which is the RSC payload's content type, and the response comes back as text/x-component too.
Next-Router-Prefetch: 1 — distinguishes a prefetch from a real navigation, so the server can return the prefetch-friendly (more static, partial) variant instead of a fully dynamic render.
Next-Router-State-Tree — the client's current router state, URL-encoded. The server parses and validates it (the parseAndValidateFlightRouterState path) to do — comparing it against the target so shared layouts can be skipped. A malformed or stripped value is the source of the "router state header could not be parsed" navigation error.
Next-Url — the logical URL for the router, which matters when rewrites or proxy change the physical path.
Next-Action — present on a POST, it carries the action's id and is how a Server Action is identified. Next.js identifies the server action through the Next-Action header, which contains the action's unique id; that id is an encrypted reference generated at build for each function.
There's also a path/query backup channel: prefetched and CDN-cached RSC payloads are addressed with a ?_rsc=<hash> query parameter (and a .rsc path suffix for statically stored payloads), so the request type can be recovered even when a cache layer drops the header. The entry's normalizers strip those suffixes back off before routing.
The two Server Action transports
With JS, a Server Action call is exactly the fetch the diagram describes: a POST to the current canonical URL with Accept: text/x-component, Next-Action: <actionId>, and Next-Router-State-Tree, with the arguments serialized into the body via encodeReply. Without JS — the progressive-enhancement form case — there's no Next-Action header at all; instead the action is detected from special form fields. The <form> submits multipart data containing hidden $ACTION_REF/$ACTION_KEY fields that encode which action to run and its bound arguments. So "decide from headers" has a sibling: "decide from form fields" for the no-JS path.
The response side, and a gotcha
Because one URL can legitimately be a full HTML document and an RSC payload depending on these headers, the response sets a Vary so caches don't mix them up: Vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Url. That's the contract that lets a CDN cache both variants of / separately.
The gotcha worth knowing, especially after the proxy.ts discussion earlier: these are internal headers, and the internal RSC headers such as Next-Router-State-Tree are stripped from request.headers inside proxy.ts before your code sees them. So you can't reliably branch on them in proxy/middleware (people have asked for years to detect prefetches or action names there and hit this wall), and a broad proxy matcher rewriting these requests is a known way to break soft navigations.
So the full rule: route kind decides handler-vs-page; for pages, Next-Action (or $ACTION form fields) means an action, RSC: 1 (or _rsc/.rsc) means a Flight render with Next-Router-State-Tree driving the partial render and Next-Router-Prefetch selecting the prefetch variant, and the absence of all of them means a full HTML document. If you want, I can trace what each of those branches actually returns on the wire — the action response shape versus a navigation .
09Route handlers
How do Route Handlers (route.ts) work?
Route handler basics are stable, but the caching defaults are version-sensitive, so let me confirm the current behavior before detailing it.
A is the App Router's API layer — a file-system endpoint that runs server-side and returns a raw HTTP response. It's the one branch of the request-type fork that has nothing to do with React: no RSC render, no SSR, no Flight, no , and no participation in layouts or client navigation. A route.js/route.ts is the lowest-level routing primitive; route handlers don't participate in layouts or client-side navigations the way pages do. You define one by exporting async functions named after HTTP verbs.
FIG 15Route handler dispatch flow
Method dispatch and the segment rules
The supported methods are GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS; an unsupported method returns a 405 Method Not Allowed. The file owns every verb at its segment, which is why a route.js cannot live at the same route segment as a page.js — each one takes over all HTTP verbs for that route. A handler can be nested anywhere under app/ just like a page; the difference is purely that it's an endpoint, not UI.
The signature
Each handler receives the request and a context object carrying the dynamic params, and returns a response. The platform gives you the Web-standard Request/Response, and Next extends them with NextRequest and NextResponse for conveniences like request.cookies, request.nextUrl.searchParams, and NextResponse.json()/redirect()/rewrite(). One v16 thing to watch: params is now a promise.
example.tsTS
// app/api/posts/[id]/route.tsimport { NextRequest, NextResponse } from'next/server'exportasyncfunctionGET( request: NextRequest, { params }: { params: Promise<{ id: string }> }) {const { id } = await params // v16: params is now a promiseconst post = await db.posts.find(id)return NextResponse.json(post)}
You read the body with the standard await request.json(), request.formData(), or request.text(), and you can return anything a Response can carry.
Caching: opt-in, GET only
Route Handlers are not cached by default; you can opt into caching for GET only — other methods are never cached — by using a route config option such as export const dynamic = 'force-static'. That's the previous model. Under Next.js 16's , the picture aligns with pages: when cacheComponents is enabled, GET Route Handlers follow the same prerendering model as pages, and the route segment configs like dynamic, revalidate, and fetchCache are replaced by use cache and cacheLife, with everything dynamic by default. For on-demand invalidation either way, you call revalidateTag() (or revalidatePath()) inside the handler — handlers are the canonical place to trigger revalidation after a mutation or webhook.
Runtime, streaming, and special files
By default a handler runs on the Node.js runtime, but unlike proxy.ts it can still opt into the with export const runtime = 'edge'. Because you return a real Response, you can hand back a ReadableStream — which is how route handlers serve Server-Sent Events or proxy a streaming AI response. And a handful of conventions are route handlers under the hood: sitemap.ts, opengraph-image.tsx, icon.tsx, and other metadata files are special route handlers that stay static by default unless they use request-time APIs or dynamic config.
So the whole model: route.ts registers an endpoint that owns its segment's verbs, the request method picks the exported function, it runs server-side with the Web Request/Response (plus Next's helpers) and a promised params, it's uncached unless you opt a GET in, and it never touches the rendering pipeline. That makes it the right tool for webhooks, REST/JSON APIs, OAuth callbacks, streaming endpoints, and revalidation triggers — and the wrong tool for anything you'd want to render as UI, which belongs in a page. If you want, I can contrast a route handler against a for mutations, since people often reach for the wrong one.
10Server actions
How do Server Actions work end to end?
A lets you call a server-side function from the client as if it were a local async function — but there's no magic, it's a POST round trip with the function body never leaving the server. "End to end" really has two halves: a build-time half that wires up how the call reaches the right function, and a runtime half that's the actual request. Let me take them in order.
Build time: how a call reaches a function it doesn't have
The "use server" directive marks a function as callable from the client. At build, Next detects it and does two things: on the server it assigns the function an id and registers it (in the server-reference ); in the client bundle it strips the function body entirely and replaces the import with a stub. During the build Next generates a unique action id for each "use server" function, excludes the actual function body from the client bundle, and emits only a reference created via createServerReference(actionId) that sends that id as a POST when called.
FIG 16Server action build time split
Concretely, the split looks like this:
example.tsTS
// app/actions.ts — the source you write'use server'exportasyncfunctioncreatePost(formData: FormData) {/* this body only ever exists on the server */}// what the client bundle gets after the buildimport { createServerReference } from'private-next-rsc-action-client-wrapper'exportconst createPost = createServerReference('7f9a2c…') // just an id
So the client holds a function-shaped object that, when invoked, knows only an id. That's the whole trick: the "function" you import on the client is a typed handle to a server endpoint.
Runtime: the round trip
Invoking that handle — directly inside a transition, or via <form action={createPost}> — fires one POST.
FIG 17Server action runtime round trip
Walking the round trip: the call POSTs to the current canonical URL with Accept: text/x-component, Next-Action: <actionId>, and Next-Router-State-Tree, with the arguments serialized into the body via encodeReply. On the server, the request-type fork sees Next-Action and routes into the action runtime, which looks the id up in the manifest, deserializes the args, and runs your function with full server access — it can hit the database, read cookies(), call redirect(), and invalidate caches with revalidatePath/revalidateTag. Then the important part: the response isn't just a return value. Next re-renders the current route and streams back a carrying both the action's return value and the refreshed RSC for the page; revalidatePath/revalidateTag decide which cached data that refresh reads fresh. The client reconciles the new UI into its existing tree (no full reload) and surfaces the return value to your code. One POST does the mutation and the UI update together.
Without JavaScript
Because actions are built on forms, they degrade gracefully. With a <form action={createPost}> and JS disabled, the browser does a plain form POST — there's no Next-Action header, so the action is identified from the form body instead. The form carries hidden $ACTION_REF/$ACTION_KEY fields (as multipart data) that encode which action to run and its bound arguments, and the server responds with a full-page navigation. That's why server actions are the recommended way to do mutations: the same code works pre- and post-hydration.
The React surface
You rarely call the raw handle directly. useActionState wires an action to a form and gives you back the latest return value plus a pending flag; useFormStatus exposes pending state to nested submit buttons; and useOptimistic lets you show the expected result immediately and reconcile when the real Flight response lands. When you do call an action outside a form (say, in a click handler), you wrap it in startTransition so the pending UI stays responsive — the same transition machinery the client router uses for navigation.
Security, because the boundary is a public endpoint
Every action is effectively a public POST endpoint, so the model has guardrails. Action ids are encrypted references rather than raw function names, and bound/closed-over arguments are encrypted but still sent over the wire — which is why you use React's taint APIs to keep sensitive server data from accidentally being closed over and serialized. POST gives CSRF protection in modern browsers by default, and you tighten it further with the allowedOrigins config; self-hosting across multiple instances requires syncing the encryption keys so action requests that round-robin between servers still validate. Unused actions are dead-code-eliminated so you don't expose endpoints you're not calling.
Action vs route handler
Since this is the natural pair to the last question: reach for a Server Action when you want an RPC-style mutation tied into the router and cache — it re-renders and revalidates the current page in the same round trip, and it works without JS. Reach for a when you need a generic HTTP endpoint with full control over the response: webhooks, third-party callbacks, public REST/JSON APIs, or streaming. Actions are for your own UI's mutations; route handlers are for the outside world talking to your app.
End to end, then: "use server" splits the function at build into a server-registered body and a client stub holding only an id; calling the stub POSTs that id plus encoded args; the server runs the real function, mutates, revalidates, and re-renders; and the response is a single Flight stream that updates the UI and hands back the return value — with progressive enhancement and a security model baked in because the boundary is a real network endpoint.
11RSC navigation
What is an RSC navigation request?
An RSC navigation request is the request the client-side App Router fires during a soft navigation — a <Link> click, router.push/replace, a back/forward restore, or a — to fetch the target route's output (the Flight payload) instead of a full HTML document. It's the request that makes App Router navigation feel like an SPA while the new UI is still rendered on the server. Mechanically it's a GET to the target URL with the RSC header set, and the trick that makes it cheap is that it sends the client's current router state up with it.
Here's the actual request and what comes back:
FIG 18InteractiveRsc navigation request anatomy
The fields each do specific work. RSC: 1 is the discriminator that flips the request-type fork to the Flight branch instead of returning HTML. The _rsc=<hash> query param is a cache key/buster so CDNs don't serve a stale or wrong variant. The most important one is Next-Router-State-Tree: it's the client's current route state, URL-encoded, and the server parses and validates it to drive partial rendering. Because the server knows what's already mounted, it renders and returns only the segments that change — shared layouts that both routes have are skipped entirely. Next-Url carries the logical current URL so rewrites and proxy stay aligned, and Accept: text/x-component is the RSC payload's content type.
What comes back is a Flight payload, not a page — the serialized server render of just the changed subtree. The client reconstructs it and merges it into its live component tree, preserving the untouched layouts and their state, then updates history. Nothing re-bootstraps; there's no second hydration. And because one URL can be both an HTML document and a Flight payload, the response carries Vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Url so caches key the two variants separately.
The one variation to know is navigation versus prefetch. Both set RSC: 1, but a prefetch additionally sets Next-Router-Prefetch: 1, which asks the server for the prefetch-friendly variant, and the browser fetches that RSC payload ahead of time so it's ready before you navigate. A real navigation, then, often makes no network request at all — if a prior prefetch already dropped the segments into the Router Cache, the click is a pure cache hit. The RSC navigation request on the wire is what you see only on a cache miss.
Set against a hard request, the contrast is the whole point:
Hard request
RSC navigation request
Trigger
first load, refresh, crawler
<Link>, router.push, back/forward, prefetch
RSC header
absent
RSC: 1
Sends current state tree
no
yes (Next-Router-State-Tree)
Server renders
full route, root to leaf
changed segments only
Response
HTML document + inlined Flight + JS bootstrap
Flight payload only
Client
parse, paint, hydrate
reconstruct + merge into live tree
So an RSC navigation request is, precisely, a RSC: 1 GET that uploads the current router state and downloads a Flight payload of only the segments that differ — the request type that turns server-rendered routes into instant, partial, SPA-style transitions. If you want, the last unmapped corner is the full-document (hard) request path in the same detail — what the very first byte-to-paint looks like when there's no RSC header at all.
12Full document
What happens on a full document request?
A full document request is the hard request — a GET with no RSC header, matched to a page. It's the browser's first contact with your app: the initial load, a refresh, opening a link in a new tab, or a crawler hitting a URL. It's the default branch of the request-type fork, and it's special in two ways — it's the only branch that returns an actual HTML document, and it's the only one that boots the entire client runtime that every later navigation reuses.
Everything from the earlier stages converges here. After ingress, the entry, and route matching, both React passes run: the RSC render turns Server Components into a Flight stream, the SSR render turns that Flight into HTML, and the Flight is tee'd so it's embedded inline. Next then assembles a real HTML document and streams it.
FIG 19InteractiveFull document html anatomy
The document doesn't arrive all at once. The shell — the <head>, the layout markup, and any Suspense fallbacks — flushes first, so the browser paints meaningful content while the server is still working. As each Suspense boundary resolves, another chunk streams down carrying the real markup plus a tiny inline script that swaps it into place, and the Flight tree streams alongside it as those self.__next_f.push(...) calls. The browser parses and paints progressively the whole time. Then the JS referenced by the preloads loads, drains the inlined Flight to reconstruct the tree, and hydrateRoot attaches React — at which point the page is interactive and the client router is live, seeded with the current tree and cache.
What makes this path distinct from the other three branches of the fork is worth stating plainly. It's the only one that returns an HTML document; a route handler returns a Response, a Server Action and an RSC navigation both return Flight. It's the only one that runs both React passes — an RSC navigation runs only the RSC pass, and handlers/actions don't render UI at all. And it's the only one that ships the bootstrap that starts the entire client runtime; every soft navigation afterward reuses it and only fetches changed segments. The inlined Flight is the quiet efficiency here: it means the very request that gave you HTML also gave you the data to hydrate, so there's no second round trip just to wake the page up.
Caching shapes how much of this actually runs. A statically renderable route can be served entirely from the Full Route Cache or the CDN as precomputed HTML (with its .rsc payload alongside), so no render happens at request time — that's the cached-route ingress path from the start of the thread. A dynamic route runs the two passes per request. And under Partial Prerendering, you get both: a static shell is served immediately while the dynamic content streams in through Suspense boundaries. That's also where the performance story lives — TTFB depends on cache-hit versus dynamic render, the streamed shell gives a fast first paint and LCP, and the cost you pay last is hydration, the JS needed to make Client Components interactive.
That closes the loop completely. All four branches of the request-type fork are now mapped — route handler, Server Action, RSC navigation, and full document — and the full-document path is the one that runs the whole machinery end to end: ingress, entry, routing, the two-pass render, streaming, and hydration into a live client router. Every other interaction with an App Router app is a lighter-weight variation that reuses the runtime this first request builds.
13Static shell
What is in the static shell that flushes first?
The static shell is the part of a route Next.js can produce without any request-time data — so it can be prerendered (at build, under Partial Prerendering / Cache Components) and served instantly, while everything that does depend on the request streams in afterward through Suspense boundaries. "Static shell" is PPR terminology, and the precise contents are well-defined: the static shell is all the statically renderable content plus the Suspense boundary fallbacks for the dynamic content that streams in later.
Concretely, what flushes first is: every layout's static markup (the chrome — the <html>/<head>/<body> scaffold, nav, sidebar, footer), any page content that doesn't read request-time data, anything you've explicitly cached with use cache, the <head> metadata and chunk preloads, the bootstrap script references, and — crucially — the fallbacks of your Suspense boundaries (the loading.tsx skeletons). The dynamic content itself is not in the shell; its placeholder is.
FIG 20Static shell vs dynamic holes
What stays out of the shell is anything that reads request-time APIs — cookies(), headers(), searchParams, draftMode() — or fetches uncached data. Those have to live inside a Suspense boundary; the boundary's fallback goes in the shell, and the real content streams into that slot once it resolves at request time. Under Cache Components this isn't optional: if a component accesses uncached data and isn't wrapped in <Suspense> or marked with use cache, you get an "uncached data accessed outside of <Suspense>" error at build and dev time. That error is the framework forcing you to keep the shell clean.
The reason this split matters is the behavior it replaces. In a non-PPR route, any dynamic call like cookies() or searchParams anywhere in the tree opts the entire route out of static rendering; with PPR, those calls are isolated inside Suspense boundaries, so the outer shell can still be statically cached even when a child reads a cookie. The shell's cache key is fixed at build time, which is exactly why it can be served from the edge with zero render work.
And that's why it flushes first: there's nothing to wait for. The shell is already-computed HTML, so it goes out the instant the request arrives — fast TTFB, fast first paint — while the dynamic holes are still resolving on the server and stream in behind it. So the t0 chunk in that streaming timeline is the chrome, the static content, and the skeletons; the t1/t2 chunks are the holes filling in. The shell is the instant, cacheable skeleton of the page; the boundaries are where request-time reality gets stitched in.
14Suspense stream
How does a Suspense boundary stream in later?
When a component inside a <Suspense> boundary suspends, React doesn't block the response waiting for it. It sends the fallback as part of the shell, keeps the HTTP response open (chunked), and when the data finally resolves it does something clever: it appends the real content at the very end of the document, hidden, and immediately follows it with a one-line inline script that moves that content up into the placeholder's slot. Your code does nothing; no second request is made.
FIG 21Suspense boundary stream swap
The pieces that make this work: when the shell streams, each suspended boundary leaves an addressable placeholder — React brackets it with marker comment nodes and gives it an id (the B:0 style ids and the $RC reveal function are React internals, so treat them as illustrative). The bootstrap ships a tiny inline runtime first, before your app's JS, precisely so these reveals can happen without hydration. Then, as each boundary's promise resolves on the server, React writes the resolved markup into a <div hidden id="B:0"> near the bottom of the still-open response, followed by <script>$RC("B:0", …)</script>. That script finds the hidden div and the placeholder and splices the real content into the placeholder's position, dropping the fallback. Because each boundary carries its own id, they can resolve and flush in any order — a slow one doesn't hold up a fast one behind it.
There are actually two streams interleaved in that single response. The HTML chunks with the $RC swaps are what update the painted page — and they run as pure DOM manipulation, so content appears progressively even before React has hydrated anything. Alongside them, the Flight payload streams as self.__next_f.push(...) chunks; in that payload the boundary starts as a pending reference and a later row resolves it, which is what lets the client reconstruct the full tree for hydration. So the boundary "arrives" twice in coordinated form: once as visible HTML for the eye, once as Flight data for React.
Once the main JS loads, hydration takes over for interactivity, and it's selective — React hydrates each boundary independently as its content and code are present, and prioritizes one you interact with. If a suspended component throws instead of resolving, the same channel streams the error and React swaps in the nearest error boundary's UI rather than the content.
So the t0→t1→t2 progression from the timeline is really one open HTTP response doing out-of-order delivery: the shell with placeholders goes first, then for each boundary React appends hidden content plus a reveal script that stitches it into place — fast boundaries landing before slow ones — with the Flight payload riding along so the page can hydrate when its JS is ready.
15Out of order
Can multiple Suspense boundaries resolve out of order?
Yes — and this is the whole point of the design. Independent boundaries resolve and flush in completion order, not source order. A boundary that's lower on the page but backed by fast data will stream in before a slower one sitting above it.
FIG 22Suspense out of order completion
This works because, as covered, each boundary is addressed by id and revealed by an id-targeted script, so where content lands in the page is decided by its placeholder — not by when its bytes arrive. The first boundary whose subtree fully resolves (renders without suspending) is the first one React flushes. The practical win is concurrency: independent boundaries kick off their data fetches in parallel on the server, and whichever finishes first paints first, so a slow query never blocks a fast one queued behind it. That's the streaming model's answer to request waterfalls.
There's one ordering constraint, and it's about nesting, not siblings. A boundary nested inside another can't be revealed before its parent, because the child's placeholder doesn't exist in the DOM until the parent's content has been spliced in. React handles this by queuing reveals so they apply in a valid order — a child waits for its parent. So the accurate rule is a partial order: sibling/independent boundaries are fully unordered (completion wins), while nested ones are parent-before-child.
A couple of consequences worth holding onto. Granularity is yours to set: a single boundary waits for its entire subtree before flushing, so if you wrap three async children in one <Suspense> they arrive together as a unit, whereas three separate boundaries stream independently. Where you place boundaries is literally where you set the streaming seams. And despite the byte order varying, the result is deterministic — the final DOM is identical no matter which boundary won the race, so a crawler that waits for the full stream gets the complete, correctly-ordered page. If you ever need a guaranteed reveal order (say, content that only makes sense top-down), you nest the boundaries or collapse them into one; otherwise, let them race.
So: out-of-order is the default and the feature. The shell goes first, then every independent boundary flushes the moment its data is ready, in whatever order that happens — with the only sequencing being that a nested boundary can't outrun the parent it lives inside.
16Link intercept
How does the client router intercept a Link click?
The key design decision is that <Link> renders a real <a href="...">, not a <div> with a handler. That genuine anchor is what makes right-click "open in new tab," cmd/ctrl-click, "copy link," keyboard activation, crawlers, and the no-JS fallback all work like normal browser navigation. The client-side interception is layered on top via an onClick handler that decides, per click, whether to take over or let the browser do its thing.
That decision is the whole mechanism — it only intercepts a plain, same-origin, left-click:
FIG 23Link click interception decision
The handler runs through that filter on every click. If the user held a modifier key or used the middle/right button, the browser should open a new tab or context menu, so it bails. If the anchor has target="_blank" or a download attribute, or already had defaultPrevented set by something upstream, it bails. If the href resolves to a different origin, it lets the browser do a real cross-origin navigation. Only when it's a clean, unmodified, primary-button click to a same-origin URL does it call event.preventDefault() — which cancels the browser's default document load — and hand off to the router with a push (or a replace, if you set the replace prop).
From there it's the soft-navigation path you've seen: the router dispatches a navigate action, checks the Router Cache, fetches the changed segments as an RSC payload on a miss, partially renders, merges into the live tree, and updates history with pushState — all wrapped in a transition so the current UI stays responsive. The reason the click usually feels instant is the other half of <Link>: it registers with an IntersectionObserver and, in production, prefetches the target's RSC payload into the Router Cache as the link scrolls into view (you opt out with prefetch={false}). By click time the segments are often already cached, so interception leads straight to a cache hit with no network at all.
Using a real anchor isn't just for new-tab support — it's what keeps the whole thing accessible and resilient. Keyboard users pressing Enter on a focused link trigger the same onClick path; screen readers announce it as a link with its real destination; and if the JS hasn't loaded or fails, the href still works as an ordinary navigation. The interception is an enhancement over a working baseline, not a replacement for it.
Two related notes to close the picture: programmatic navigation via router.push() skips the click entirely and dispatches the navigate action directly (same downstream path, no anchor involved), and back/forward isn't a <Link> concern at all — that's a separate popstate listener the router installs to restore from cache. So <Link>'s job is narrow and precise: render a correct anchor, and on a plain left-click, quietly call preventDefault and route the navigation through the client instead of the network.
17Router cache
What is the client Router Cache and when is it a hit?
The Router Cache's hit conditions and staleTimes defaults shifted across recent versions, so let me confirm the current behavior before I commit to specifics.
The client Router Cache is the in-memory, per-session cache the App Router keeps in the browser. It stores the RSC payloads of recently visited and prefetched routes so the router can render a page from the cache instead of fetching it from the server again, which is what makes most in-app navigation feel instant. The crucial structural fact is that it's keyed per segment, so a single navigation is usually a mix of hits and misses rather than one or the other.
FIG 24InteractiveRouter cache hit miss by segment
A lookup is a hit when the target segment is present in the cache and still within its freshness window; on a hit the client serves the cached RSC payload without hitting the server at all. Because it's per-segment, the common case during a navigation is partial: the shared layouts the two routes have in common are reused, and only the segment that actually changed is checked for freshness. This is the same partial-rendering property from the navigation flow — extending the cache window doesn't change partial rendering, so shared layouts aren't refetched on every navigation, only the page segment that changes.
Freshness is governed by stale time. The experimental staleTimes config splits it in two: the static window applies to statically generated pages, or when prefetch is true, or when you call router.prefetch; the dynamic window applies when the page is neither statically generated nor fully prefetched, and loading boundaries are reusable for the static period. The default that trips people up is from v15: page segments are not cached by default (staleTime 0), so navigating to a route refetches the latest data rather than reusing the previous payload — while layouts and loading.tsx boundaries stay cached. So out of the box, the changing page segment is typically a miss and the chrome around it is a hit. Prefetched entries get their own reuse window; since the server can send a staleTime value, the client router uses that rather than a heuristic to decide whether a prefetch entry can be reused, and cacheLife enforces a 30-second floor so prefetched data doesn't expire before a user can click it.
A lookup misses when the segment isn't cached, when its entry has gone stale, or when the cache was invalidated. Invalidation is aggressive on mutations: calling revalidateTag, revalidatePath, updateTag, or refresh from a Server Action immediately clears the entire client cache, bypassing the stale time. Two cases stand apart from all of this — back/forward navigation always reuses the cache regardless of staleness (to keep scroll position and avoid layout shift), and a hard reload bypasses the client cache entirely and requests fresh data from the server.
So the short version: the Router Cache is a per-session, per-segment store of RSC payloads; a lookup hits when the segment is cached and fresh — guaranteed for back/forward and for shared layouts during partial rendering — and misses when it's absent, stale (which, by default, the changing page segment is), or when a revalidation cleared it. That hit/miss decision is exactly the branch that, on a miss, becomes the RSC navigation request to the server.
18Changed segments
What does an RSC payload request for changed segments look like?
This is the partial-rendering side of the RSC request, so the interesting detail is how the request encodes "what I already have." Let me confirm the state-tree structure before describing it.
It's the same RSC navigation request on the wire — a GET with RSC: 1, the _rsc query param, and Accept: text/x-component. What makes it a changed-segments request is one header doing the heavy lifting: Next-Router-State-Tree. It carries the client's current router tree so the server knows which parts of the page to re-render during navigation. So the request's real content isn't the destination — it's a description of what the client already has mounted.
That value is a serialized FlightRouterState: a recursive structure where each node encodes its segment, its parallel-route slots, and a cache key. Crucially, that cache key includes the dynamic values when the segment is within a dynamic segment — so the server can tell not just which segments you have but which concrete params they're rendered for. The server reads this, compares it to the target URL's tree, and renders only from the point where they diverge.
FIG 25Rsc changed segments tree diff
The result is that the response is a partialFlight payload. Partial rendering means only the route segments that change on navigation are re-rendered; the shared layouts above the divergence point are preserved rather than re-sent. In the example, the root and dashboard layouts are common to both routes, so they stay in place — the request announces them via the state tree, and the server skips them entirely and renders only the page segment that actually changed. The client then patches that returned subtree into its existing tree at the divergence point, which is why the shared layouts keep their DOM and state across the navigation.
Granularity matters here, and it's tied to prefetching. For a dynamic route, the prefetch variant doesn't fetch the whole thing — only the shared layout, down the rendered tree until the first loading.js, is prefetched and cached for 30 seconds, so you get an instant loading state and the actual content is fetched on click. Next.js 16 pushes this further with incremental prefetching: it fetches only the segments not already in the Router Cache and deduplicates shared layouts across multiple links, so what you see on the wire is more, smaller segment-level requests rather than one whole-route fetch.
One subtlety the cache key exposes: because a segment's key includes its dynamic param values, navigating within a dynamic segment to a different value (say /[lang]/en → /[lang]/de) changes that segment's key, which marks it changed and triggers a fetch and re-render of that subtree — even though the route shape is identical. That's the same diff mechanism, just keyed on the param rather than the path shape.
So what an RSC request for changed segments looks like, precisely: a normal RSC GET whose Next-Router-State-Tree header is a serialized FlightRouterState describing every segment the client currently has (with dynamic cache keys); the server diffs that against the target, finds the shared layout prefix, and returns a Flight payload containing only the subtree from the first divergence down; and the client splices that partial tree into place. The request is essentially the client saying "here's exactly what I'm holding — send me only what's different."
19Partial render
How does partial rendering preserve shared layouts?
This is really a client-side reconciliation property, and it's worth separating the two halves. The server side is the data optimization you just saw — it skips the shared layouts and returns only the changed subtree. The preservation itself happens on the client: the existing layout component instances are never unmounted, so their state simply continues to exist. The server sending less is what makes it cheap; the client keeping the instances mounted is what preserves state.
The mechanism is React reconciliation keyed by segment. The router renders the tree through nested LayoutRouter components, one per segment level, and each renders its subtree inside a provider keyed by that segment's cache key. That key is produced by createRouterCacheKey and includes the dynamic values when within a dynamic segment. On navigation, React reconciles the new partial tree against the current one: where a segment's key is unchanged — the shared layouts — React keeps the existing component instance and DOM in place; only the segment whose key differs has its subtree torn down and rebuilt. Stable key means preserved; changed key means remount.
FIG 26Partial render layout state preservation
Because the layout's component instance survives, everything tied to it survives with it: its useState, refs, scroll position, focus, uncontrolled input values, and any Client Component state living inside it. The changed page segment, by contrast, remounts, so its local state resets. This is the concrete payoff in the diagram — the sidebar's open/closed state rides through the navigation untouched while the page's form state is gone.
This is also exactly the distinction between layout and template. Layout components are not unmounted during navigation within their scope, so state and DOM are preserved; template.tsx is like a layout but re-mounts on navigation, so state does not persist. A template gets a fresh key on every navigation, which is the deliberate opt-out when you want a reset (re-running enter animations, resetting a form per route). So preservation isn't magic you fight against — it's the default for layout, and template is the escape hatch.
The boundary is the cache key. Preservation holds only while a segment's key is stable, and since the key includes the dynamic value within a dynamic segment, navigating within a dynamic segment to a different value — /[lang]/en to /[lang]/de — changes that key and remounts the subtree, including its layout, losing the state. That surprises people, and the fix follows directly from the mechanism: put the provider or state you want to keep in a layout.tsx above the dynamic segment rather than in the route segment itself, so it sits on a node whose key doesn't change.
So the full picture of preservation: the server returns only the diverging subtree, and the client reconciles it into the existing tree by segment key — unchanged keys keep their mounted instances (and all their state), the changed key remounts. Shared layouts are "preserved" because, structurally, the client never had a reason to touch them. Put durable state on the stable-key nodes (layouts above the change), and it survives every navigation that doesn't alter their key.
20Reconcile
How does the client reconcile the new RSC payload into its tree?
Reconciliation is a pure reducer step: the client router takes the path-addressed payload the server sent, merges it into its in-memory tree by reusing everything that didn't change, and then re-renders inside a transition. The key to understanding it is that the router maintains two parallel trees — the route tree (the FlightRouterState you saw sent up in the request) and a cache tree of CacheNodes holding the actual rendered RSC payload per segment — and reconciliation updates both. (These internal names are implementation details, but the shape is what matters.)
The thing that makes the merge precise is that the response isn't an opaque blob. It's addressed: each entry is roughly [path-to-segment, new-subtree, rsc, head] — it tells the client exactly where in the tree the change applies and what the new content is.
FIG 27Client reconcile cache tree splice
The reducer walks the existing cache tree along that path and splices in a new CacheNode at the target, while keeping the ancestor and sibling nodes by reference. It's effectively copy-on-write: only the spine down to the changed segment is rebuilt, and everything off that spine — the shared layouts, untouched parallel slots — is the same node object as before. The new tree therefore shares most of its structure with the old one. That structural sharing is what later makes React's reconciliation cheap and is the other half of the layout-preservation story: the shared layout nodes are literally identical references, so React has nothing to remount.
Before it's stored, the Flight payload is deserialized by the RSC client into actual React elements — resolving any client references to their now-loaded chunks — and that becomes the rsc held in the new cache node. Importantly, that value is lazy: it can still be a streaming/suspending value, so the reducer can commit the merge before all the data has resolved, and the Suspense boundaries fill in as the rest of the stream arrives.
Once the reducer returns the new state, React re-renders. The nested LayoutRouter components read their segment's node from the new cache tree through context; because the unchanged nodes are the same references and the route tree's keys for shared layouts are stable, React keeps those instances mounted and only renders the new subtree at the changed path. The whole apply runs inside a transition, so the previous UI stays interactive on screen until the new tree is ready to commit — no flash of empty layout. On commit, the router also updates history with pushState, and if the segments came from a prefetch, that prefetched cache is what gets merged in rather than a fresh fetch.
So reconciliation, in one line: deserialize the addressed Flight payload into React nodes, walk to its path and splice in a new cache node there while reusing every node off the spine by reference, then re-render in a transition where stable keys keep the shared layouts mounted. That's the final step of the soft-navigation loop — the point where the bytes the server diffed and sent become the live, partially-updated page, with the chrome around the change never having moved.
21TLS · HTTP/2·3
What happens during the TLS handshake and HTTP/2 vs HTTP/3 for a Next.js app?
This is the connection layer — everything that happens before the first HTTP byte, and the transport it rides on. Since you're into OS internals, I'll go down to the wire here, then tie it back to why it matters specifically for a Next.js app.
The TLS handshake (TLS 1.3)
After the TCP three-way handshake (SYN, SYN-ACK, ACK) opens the socket, TLS 1.3 negotiates the secure channel in a single round trip. The client sends a ClientHello carrying its supported cipher suites, an ephemeral ECDHE key share (its half of the Diffie-Hellman exchange), the SNI extension naming the host it wants, and the ALPN list of application protocols it speaks. The server replies with a ServerHello choosing the cipher and its own key share, and — now that both sides can derive the shared secret — sends the certificate, a CertificateVerify signature, and Finished, all encrypted. The client verifies the cert chain to a trusted CA, sends its own Finished, and application data flows. Because the key exchange is ephemeral, every session has forward secrecy; a stolen long-term key can't decrypt past traffic.
Two of those fields matter enormously for how a Next.js app is served. SNI is what lets one edge IP terminate TLS for many different hostnames — it picks the right certificate before decrypting anything, which is how shared hosting and CDNs route your domain. And ALPN is where the HTTP version is actually negotiated: the client advertises h2 and http/1.1, and the server picks one inside the handshake, so by the time the channel is up, both sides already agree on HTTP/2 or HTTP/1.1. TLS 1.3 also supports 0-RTT resumption — a returning client with a pre-shared key can send "early data" in its very first flight — at the cost of replay risk, so it's reserved for idempotent requests.
HTTP/2 vs HTTP/3
HTTP/2 runs over that TCP+TLS connection (negotiated via ALPN). It adds a binary framing layer that multiplexes many request/response streams over one connection and compresses headers with HPACK — so a page's dozens of resources no longer need six parallel connections. Its Achilles' heel is at the transport: TCP is a single ordered byte stream, so one lost packet stalls every multiplexed stream until it's retransmitted. HTTP/3 fixes exactly this by moving onto QUIC, which runs over UDP and folds TLS 1.3 into the transport handshake itself.
FIG 28Http2 vs http3 head of line blocking
Because QUIC tracks each stream separately, a lost packet only blocks the stream it belonged to — the others keep flowing — which is the head-of-line-blocking fix the diagram shows. QUIC also brings two more wins: the crypto handshake is part of connection setup, so you establish transport and TLS together (often 1-RTT, or 0-RTT on resumption), and a connection carries a connection ID rather than being tied to an IP/port four-tuple, so it survives a network change like Wi-Fi to cellular without re-handshaking. One wrinkle: HTTP/3 can't be chosen by ALPN on the first TCP connection, because it's a different transport (UDP) — instead the server advertises it via an Alt-Svc response header or an HTTPS/SVCB DNS record, and the client upgrades on a later connection.
Why this matters for a Next.js app
In production you almost never get H2/H3 from next start directly — Node's built-in server speaks HTTP/1.1, and HTTP/2 requires the http2 module that's rarely wired up by hand. Instead, the edge or CDN in front (Vercel, Cloudflare, an nginx layer) terminates TLS and speaks H2/H3 to the browser, while the hop from edge to your origin Node process is frequently plain HTTP/1.1. The streaming still works end to end because a chunked H1 response from origin gets re-framed into H2/H3 frames toward the client.
The App Router amplifies why the transport choice matters. It code-splits per route and, on any page, the browser is pulling many resources at once — route JS chunks, CSS, the streamed document, the inlined Flight, and a flurry of RSC prefetch requests (especially under v16's incremental prefetching, which deliberately produces more, smaller requests). HTTP/2 multiplexing means all of that rides one connection concurrently instead of contending for a handful of TCP sockets, and HTTP/3's per-stream independence means a single slow or dropped prefetch can't stall the streamed document or the other prefetches behind it — exactly the head-of-line problem the diagram contrasts. And the faster handshakes (TLS 1.3's 1-RTT, QUIC's 0-RTT) shave latency off the very first byte of the initial document, which is the part the user is staring at a blank screen waiting for.
So the connection layer, in one pass: TCP (or QUIC) opens, TLS 1.3 negotiates keys and — via ALPN — the HTTP version in a single round trip, SNI routes you to the right cert at the edge; then HTTP/2 multiplexes everything over one TCP stream with shared-fate head-of-line blocking, while HTTP/3 over QUIC gives independent streams, an integrated faster handshake, and connection migration. For an App Router app fanning out into many small streamed and prefetched requests, H2 is a big step up over H1 and H3 removes the last shared-fate stall — all of it terminated at the edge that fronts your origin.
22CDN edge
What does the CDN edge node do before reaching Next.js?
The CDN edge node is best understood as a gatekeeper whose goal is to answer the request without reaching your Next.js origin at all — and only forward when it has no other choice. Quite a lot happens at the PoP first.
After anycast routing lands the packet at the nearest point of presence, the edge terminates TLS (the handshake from the last answer happens right here — SNI selects the cert, ALPN settles on H2/H3, and the request is decrypted). Then comes the platform's security layer: DDoS mitigation, WAF rules, rate limiting, bot and geo policies. Next the edge consults its routing table — on Vercel this is generated from the build output — to classify the request: is it a static asset, a cacheable route, something that needs an edge function, or something only origin can produce. Static assets under /_next/static/* (immutable, content-hashed) and /public/* are served straight from edge storage and never touch your code.
The pivotal step for a Next.js app is the cache lookup.
FIG 29Edge cache lookup outcomes
For a statically rendered or ISR route, the edge keys its cache on the URL plus the Vary headers — RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Url — so the HTML document and the .rscFlight payload for the same URL are stored and served as separate variants. On a hit it returns the cached response straight from the PoP and your code never runs (that's the x-vercel-cache: HIT you can see in response headers). If the entry is past its s-maxage but still inside its stale-while-revalidate window, the edge serves the stale copy immediately and fires a background revalidation to origin — that's Incremental Static Regeneration, and it's why an expiring page never makes a user wait. Only a true miss forwards to your Next.js origin, where the response is then cached per its Cache-Control. Edges also coalesce simultaneous misses for the same key into a single origin request, so a cache expiry under load doesn't stampede your origin.
If the route is wired to an edge function, that runs here too — in a V8 isolate at the PoP, before origin — handling rewrites, redirects, auth gating, or geo logic, and it can either short-circuit with a response or rewrite the request and continue. (Note the wrinkle from earlier: in Next.js 16 proxy.ts itself runs on the Node origin runtime, so this slot is for platform edge functions and edge-runtime routes, not proxy.ts.)
Whatever path is taken, the edge also massages the request and response around it: it adds forwarding and identity headers (X-Forwarded-For/Proto/Host, geo headers, a request id like x-vercel-id), strips hop-by-hop headers, and on some flows strips the internal RSC headers such as Next-Router-State-Tree from what the app sees. It negotiates content encoding too, serving brotli/gzip and compressing origin responses.
So before a request ever "reaches Next.js," the edge has terminated TLS, screened it for abuse, classified it against the routing table, served it outright if it's a static asset or a fresh cache hit, possibly run an edge function, and normalized the headers — and it forwards to your origin compute only on a genuine miss or a background revalidation. For most production traffic to static and ISR routes, that forward never happens, which is the whole point: the edge exists to keep requests off your Next.js server.
23Static at edge
How are Next.js static assets served at the edge?
Next.js static assets split into two classes, and how they're served at the edge comes down to one property: whether the filename is content-hashed.
The big class is the build output under /_next/static/* — your JS chunks, CSS, fonts, and imported media. The build gives every one of these a content-hashed filename, like /_next/static/chunks/main-a1b2c3.js, where the hash is derived from the file's contents. Because the URL is a fingerprint of the bytes, the file at that URL can never change. That's what makes it safe to serve with Cache-Control: public, max-age=31536000, immutable — cache it for a year, and the immutable directive tells browsers not to even send a revalidation request on reload.
FIG 30Content hashed immutable assets
The payoff is that cache invalidation never happens. When you deploy a change to that chunk, the build produces a new hash and therefore a new URL; the freshly rendered HTML points at the new URL, and the old file simply becomes unreferenced and ages out of caches on its own. Cache-busting is structural — it falls out of the filename — so there's nothing to purge and no risk of a stale chunk being paired with new HTML.
The other class is /public/*. Those files keep the URL you gave them (public/logo.png is served at /logo.png), so they're not content-hashed. Because the same URL could serve different bytes after you replace the file, they can't be cached immutably by default — they get a conservative cache policy, and you opt into long caching yourself (via the headers() config) only if you know a given file is effectively immutable.
Asset class
Example URL
Hashed?
Caching
/_next/static/* (build output)
/_next/static/chunks/main-a1b2.js
yes
immutable, 1 year, never revalidated
/public/*
/logo.png
no
conservative default; opt into long caching
As for the actual serving: at build/deploy these files are uploaded to the CDN's storage and distributed to PoPs. Incoming requests for those paths are matched by the edge's routing table before any compute decision, so they're answered straight from the nearest PoP — the first request to a cold PoP pulls the file once from origin storage, then it's cached there. Self-hosted, next start serves /_next/static from the .next/static directory on disk with the same immutable headers, and you put a reverse proxy or CDN in front to cache them; you can also set assetPrefix to serve /_next/static from an entirely separate CDN domain.
The link back to the rest of the pipeline is the manifests: the build records which hashed chunks each route needs, and the rendered HTML emits <script> and <link rel="preload"> tags pointing at those hashed URLs. So a page that is itself dynamic or stale-while-revalidate still references JS and CSS that are immutably edge-cached — the document and its assets have completely independent cache lifetimes. (Optimized images via next/image are a hybrid: they're served from /_next/image, optimized on demand the first time for a given size/quality, then edge-cached like the rest.)
So static assets are the simplest and fastest thing the edge does: content-hashed build output served immutably for a year and busted only by new hashes on deploy, plus public/ files on stable URLs with conservative caching — all matched ahead of any compute and served directly from the PoP, which is exactly why they never reach your Next.js server.
24Full Route Cache
How does the Full Route Cache / ISR serve a route from the CDN?
This sits right at the version boundary — the Full Route Cache and ISR have a different shape under v16's Cache Components — so let me confirm the current mechanics before detailing them.
The thing to get straight first is that "Full Route Cache" and "the CDN" are two different caches, and serving a route from the CDN is really these two layers cooperating. The Full Route Cache is the server-side store of a route's rendered output — its prerendered HTML plus the RSC payload — produced at build and kept on the Next.js side. The CDN edge cache is a separate cache sitting in front of your origin, and the only thing that connects them is the Cache-Control header the route emits.
That header is the bridge. ISR pages are served with Cache-Control: s-maxage={revalidate}, stale-while-revalidate={expire - revalidate} (the default expire is one year, so stale-while-revalidate is included by default), while dynamic pages get private, no-cache, no-store, max-age=0, must-revalidate; CDNs that respect s-maxage and stale-while-revalidate can cache static and ISR pages at the edge. So at build, under v16's Cache Components, Next.js prerenders routes by default into static HTML plus an RSC payload before any request — that's the Full Route Cache entry — and it goes out with the ISR caching header that lets the CDN hold a copy.
FIG 31Full route cache cdn isr layers
A request is answered by the first layer that has a fresh copy. Within the s-maxage window the CDN serves its cached copy and your origin is never touched. Past it, the request reaches origin, where the Full Route Cache can still answer without re-rendering — and that's where ISR comes in, in two flavors.
Time-based ISR is stale-while-revalidate. The cached content is served immediately, and a background regeneration is triggered when the content's age exceeds the cacheLife/revalidate duration; the stale content keeps being served until the fresh content is ready. That background regeneration runs on the instance that received the triggering request, which on per-request billing is extra compute to be aware of. You shape the windows with cacheLife: stale is how long the client can use cached data without checking, revalidate is when the next request triggers a background refresh, and expire is the point after which a request waits for fresh content.
On-demand ISR is the other trigger: from a Server Action or Route Handler you call revalidatePath or revalidateTag(tag, 'max'), which marks the entry stale so the next visit uses stale-while-revalidate semantics. The important caveat is timing and scope: revalidatePath invalidates the cache entry but regeneration happens on the next request, not immediately.
And here's the part specific to "serving from the CDN" that trips people up: on-demand revalidation only invalidates the server's Full Route Cache. CDN-level caching alone doesn't support on-demand revalidation — revalidateTag()/revalidatePath() invalidate the Next.js server cache, but the CDN keeps serving its cached copy until the s-maxage TTL expires; to propagate the change, you trigger a CDN purge alongside the revalidation call, for both the HTML and RSC variants. Managed platforms like Vercel wire that purge up automatically; self-hosted behind your own CDN, you have to call the purge API yourself, or your "instant" content update won't actually be instant at the edge.
A few operational notes complete the picture. Because the route is cached as both an HTML document and an RSC payload, they must be cached together with the Vary header respected, or users will see mismatched content during client-side navigation. You can watch all of this through the x-nextjs-cache response header, whose values are HIT, STALE, MISS, or REVALIDATED, and note that ISR is only supported on the Node.js runtime. And the boundary back to the earlier PPR discussion: caches with very short lifetimes — zero revalidate or expire under five minutes — are automatically excluded from the prerender and become dynamic holes instead, which is how a route can be partly in the Full Route Cache (the shell) and partly rendered per request.
So the full mechanism: the route is prerendered into the Full Route Cache as HTML + RSC and emitted with s-maxage/stale-while-revalidate; a CDN that honors those headers serves it from the edge until the TTL lapses; time-based ISR regenerates the server-side entry in the background once it ages past revalidate; on-demand revalidation marks it stale for the next request but leaves the CDN untouched unless you also purge it. The CDN gives you the instant edge delivery, the Full Route Cache keeps origin from re-rendering, and ISR is the machinery that keeps both fresh without a redeploy.
25Edge ingress
How does an edge function / proxy.ts run at ingress?
The proxy.ts runtime story had a "to be continued" in 16.0, so let me check whether 16.2 changed the edge-runtime situation before I describe how it runs.
The diagram node lumps two things together that Next.js 16 deliberately pulled apart, so the first job is to separate them. An "edge function" is code that runs in a V8 isolate at the CDN's point of presence — close to the user, near-zero cold start, Web-APIs-only sandbox. proxy.ts is the App Router's request interceptor, and as of v16 it runs on the Node.js runtime at your origin region, not at the edge. Both execute at ingress before routing, but they run in different places with very different capabilities.
FIG 32Edge function vs proxy ingress path
The edge function
An edge function runs in a V8 isolate spun up at the PoP, geographically near the user. Isolates are cheap to start — there's no Node process or container to boot — so cold starts are effectively nothing, which is why this layer is good for work you want on every request without an origin round trip. It receives a Web-standard Request and returns a Response, runs before the request reaches origin, and can short-circuit (redirect, rewrite, respond) or pass through. The price is a restricted environment: the Edge Runtime supports only a limited set of APIs (it does support AsyncLocalStorage and process.env), native Node.js APIs aren't available — you can't read or write the filesystem — node_modules only work if they're ES Modules without native APIs, require isn't allowed, and ISR isn't supported. The request body can also only be read once.
proxy.ts
proxy.ts runs at ingress on every matched request, before routing — same position the old middleware had — but the runtime changed. The edge runtime is not supported in proxy; the proxy runtime is nodejs and it cannot be configured. As of Next.js 16, middleware.ts was renamed to proxy.ts and now runs on the Node.js runtime instead of the edge runtime. That's the whole point of the change: the full Node API surface is available, so things that never worked in the edge isolate — auth libraries that open TCP sockets, direct database access, native modules — now work in your interceptor. It still uses NextRequest/NextResponse and the same redirect/rewrite/next model; what moved is where and with what APIs it runs. The trade against an edge function is the mirror image: full capability, but it executes in your origin region rather than at the PoP, so you lose the locality and the instant cold start.
The edge runtime is still reachable
Two escape hatches remain if you genuinely need edge execution. middleware.ts still works and still uses the Edge Runtime — it's just deprecated, so you keep it specifically for edge interception cases, and if you want to continue using the edge runtime, you keep using middleware. Separately, the Edge Runtime is still available for Route Handlers via the runtime = 'edge' export, so individual endpoints can opt into running as edge functions.
Choosing between them
The practical rule: reach for proxy.ts (Node) as your default interceptor — it's simpler and has no API restrictions, which covers most auth gating, rewrites, redirects, and header work. Reach for an edge function (legacy middleware.ts, or an edge route handler) only when the locality and near-zero cold start genuinely matter and your code fits the isolate's constraints. The two aren't really the same feature anymore: one is geographically-distributed, restricted, instant compute at the PoP; the other is full-Node interception at your origin. Both still sit at the same point in the lifecycle — ahead of route resolution — with the edge function able to resolve a request entirely at the PoP before it ever reaches your region, and proxy.ts running once the request arrives there, just before the router takes over.
26Origin receive
How does the origin Node or serverless function receive the request?
On a cache miss the edge forwards the request to origin compute, and "origin" comes in two fundamentally different shapes — a long-lived Node server or a serverless function. They receive the request through completely different mechanisms but converge on the exact same Next.js request handler.
FIG 33Origin node vs serverless receive
The long-lived Node server
This is what next start runs: a persistent process with http.createServer listening on a port. The edge or load balancer in front proxies the request to that port over a kept-alive TCP connection — usually plain HTTP/1.1 on the origin side, since TLS was terminated upstream. From there it's ordinary Node networking: the kernel accept()s the connection, libuv hands the socket to Node, llhttp parses the bytes into an http.IncomingMessage and http.ServerResponse, and the 'request' event fires the handler returned by getRequestHandler() — which is NextNodeServer.handleRequest over BaseServer. The defining property is that the process is warm: the manifests and in-memory caches were loaded once at boot, and a single process serves many requests concurrently through the event loop. Because TLS terminated at the edge, Next reconstructs the original protocol, host, and client IP from the X-Forwarded-Proto/Host/For headers the proxy added. The output: 'standalone' build produces a minimal server.js that boots exactly this server with only the dependencies it needs, for containers and self-hosting.
The serverless function
Here there's no listener at all. The platform invokes a function per request (reusing a warm instance when one exists), and the request doesn't arrive as a socket — it arrives as a platform-native event object (an API Gateway / Function URL event on Lambda, an invocation payload on Vercel). An adapter emitted by the build translates that event into Node-compatible IncomingMessage/ServerResponse objects and then calls the same Next handler, so Next's core never knows the difference. The systems contrast with the Node server is the interesting part: instead of a persistent process accepting connections off an event loop, you get an ephemeral invocation, and if no instance is warm you pay a cold start — booting the runtime and loading the module graph and manifests before any handling. Concurrency is typically one request per instance with the platform scaling instances out, rather than one process multiplexing many. Builds often split the app into per-route functions, and the edge's routing table picks which function to invoke for the matched path. Streaming responses work too, as long as the platform supports response streaming — which the App Router's streamed SSR depends on.
One interface, two deliveries
The throughline is that both paths end at the same place: a req/res pair handed to Next's request handler. (The edge runtime is a third shape, where a route or function receives a Web Request and returns a Response inside a V8 isolate — a different interface, covered earlier.) Everything downstream from here — normalization, route matching, the two-pass render — is identical regardless of how the bytes were received; only the receipt mechanics differ.
Next.js 16 also generalized this with a stable Build Adapters API, which lets alternative hosting providers hook into the build to emit their own server or function artifacts and receive requests natively — so the "how does origin receive it" step is no longer hardcoded to one platform's conventions.
So, concisely: a Node server receives the request as a real socket connection accepted by a warm, long-running process and parsed into req/res; a serverless function receives it as an event object that a build-emitted adapter reshapes into req/res, possibly after a cold start; and both then call the identical Next.js handler. The edge decides whether to forward; the deployment model decides how the request lands when it does.
27Recap
Recap — request entry to two-pass render
Pulling the middle of the journey together — from the moment the request reaches Next's handler to the streamed bytes going back out.
FIG 34Router entry to two pass render recap
Once the request reaches Next's handler (BaseServer.handleRequest), it enters the router in three quick beats. First, normalization: collapse slashes, strip basePath and locale, peel off the internal .rsc/prefetch suffixes, and short-circuit static assets. Then matching: the precompiled regexes from the build manifests run against the pathname, the most specific candidate wins (static beats dynamic beats catch-all), params are captured, and the matched leaf is assembled back up to the root into the loaderTree — the nested structure of layouts wrapping the page, with Suspense boundaries where loading.tsx lives. Then the request-type branch reads the method and the Next-Action/RSC headers to decide what to produce; for a plain GET with no RSC header, that's a full document, which sends the loaderTree into the renderer.
The two-pass render is two React renders in two separate React module instances inside one process, linked by the Flight stream. The first pass, in the react-server realm, executes the Server Components — they fetch data, render, and then vanish, leaving behind the Flight payload: their output as serialized host elements, with Client Components encoded as references plus serialized props. The second pass, in the ordinary react-dom/server realm, consumes that Flight stream, runs the Client Components' initial render, and produces HTML — and the same Flight stream is tee'd inline into that HTML as self.__next_f chunks, so the browser gets the data to hydrate without a second request.
From there it streams: the shell flushes first for a fast paint, each Suspense boundary's content streams in as it resolves, and once the JS loads the browser hydrates and the client router takes over — after which every further navigation is an RSC request that only fetches the changed segments.
So the whole middle, in one line: normalize → match into a loaderTree → branch to the document path → RSC render to Flight → SSR render to HTML with Flight inlined → stream the shell then the holes. The router decides what to render and assembles the tree; the two passes turn that tree into a streamed page carrying its own hydration data.
28Manifests
What manifests does Next.js load at boot, and what is each for?
At boot, Next.js reads a set of precomputed JSON manifests into memory — they are the bridge from build time to runtime, and they are why per-request work is cheap lookups rather than filesystem discovery. (The exact filenames are implementation details that shift between versions, and with Turbopack as the default bundler in v16 these are generated by Turbopack rather than the old webpack plugins, but the roles are the same.) The main ones:
Manifest
What it is for
routes-manifest.json
The routing table — static routes, dynamic routes with their precompiled regexes and param groups, plus rewrites, redirects, headers, basePath, and i18n. This is what matching runs against.
app-paths-manifest.json (+ pages-manifest.json)
Maps each matched route path to its compiled server entry module, so when a request arrives Next does not discover routes dynamically — it consults this manifest to load the right code.
app-build-manifest.json / build-manifest.json
Maps each route to the JS/CSS chunks it needs, so the renderer injects the correct <script>/<link> tags; the client-side _buildManifest.js maps URLs to chunks and powers client-nav preloading on <Link> hover.
prerender-manifest.json
Which routes are prerendered/ISR (the server checks prerenderManifest.routes[pathname] to decide isSSG), their revalidate windows, fallback config, and preview/bypass tokens. Backs the Full Route Cache and ISR.
*_client-reference-manifest.js (per route)
Maps each "use client" module reference to its chunk URL and export, so the Flight payload can reference Client Components and the SSR/client passes can resolve them.
server-reference-manifest.json
Maps each Server Action ID to its function and holds the encryptionKey for action arguments; looked up when a Next-Action POST arrives.
middleware-manifest.json
The edge function / middleware (and proxy) entries and their matchers — what, if anything, runs at ingress.
A few smaller ones round it out: react-loadable-manifest.json (dynamic next/dynamic import → chunk mapping), next-font-manifest (font preloading), and required-server-files.json (used by output: 'standalone' to know which files and manifests to ship and load).
The reason this all happens once, at boot, is the performance shape from the server-entry stage: pay the structural cost — parse config, load these tables, compile the route matchers — a single time, so the request hot path is just regex matches and object lookups against in-memory data. It is also exactly what a serverless cold start has to redo before it can answer, which is the latency the edge cache layer works so hard to avoid.
29BaseServer pipeline
What is BaseServer.handleRequest and the request pipeline?
Real stack traces give us the actual call chain, from the socket down to the bytes going back out.
FIG 35BaseServer request pipeline
The key idea is that BaseServer is the runtime-agnostic abstract class — it does not know about Node's IncomingMessage or about a serverless platform's event object or about an edge Request. Each environment gets a subclass (NextNodeServer for the long-lived Node server, a web/edge variant for isolates) whose job is to wrap the platform's native request/response into the adapter shape BaseServer expects, then hand off. That is why the same handleRequest runs whether you are on a VPS, a serverless function, or the edge.
Walking the chain:
router-server is the outer worker that owns the socket. It is where proxy.ts (the renamed middleware) runs, and where early rewrites/redirects resolve; if the request survives that, it calls invokeRender, which crosses into the render server.
BaseServer.handleRequest is deliberately thin. It establishes the tracing boundary — it extracts any propagated context from req.headers via withPropagatedContext() and opens the BaseServer.handleRequest span — and immediately delegates to handleRequestImpl in base-server.js. Splitting it this way keeps the span wrapper separate from the logic, so the trace cleanly brackets the whole request.
handleRequestImpl is the actual pipeline, and it does four things in order:
Parse — ensure the URL and query are parsed into a structured object.
Normalize — run the path through a chain of normalizers that strip and decode the special markers Next puts on the wire: the .rsc / .prefetch.rsc suffixes for RSC navigations and prefetches, the postponed-resume marker for PPR, the action marker for Server Actions, basePath, and locale prefixes for i18n. Each one peels off its marker, recovers the logical route, and records what it found as flags in request meta — the side channel that rides alongside the request so later stages know "this is a prefetch" or "this is an action" without re-parsing headers.
Match — run the normalized pathname against the in-memory matchers (the precompiled regexes from routes-manifest) to find the route and assemble its component tree.
Dispatch — hand off to the right handler. For a page/app render that is handleCatchallRenderRequest on NextNodeServer, which calls renderToResponse → the app-render path (the two-pass RSC + SSR render). A route handler dispatches to its GET/POST export instead; a static asset short-circuits before any of this.
Whatever the dispatch produces — a stream, a string, a redirect — lands at pipeToNodeResponse, which writes it to the ServerResponse (and is exactly where you see those failed to pipe response errors when a stream breaks mid-flight).
So the mental model: handleRequest is the traced front door, handleRequestImpl is the normalize-match-dispatch core, and everything above it is adapter plumbing that exists only to make one platform's request look like every other's.
30Normalizers
How does Next.js normalize the URL, locale, and RSC suffixes?
The mental model worth holding first: one logical route can arrive wearing several different "wire dresses," and normalization strips the costume back to the canonical path while writing down what the costume was. The current normalizer set on BaseServer is { rsc, prefetchRSC, segmentPrefetchRSC, data } (the old postponed one has been removed, and segmentPrefetchRSC was added for segment-level prefetching).
FIG 36URL normalization funnel
The precise mechanics, in the order they apply:
basePath comes off first. If you have set basePath: '/app', every public URL is prefixed with it, but none of your route folders are — so the prefix is stripped immediately, and everything downstream (matching, the other normalizers) sees clean paths. It is purely a deployment-mounting concern, gone before any real work.
Locale is next, and here is the important caveat: built-in locale normalization is a Pages Router feature. When you configure i18n in next.config.js, Next detects a leading locale segment (/en/blog/post), strips it to /blog/post, and records locale in request meta so rendering knows which language to serve. The App Router has no built-in locale routing — there, /[lang]/blog/post is just an ordinary dynamic segment, matched and remounted like any other param. So if you are on the App Router, "locale normalization" is really your own proxy.ts plus a [lang] segment, not this built-in step.
RSC suffixes are the heart of the matter. The same route can be requested as HTML or as a Flight payload, and the payload variants are encoded as suffixes on the path so a CDN can cache each as a distinct URL. The normalizer set is rsc, prefetchRSC, segmentPrefetchRSC, and data:
RSCPathnameNormalizer strips a trailing .rsc — a full RSC navigation payload for the route.
PrefetchRSCPathnameNormalizer strips .prefetch.rsc — the prefetch variant, which is the static-shell-only payload <Link> fetches ahead of time.
SegmentPrefixRSCPathnameNormalizer handles the per-segment prefetch form, used by the newer segment-level prefetching under Cache Components, where individual layout/page segments are fetched and cached independently rather than one payload for the whole tree.
Each one does the same two things: recover the canonical pathname (so matching finds one route regardless of which payload was asked for) and set a meta flag (isRSCRequest, isPrefetch, segment info) so the renderer knows to emit Flight instead of an HTML document, and to emit only a shell for a prefetch.
_next/data is the Pages Router analogue: NextDataPathnameNormalizer turns /_next/data/<buildId>/blog/post.json back into /blog/post, which is how Pages requests getServerSideProps/getStaticProps data during client navigation. The <buildId> in the path is also the cache-bust — a new deploy changes it, so stale data URLs miss.
One concrete way to see all this: the skipProxyUrlNormalize flag (renamed from skipMiddlewareUrlNormalize in v16). With it off, proxy.ts sees /blog/post — the normalized path; with it on, it sees the raw /_next/data/<id>/blog/post.json. That flag is literally a switch for whether this normalization runs before your proxy code.
The payoff for the whole pipeline: by the time handleRequestImpl reaches the matcher, the pathname is canonical no matter how the request dressed itself up, and request meta carries the full description of what the client wanted — HTML, full RSC, prefetch shell, a single segment, or Pages JSON — so the dispatch and render stages can act on flags instead of re-parsing the URL.
31Static short-circuit
How does the static short-circuit for /_next/static and public work?
The short-circuit is essentially a branch near the top of handleRequestImpl: before the matcher and before any rendering, Next checks whether the path is a static asset and, if so, streams a file off disk and returns — none of the normalize/match/render machinery runs.
FIG 37Static asset short-circuit branch
The two branches look similar but behave very differently, and the difference is entirely about whether Next can trust the file not to change.
/_next/static/* is the build output — your bundled JS and CSS chunks. The filenames contain a content hash, so a given URL's bytes can never change: a new build produces new filenames, and once you deploy, the old bundles from the previous build are never served again. Because the URL is effectively a fingerprint of its contents, Next serves these with Cache-Control: public, max-age=31536000, immutable, and it cannot be overridden. That is the safest possible caching: a year, never revalidated, and structurally impossible to serve stale (a changed file is a changed URL). The handler reads the file from .next/static on disk and streams it straight back.
/public/* is the opposite situation. These files are served at the root path (public/me.png → /me.png), but they are not content-hashed — me.png stays me.png across deploys even if you swap the image. So Next cannot assume they are safe to cache, and the practical reality is blunt: Next.js does not automatically set cache headers for files in /public; they are served as-is, and unless your hosting layer adds caching rules they get default minimal behavior. If you want them cached, you set the headers yourself at the CDN or server. To match incoming paths against these files efficiently, Next reads the public directory into an in-memory set at boot, so the request-time check is just a set lookup, not a filesystem stat on every request.
A couple of things both branches share: they happen before the route matcher and rendering — no normalizers, no RSC, no SSR, just a file stream — and they are also excluded from proxy.ts/middleware by the conventional matcher pattern /((?!api|_next/static|_next/image|favicon.ico).*), so your proxy code does not run on every asset request either.
The big caveat for production: on a CDN platform like Vercel, these requests usually never reach the Node server at all. Static files are cached at the edge after the first request and persist across deployments thanks to the hashed filenames. So there are really two short-circuits stacked — the edge serves the asset before the origin is ever contacted, and the in-process handler above is the fallback that runs for self-hosted next start (or on a cold/edge-miss). This is also why self-hosting /public assets can feel uncached compared to Vercel: on Vercel the platform layer adds the headers Next itself declines to.
So the short-circuit is really a trust gradient: hashed build assets get locked-down immutable caching and skip everything; un-hashed public files get served but deliberately uncached; and only a non-asset path pays for the full normalize-match-render pipeline.
32Matcher pipeline
How does the route matcher pipeline work and hand off to the router?
The matcher is explicitly internal (RouteMatcherManager and its implementation may change between versions), and the key structural detail is that matchers are grouped by kind, with static routes held in a Map and dynamic routes in ordered collections.
FIG 38Route matcher manager structure and handoff
The whole thing has two lives: one expensive setup at boot, then a cheap lookup per request.
At boot, the manager asks a set of providers to enumerate routes. Each provider knows how to read one slice of the build output — app pages, app route handlers, Pages pages, Pages API — and turns the manifest entries into matcher objects. The manager then sorts them into the two buckets: anything with no dynamic segments (a literal path like /blog/about) goes into a Map keyed by the exact pathname, and anything with [param] segments gets a compiled regex and goes into an ordered list. The sorting and regex compilation happen once here, so they never cost anything on the hot path.
Per request, manager.match(pathname) runs in priority order:
Static first — a single Map lookup. If /blog/about is a literal route, it is found in O(1) and nothing else is tried. This is why static routes always beat dynamic ones: they are checked first and short-circuit.
Dynamic next — if the Map misses, it walks the dynamic list in precedence order, testing each compiled regex against the pathname, and the first one that matches wins. The order is most-specific to least: a single dynamic segment [slug] beats a catch-all [...slug], which beats an optional catch-all [[...slug]]. So /blog/hello prefers blog/[slug] over blog/[...rest] even though both regexes match.
Capture — the winning regex's groups become the route params ({ slug: 'hello' }, or an array for catch-alls), and those ride along in the result.
The output is a RouteMatch: a definition (the route's kind, its compiled-module filename, the original page path) plus the extracted params. The manager stashes this in request meta so later stages do not re-match, and returns it. A null here is what becomes a 404.
The handoff is where "which route" becomes "render this route." The definition names a compiled module on disk, and the server resolves it to a route module — the runtime wrapper around your file. There are a few kinds, and the match's kind decides which: an AppPageRouteModule for a page.tsx, an AppRouteRouteModule for a route.ts, and the Pages-Router equivalents. For an app page, the module's render is what assembles the loaderTree — the nested structure of layouts, templates, loading/error boundaries, and the page for the matched segment path — and kicks off the two-pass RSC + SSR render. For a route.ts, there is no tree at all; the route module just dispatches to your GET/POST export.
So the clean mental boundary: the matcher's job ends the moment it produces a RouteMatch — it answers which route and what params. The route module takes over from there and answers what to render, and for the App Router that means building the loaderTree and entering the render pipeline. The params the matcher captured are the same ones your page.tsx eventually awaits via the params prop, carried the whole way down from that regex capture.
★Reference
Glossary
The load-bearing concepts of the App Router request lifecycle. Every dotted term in the deep dives opens its definition; here they all are in one place.
'use client'
A directive marking a module as a client boundary. The bundler replaces its import with a client reference (an id) in the Flight stream and records the real chunk in the client-reference-manifest.
BaseServer
The request pipeline class. handleRequest → handleRequestImpl normalizes the URL, short-circuits static assets, runs the matcher, then dispatches to the route module. NextNodeServer is the next start subclass.
Build manifests
JSON tables the build emits and the server loads once into memory - route → module maps, dynamic-route regexes, chunk lists, and the reference tables for actions and 'use client'. The build-time-to-runtime bridge.
Cache Components
The Next.js 16 model: rendering is dynamic by default and you opt pieces into caching with use cache / cacheLife / cacheTag - inverting the old 'cached unless opted out' default.
Client Component
A 'use client' module. On a cold load it renders twice: once in the SSR pass on the server for first paint, once in the browser to hydrate. In the RSC pass it is replaced by a reference, not its code.
Data Cache
Server-side, persistent across requests and deploys: caches fetch results, revalidated by time or by tag.
Edge runtime
A V8 isolate with only Web APIs (no full Node), near-zero cold start, run at a PoP. It speaks Request → Response. The right home for auth checks, rewrites and geolocation.
Flight payload
React's streaming, line-oriented serialization of a rendered tree. Server Components become serialized element trees; Client Components become references into the client manifest plus their props. It is the wire protocol between the two React realms.
FlightRouterState
The client's current route state, sent up with an RSC navigation so the server knows what's already mounted and can render only the diverging subtree.
Full Route Cache
Server-side cache of the rendered RSC payload + HTML for statically renderable routes, produced at build or revalidation. A hit skips the entire two-pass render.
Hydration
Attaching client React to existing server-rendered HTML. React doesn't recreate the DOM - it drains the inlined Flight tree, reconstructs the elements, and wires events with hydrateRoot, so markup becomes interactive.
ISR
Incremental Static Regeneration: a prerendered route served from cache with a revalidate window, refreshed in the background (stale-while-revalidate) rather than blocking the request.
loaderTree
The nested segment tree the matcher assembles from your layout/template/loading/error/page files. It is the exact input the RSC render walks.
middleware.ts (legacy)
The pre-v16 edge-interception file, running in a V8 isolate with Web APIs only. Renamed to proxy.ts in v16; a leftover middleware.ts silently does nothing after upgrade.
Partial Prerendering
A statically prerendered shell served instantly, with dynamic Suspense holes streaming at request time. In Next.js 16 it becomes the default behavior under Cache Components.
Partial rendering
On a soft navigation the server compares the current segment tree to the target and re-renders only the segments that change. Shared layouts are never re-rendered or re-sent.
Prefetch
<Link> fetches a route's RSC payload as it enters the viewport, dropping it into the Router Cache so the click is instant. In v16 prefetching is incremental and deduplicates shared layouts.
proxy.ts (was middleware)
Next.js 16's renamed successor to middleware.ts. Same pipeline position - before routing, on every request - but it now runs on the Node.js runtime (not the Edge isolate). Keep middleware if you still need Edge.
Request Memoization
Per-render, in-memory dedupe of identical fetch calls within one render pass, so a value fetched in a layout and a page hits the network once.
Route Handler
A route.ts file that owns a segment's HTTP verbs and returns a Web Response, bypassing React entirely. The right tool for webhooks, REST/JSON, OAuth callbacks and streaming endpoints.
Router Cache
Client-side, in-memory for the session: the prefetched and visited RSC payloads keyed by segment. A hit makes a soft navigation a pure cache hit with no network request. Tunable via staleTimes.
RSC
A React component that runs only on the server. It can be async, await data and touch the database, then disappears — only its rendered output is serialized into the Flight payload. Its code never ships to the browser.
Server Action
A 'use server' function callable from the client as if local. Calling the client stub POSTs an action id + encoded args; the server runs the real body, mutates, revalidates, and streams back a Flight payload that updates the UI.
Server Component
Renders once, on the server, and never in the browser. It only exists downstream as Flight data, which is why server-only code (DB access, secrets) is safe inside it.
SSR pass
The second React render: a normal React instance reads the Flight stream via the RSC client, executes Client Components' initial render, and produces HTML with react-dom/server.
Suspense boundary
A boundary that lets the shell flush immediately while a slow subtree streams in later. Its file-convention form is loading.tsx. Boundaries can resolve out of order; React reconciles placement.
Two-pass render
A hard request triggers two React renders in two separate module instances: the RSC pass produces a Flight stream; the SSR pass consumes it to produce HTML and embeds the same stream inline.