Three pieces, and each one can be replaced without disturbing the others.
- The plugin reads your route tree at build time and generates the entries.
- The engine turns a component and its layouts into a Flight payload and an HTML stream.
- The host owns HTTP. It matches a URL, decides how much of the page to send, and streams the answer.
The generated Nitro entry is a host, and it knows nothing about your routes that the manifest does not tell it.
A first load
GET /posts/hello
→ the host matches the route in the manifest
→ the engine renders the page inside its layout chain
→ HTML streams out: shell first, Suspense completions as they resolve
→ the Flight payload is embedded for hydration
→ the client bundle hydrates, and the page becomes an SPAThe shell — layouts, static markup, every Suspense fallback — is flushed before anything slow is awaited, so the browser has something to paint while the data is still in flight.
A navigation
A <Link> click does not ask for the document again. It sends the layout chain
it already has, and gets back only what is below the deepest layout they share:
GET /posts/world
X-RSC: true
X-RSC-Segments: app/layout,app/posts/layout
← X-RSC-Segment-Depth: 2
← X-RSC-Layouts: app/layout,app/posts/layout
← a Flight payload for the page aloneDepth 0 means a whole document: it replaces the root and clears everything
under it. Anything deeper is handed to a segment boundary — a small client
component sitting between each layout and its children.
It has to be a client component, because server components cannot re-render in the browser.
The engine gets the last word on the depth: an interceptor targeting a slot on a layout the client is keeping forces a wider render, because an override can never reach a layout that is not being re-rendered.
The headers
| Header | Direction | Meaning |
|---|---|---|
X-RSC |
→ | Send the Flight payload, not the page. Every answer varies on it. |
X-RSC-Segments |
→ | The layout chain the client has mounted, outermost first. |
X-RSC-Segment-Depth |
← | The boundary this payload replaces. 0 is a whole document. |
X-RSC-Layouts |
← | The chain to send back next time. |
X-RSC-Version |
↔ | The build. A mismatch mid-session answers 409. |
X-RSC-Action |
→ | The server reference being invoked. |
X-RSC-Content-Type |
→ | The body’s real type, because the body itself is sent opaque. |
X-RSC-Referer |
→ | The page an action or interception came from. |
X-RSC-Intercept |
→ | Render this route into the named slot. |
X-RSC-Revalidate |
→ | Render this one region and nothing else. |
X-RSC-Location |
← | Go here instead. |
X-RSC-Redirect |
← | The render redirected. Sent with 204 on a navigation, never a 3xx — fetch would follow that and hand the decoder the wrong body. |
A server action
One POST /_rsc/action, carrying the reference id and the arguments in Flight
format. The body travels as application/octet-stream with its real type in a
header, so a host that parses multipart cannot consume it before the action
does — reading Content-Type instead leaves an upload undecodable.
Anything the action marks stale with revalidate() is re-rendered and travels
back inside the same response. The caller sees only what its function
returned; the page updates around it, and there is no second request.
A failed action answers with JSON or a redirect header, never a Flight stream. Handing a failure to the Flight decoder reports the decoder’s confusion rather than what the server said.
A redirect from a render
Headers flush before the render’s slow work, so a redirect has two ways to arrive and which one depends on where it was thrown:
- Above every Suspense boundary. React cannot finish the shell, so the promise for it rejects while the response is still unwritten. A document gets a real 3xx; a navigation gets
204andX-RSC-Redirect. - Inside a boundary. The shell is already out. The destination travels in React’s error digest for the client’s boundary to perform, and a document also gets a
location.replacescript appended so it need not hydrate first.
Neither buffers anything. See Redirects for what that means for an authorization check.
Frozen answers
Before matching a route, the host looks for a prerendered file. A frozen page is a whole page, so that lookup happens after the two things that ask for something smaller — a revalidation and an interception — and before everything else.
Prerendered routes answer partially too: alongside {path}.flight the build
writes one variant per depth, and the host serves the one matching the chain
this client shares. Without them, arriving at a stored page would always
replace the document root and unmount the pages retained behind it.
A route that only redirects is stored as the redirect — {path}.redirect.json,
holding a status and a location — and answered from that file without rendering
anything.
Deployments
The version hash identifies the build. A client that navigates after a deploy
gets a 409 and X-RSC-Location, which is a full reload — a layout retained
from the previous build has no claim on being right for the new one.
Further
PROTOCOL.md
in the repository is the full contract: the engine’s interface, the socket
framing a detached host uses, and the invariants that fail silently when a new
host gets them wrong. tests/js/workerProtocol.test.ts is its executable half.