section 01 · the floor
Most explanations stop at the metaphor: "a that opens a door." That's the lobby. This is the floor — the actual wire format, the entries, the encrypted closures, the cache state machine that ships in Next.js 16. We'll keep the doorway, but you'll leave knowing exactly what the emitted, what the server serialised, and which byte the client just deserialised.
section 02 · the map
The eleven chapters are stations on a single rail — the path one request takes from the server realm, across the 'use client' boundary, into the browser, and home again. The rail's colour shifts where the realm shifts. Press trace the journey to send a token through it, or tap any station to open the deep dive — each one now carries a small animated demonstration of its mechanism. Use ← / → inside a chapter; highlighted terms are clickable.
chapter 01 · bundling
In development we optimise for the human — small modules, named imports, clean boundaries. At we optimise for the — fewer files, smaller bytes, predictable load order. The between is called bundling: walk the from each , collect what's reachable, output a . The novel thing React Server Components ask of a is that the same source tree must produce two of these graphs — one for the server, one for the client — and the directives in your code are the only thing telling them apart.
When (or in Next.js 15 and earlier) builds an app router project, it starts from a single set of route entries and performs the walk twice with different module-resolution rules.
Think of the directives as visa stamps. The same passport (your .tsx file) travels both graphs — but at the doorway, an unstamped module gets one visa and a stamped one gets the other. A file can only have one stamp; the bundler can't put it on both sides at once.
Tree-shaking is what lets the client stay small. The bundler parses each module, builds a graph of which exports are reached from each entry, and drops the rest. This only works on , because their imports and exports are static — import {x} from './y' is parseable without running any code. , by contrast, is dynamic: require('./y').x is a property lookup on whatever value require returned at runtime, which a bundler can't statically resolve without executing the module.
This matters for specifically because the server graph and client graph need to agree on what a module exports before either bundle is built. Three further passes refine the output:
exports field) can defeat tree-shaking in subtle ways. If a popular library re-exports a hundred icons as module.exports = {...}, importing one of them pulls all of them. This is why packages like lucide-react ship explicit per-file ESM entry points: import { Star } from 'lucide-react/icons/star' hits a discrete file the bundler can isolate.
Once the client graph is built, it's split. The split happens at three kinds of boundaries:
<X /> first renders.In Next.js 16, Turbopack is the default bundler. The graph-walking and chunking strategies above are the same in spirit, but Turbopack's incremental computation model means dev rebuilds are 5–10× faster and only the smallest possible slice — when you change Card.tsx, the server graph's reference stub doesn't need to re-emit unless your exports changed.
It comes down to the boundary. A 'use client' file must be importable from a server module without executing its body on the server. ESM allows this: the import statement is metadata the bundler can intercept and replace with a stub. CJS doesn't — require() is a function call that runs the target module synchronously. There's no syntactic surface for a bundler to say "import this name, but resolve it to a reference object instead of running the file." This is why RSC is, in practice, ESM-only — the system relies on import statements being statically rewriteable.
chapter 02 · environments
The doorway metaphor implies a binary world: server here, client there. But "the server" is at least three things — a Node.js , an , and the build itself — and each has a different capability surface. Knowing which one runs your code is the difference between fs.readFile working, throwing, or refusing to compile.
Next.js 16 deploys your server code to one of two runtimes per route, plus an optional third execution (the build). Each is a real JavaScript — just not the same one.
Full Node API surface. fs, crypto, child_process, native addons.
, no Node API. Fetch, Crypto, Streams, URL, Headers, Request, Response.
fs, no net, no native codeRuns once during next build. Used by generateStaticParams, generateMetadata, and any cached 'use cache' function at time.
.next/cacheThe user's environment — anything the spec allows.
document, window, localStoragefs leaks (and how the stops it)Imagine you write a utility module that calls fs.readFile. You import it into a server component — fine. Then you import the same utility, by accident, into a client component. What happens?
In the server graph, the import resolves to the real module and works. In the client graph, the bundler walks the import too — and now fs needs to exist in the browser. would have substituted a false resolver for Node built-ins and your import would resolve to {}, silently breaking at runtime. and Next.js 16 are stricter: imports trigger a build error in the client graph, and the bundler points at the import chain that pulled them in. next/server-only is the explicit version — importing it from a client module is a guaranteed build break.
import 'server-only' // throws at build time if reached from the client graph import { readFile } from 'node:fs/promises' export async function loadApiKey() { return readFile('/etc/secrets/key', 'utf8') }
There is a mirror , client-only, which throws if imported into the server graph. You'll see it inside libraries that touch window at module scope. The pair forms a runtime firewall: a module declares which world it belongs to and the bundler refuses to ship it to the other.
One subtle environmental fact: a Node or Edge response in Next.js is a , not a single . The server can bytes — HTML chunks, rows — to the client while it's still computing the rest. This is what makes boundaries work as flush points: when the server hits <Suspense fallback={...}>, it sends the now and registers a callback to send the actual content later. The client receives Content-Type: text/x-component as an open and patches in the late content using placeholders.
The browser doesn't know upfront how many flushes are coming. It treats the stream as open until the server closes it. (Ch 8) uses this same pattern in reverse: of a client component can begin as soon as its slice of HTML arrives, not after the entire document.
server-only/client-only are the explicit firewall against accidental leaks; and the server's response is a stream, which makes Suspense a real flush point — not a developer-facing abstraction.
chapter 03 · the problem
Every web app since the late 90s has been two programs talking over HTTP. The problem RSC addresses isn't the existence of that boundary — it's that every kind of data crossing it has historically required its own bespoke synchronisation: API routes, type definitions duplicated on both sides, query libraries, cache . Below are the four costs that compound, and the mechanisms that have tried to mitigate them.
The canonical client-side data shape: render a list, each row needs detail, each detail needs an avatar. Naïve client code produces this:
function UserList() { const users = useFetch('/api/users') // 1 request return users.map(u => <UserCard id={u.id} />) } function UserCard({ id }) { const user = useFetch(`/api/users/${id}`) // N requests const avatar = useFetch(`/api/avatars/${user.avatarId}`) // N more, waterfalled behind user ... }
Each waits for the one above it to resolve before it can even start. A 50-item list with 3 layers can mean 150+ sequential , each blocked on the previous render. and React Query mitigate this with and parallel fetching but can't eliminate the : if the avatar URL depends on the user record, you still need to know the user before you can ask for the avatar.
With RSC the same component runs on the server. await is the language operator; the database is two milliseconds away, not two hundred; and the entire tree collapses into one round-trip from the browser, with all of the awaits happening on the server side of the boundary:
async function UserCard({ id }: { id: string }) { const user = await db.users.find(id) // no waterfall — these const avatar = await db.avatars.find(user.avatarId) // run in the server's process return <article>...</article> }
End-to-end type-safe libraries (tRPC, ts-rest, Hono RPC) infer a client type from the server's procedure definitions, so the call is checked at compile time. This is real type safety up to the send: api.user.find({ id: 42 }) won't compile if the procedure expects a string. But the moment the request lands on the server, the is back to any. The JSON the client posted could have been anything; TypeScript's view of the world stopped at the network. Every serious tRPC procedure ends up calling z.parse() (or valibot, or arktype) before it touches the body:
export const userRouter = router({ find: publicProcedure .input(z.object({ id: z.string() })) // ← runtime check; without this, body is any .query(({ input }) => db.users.find(input.id)) })
RSC sidesteps this for the server → client direction. The component is imported by the server module; are typed and validated by the same TypeScript compiler. The boundary is crossed by , not by HTTP semantics — there's no JSON to validate because the wire format is constructed by React itself from typed values. The client → server direction (, Ch 7) does still need runtime , for the same reason tRPC does: the network is still untrusted.
Traditional client-side caching has to invent its own coherence layer. React Query, , RTK Query each ship a sophisticated key/tag/ invalidation system because the alternative — refetching everything on every change — is too expensive. The same data shows up on five pages; one mutation has to invalidate the right five entries without invalidating unrelated tenants.
In RSC, data fetched inside a server component is part of the render. There's no separate cache to invalidate; you re-render the route. Next.js 16's system formalises this: when you call updateTag('user-42') from inside a , every cached server function that called cacheTag('user-42') is invalidated, and the routes that used those functions get re-rendered into fresh payloads. The client-side React Query of the old world becomes one server-side , with the same precision.
Notice the verb shift. REST and tRPC are about making a request. GraphQL is about composing a query. RSC is about importing a component. The same problem (get data, render it) has been recast as four different language games over twenty years; the question RSC asks is whether the last game is one you've actually been playing the whole time — you just had to leave the React tree to do it.
chapter 04 · react's answer
React's response is conceptually two changes: components can be (allowed to await things mid-render on the server), and can be sent across the network (the React knows how to serialise them, and how to receive a of them on the other side). Underneath both is a format called — a line-delimited row format that carries the rendered tree from server to client.
When a server tree finishes rendering, React serialises it into a stream of newline-delimited rows. Each row has an ID, a tag indicating its kind, and a . The client parses rows as they arrive and resolves references to other rows by their ID. Here is what you would see if you read the bytes of fetch('/dashboard').body:
// 1: a module reference — the client knows to load this chunk and pick the Card export 1:I["chunks/8b2c.js",["Card"],""] // 2: a plain value — props for what comes next 2:{"name":"Ada","age":36} // 3: an element — $ prefix marks "this is a React element" // "$1" means "render the module from row 1, with props from row 2" 3:["$","$1",null,"$2"] // 0 is by convention the root 0:["$","div",null,{"children":"$3"}]
The tag letters are not arbitrary — each one is documented in react-server-dom-webpack and the equivalent. The most common ones:
["$","div",key,props] — the tag string can be "div", a row reference like "$3", or a Symbol marker.[chunkSrc, exportNames, asyncFlag]. The runtime loads the chunk and uses one of the exports.Content-Type: text/x-component body is exactly the format above. Each newline is a row. pauses are visible as gaps in the body.
Not everything you can put in a JavaScript object can be serialised. Flight's rules are stricter than JSON's and more generous than structuredClone's. The cleanest mental model: anything React can rebuild on the client without code from the server.
string, number, boolean, null, undefinedDate, Map, Set, BigInt, RegExpArrayBufferSymbol.for(...))The exception for is striking and worth pausing on. You can do this on the server:
async function Page() { const userPromise = db.users.find('42') // not awaited return <UserCard user={userPromise} /> // passed unresolved } // Card.tsx — on the client ;('use client') import { use } from 'react' export function UserCard({ user }) { const u = use(user) // suspends until the promise resolves on the wire return <div>{u.name}</div> }
The server doesn't block its render on userPromise. It emits the tree with a $L reference at the promise's slot and keeps rendering. The client receives the tree, suspends at use(user), and waits for the row to land later in the stream. The promise is the API; the streaming is invisible.
await on the server actually doesA server component can be declared async. The React server renderer awaits it before serialising its . There's no special needed — the language feature is the API.
export default async function Dashboard() { const [user, orders] = await Promise.all([ db.users.find('42'), db.orders.recent('42') ]) return ( <Layout user={user}> <OrderList orders={orders} /> </Layout> ) }
The renderer treats this exactly like a synchronous component that returned the same JSX, only with a delay. If you wrap parts of this in <Suspense>, the renderer can the outer shell while the inner subtree is still awaiting — see the next subsection.
A <Suspense> boundary tells the renderer: "if anything inside this suspends (returns a promise, throws a ), render the here and continue with the rest of the tree." On the server, this becomes a flush boundary: the server sends the part it has, with the fallback in the Suspense slot, and registers a continuation that will write the real content as a later row.
The browser sees the header instantly, a spinner where the Suspense lives, and the footer. Some hundred milliseconds later the real content patches itself in by streaming through the same response. From the user's perspective the page never showed blank — but from the server's perspective, it served the request in three writes instead of one.
JSX over the wire is a strange thing to defend in print until you realise: HTML is JSX over the wire too. The browser has been receiving a serialised tree from a server program for thirty years. The change is that the tree now remembers it's React — which makes it patchable, refreshable, and partially hydratable in ways an HTML string never could be.
$, I, L resolving references between them. Serialisation accepts , plain objects, dates, maps, and promises; it rejects functions, classes, and DOM. async server components are normal JS; <Suspense> is the boundary that lets the server flush before everything inside has resolved.
chapter 05 · the directive
'use client' compiles to — and why it can't live inside a function.The directive is a six-character string at the top of a file. The reads it, replaces the file's body in the server graph with a stub of references, and emits the original file into a that the client can . Every export becomes an object — never a function — until the client actually loads it. The doorway metaphor is exactly right; the doorway is also a string in your code, parsed at .
Click the directive below to add or remove it. Watch the right-hand world change identity. The code on the right is the file the bundler is reading; the world it ends up in is the consequence.
All entries start here. The framework imports your route into the server graph, then walks every import below it.
import Page from './page' export default function Layout() { return <Page /> }
With 'use client' at the top, this file is the entry to the client graph. useState, event handlers, and browser APIs are all available.
Without the directive, this file is still part of the server graph. Reaching for useState here is a build error — the isn't available on the server.
'use client' import { useState } from 'react' export function Card({ data }) { const [open, setOpen] = useState(false) return <div>...</div> }
// no directive — a plain server module import { useState } from 'react' export function Card({ data }) { // ❌ useState can't run on the server const [open, setOpen] = useState(false) ... }
click the 'use client' tag to open and close the doorway
When the bundler hits a 'use client' file during the server graph walk, it doesn't read the file's body. Instead it generates a stub module whose exports look like this:
// what the server bundler emits in place of Card.tsx export const Card = { $$typeof: Symbol.for('react.client.reference'), $$id: 'chunks/8b2c.js#Card', $$async: false }
That object is what gets imported when a server module writes import { Card } from './Card'. It has the same name and slot as the real export, but its body is gone. When this object appears in a rendered tree, the React server renderer emits an I-row (Ch 4) referencing the chunk URL — and the client, on receipt, loads chunks/8b2c.js, finds the real Card export, and renders it. The two halves of the directive are: excise on the server side, register a reference on the client side.
The bundler emits one more thing per build: .next/server/app/route/client-reference-manifest.json. This file maps the $$ids the server might emit to the URLs the client should fetch:
{ "clientModules": { "chunks/8b2c.js#Card": { "id": "chunks/8b2c.js", "chunks": ["static/chunks/8b2c.js"], "name": "Card", "async": false }, "chunks/8b2c.js#default": { "id": "chunks/8b2c.js", "chunks": ["static/chunks/8b2c.js"], "name": "default", "async": false } }, "entryCSSFiles": { "app/page": ["static/css/app/page.css"] } }
At request time, the renderer reads this manifest to know which chunk URL to put in each I-row. At time, the client uses the same manifest (also shipped) to fetch the right chunk before resolving its references.
You can't write this:
export function Card() { 'use client' // ← parsed as a string expression, ignored — not allowed by spec ... }
The bundler needs to decide at module-resolution time which graph a file belongs in. Module resolution happens before the parser ever steps inside a function body — the import statement import { Card } from './Card' needs an answer about where Card lives before any of Card's logic runs. A function-scoped directive would require the bundler to inspect every call site and partially execute the program, which is undecidable in general. The module-level constraint is what makes static graph splitting possible.
Once a file is in the client graph, everything it transitively imports is too. The directive only needs to live at the to the client world. Toggle the directive on any of the files below — see how the colours propagate.
framework → page.tsx → a.tsx → b.tsx
import()crosses the boundary cleanly: (() => import('./Card')) in a server component becomes a -able client chunk load.chapter 06 · references
Every time the server "uses" a client component, it doesn't have the component — it has a small object that names it. That object is what crosses the boundary; the actual function only ever existed in the chunk the client will load. Let's read it field by field, watch what the does with it, and look at the one situation where calling such an object is a runtime error you've probably seen in production.
There are two reference shapes you'll encounter in a Next.js app. They live on different sides of the boundary and answer different questions.
What a 'use client' export becomes inside the server graph. Imported by server modules; rendered by the server renderer; resolved by the client at .
{
$$typeof: Symbol.for('react.client.reference'),
$$id: 'chunks/8b2c.js#Card',
$$async: false,
}
What a 'use server' export becomes when imported by a client module. The client holds it; calling it triggers an HTTP POST back to the server.
{
$$typeof: Symbol.for('react.server.reference'),
$$id: '7a8b9c…d4e2f1#updateUser',
$$bound: null, // or [encrypted, args]
}
The encrypted $$bound field is the mechanism behind one of the most "magical" feeling parts of — that just use a variable from the surrounding server component:
// you write this export default function EditPost({ postId }: { postId: string }) { async function update(formData: FormData) { 'use server' await db.posts.update(postId, formData.get('title')) // ↑ how does the server know postId? The client never had it. } return <form action={update}>...</form> } // what the client receives (conceptually): const update = { $$typeof: Symbol.for('react.server.reference'), $$id: '7a8b9c…d4e2f1#update', $$bound: 'AES256-GCM(postId="abc", iv=..., tag=...)', // shipped, opaque }
The server encrypted postId with a key only it knows, shipped the to the client, and stored the ciphertext as part of the reference. When the form submits, the client sends the ciphertext back. The server decrypts it, restores the , and runs the action. The user can't tamper with the captured value without knowing the key.
$@ self-reference marker and the way $$bound deserialisation walked prototype chains, an attacker could trigger remote code execution on any unpatched Next.js app. Fixed in React 19.0.1 / 19.1.2 / 19.2.1 — Next.js 16 pins to a patched version. The lesson: treat every byte the client sends as untrusted, including the ones React generated.
When the client receives a Flight containing an I-row referencing a client component, the runtime does the following before it can render anything:
chunkSrc, exportNames, asyncFlag.chunkSrc in the client-reference to get the absolute URL.import(chunkUrl); deduplicate against in-flight loads of the same chunk.If $$async was true, step 3 might already have a pending from a lazy() import elsewhere; the runtime joins that promise instead of starting a new request. This is how chunk deduplication survives across multiple references to the same module.
Press Run import. Watch the server-side log produce the reference object — and the client-side log produce the actual value. Same source export, two environments:
A reference. A pointer to something the server cannot read or execute — only the client can.
The real value, because the client runs in the same environment as a.tsx after the chunk loads.
forwardRef, memo, and what happens to the wrapperIf you wrap a client component in forwardRef or memo, the wrapper is part of the client module — not the server's reference. The server still sees the same four-field reference object. When the client loads the chunk, it gets the wrapped component, and the reconciler treats the entire thing as a single unit. The wrappers don't survive because they don't need to: they're just transformations that happen at component-mount time, on the client side of the boundary.
A reference object is not callable. Try this in a server component:
import { Card } from './Card' // Card is a 'use client' export export default function Page() { const rendered = Card({ name: 'Ada' }) // ❌ TypeError return rendered }
The runtime throws something like TypeError: Card is not a function. (In 'Card({ name: 'Ada' })', 'Card' is an object with $$typeof: Symbol(react.client.reference)). The fix is to use it as a element — <Card name="Ada" /> — which lets React's renderer emit an I-row to the wire and resolve it later on the client. JSX is the API; direct invocation isn't.
There's a quiet symmetry here. The server doesn't have the component because it can't run it; the client doesn't have db.query because it can't run that either. Each side imports a handle to the thing on the other side. The handle has a shape that the runtime knows how to dial. JSX is the dialer for client references; action={...} is the dialer for server references; an await on a server-passed promise is the dialer for data. Same pattern, three surfaces.
$$typeof, $$id, plus $$async (client) or $$bound (server). The encrypted $$bound is how server actions capture closure variables without leaking them. The client runtime resolves $$id via a manifest, loads the chunk, hands the export to the reconciler. Reference objects can't be invoked directly — use JSX or an action prop.
chapter 07 · 'use server'
A 'use server' function is an endpoint with no URL. You import it; the replaces it with a reference; calling the reference triggers a POST that the framework routes back to the original function. Because the URL is implicit, the entire surface area of authentication, , and input falls onto the framework's transport layer — and the protocol layer just had its second critical since 2025. This chapter is the longest in the doc for a reason.
Every gets an ID at . The ID is the action's address; the client uses it to invoke the action; the server uses it to look up which function to run.
// at build time, for every export with 'use server': function deriveActionId(filepath, exportName) { const input = `${filepath}\0${exportName}` const hash = sha256(input).slice(0, 40) return `${hash}#${exportName}` } // app/actions.ts → export async function updateUser(...) // → "7a8b9c12d4...e1f2#updateUser"
Two consequences of using a :
A server action call is an HTTP POST. The URL is the current page's URL — there's no /api/... route. Next.js's request handler recognises action requests by a special header:
POST /dashboard HTTP/1.1 Host: app.example.com Next-Action: 7a8b9c12d4...e1f2#updateUser // ← the routing key Content-Type: multipart/form-data; boundary=... Cookie: session=... ------boundary Content-Disposition: form-data; name="0" {"title": "new title"} // arg 0 (JSON, plain object) ------boundary Content-Disposition: form-data; name="1" "$@0" // $$bound — the encrypted closure envelope ------boundary--
The Next-Action header is the dispatch key. The request body is multipart — args are encoded one per part, with 's reference syntax ($@n) used for shared objects and the $$bound envelope. On the server, the handler:
Next-Action in the build's action .$$bound with the build-time key; verify the tag.The action ID is a routing key, not a credential. A malicious page could load your domain in a hidden and post to /dashboard with a known action ID. The defences are layered:
$@ self-reference, $$bound reconstruction) allowed an attacker to forge a payload that walked the , accessed constructor, and produced an arbitrary Function instance that ran on the server. The fix in React 19.0.1 / 19.1.2 / 19.2.1 added hasOwnProperty ownership checks during deserialisation. Next.js 16 ships with the patched ; if you're upgrading from 15 or earlier, pin the React patch version.
A server action's hardest job isn't running — it's invalidating. After updateUser finishes, every cached read that touched that user must be marked stale. Next.js 16 introduces a new caching model around the 'use cache' directive (the v15 unstable_cache is being deprecated), with tag-based as the primary tool.
import { cacheTag, cacheLife } from 'next/cache' async function getUser(id: string) { 'use cache' // the new directive — function-scope cache cacheTag(`user:${id}`) // tag this entry for targeted invalidation cacheLife('hours') // or 'minutes', 'days', 'max', { expire: 3600 } return db.users.find(id) }
And the action that invalidates it — note the two different invalidation in Next.js 16, each with a specific role:
'use server' import { updateTag, revalidateTag } from 'next/cache' export async function updateUser(id: string, data: FormData) { await db.users.update(id, data) // IMMEDIATE invalidation, only works inside Server Actions: updateTag(`user:${id}`) // next read fetches fresh // alternative: stale-while-revalidate (background refresh) // revalidateTag(`user:${id}`, 'max') // serve stale, refresh in BG // revalidateTag(`user:${id}`, { expire: 0 }) // immediate, anywhere (not just actions) }
updateTag outside an action .revalidateTag('user:42') is deprecated in Next.js 16. It compiles, but TypeScript errors in strict mode and the runtime falls back to legacy behaviour. The two-argument form is required: revalidateTag('user:42', 'max') for or updateTag('user:42') inside actions for immediate.
The third superpower of the action prop: forms work without JavaScript. If the page hasn't yet, or JS is disabled, or the user is on a slow connection, the browser handles the form natively — it posts to the page URL, the framework runs the action server-side, and the page re-renders. With JS, you get the optimistic-update experience on top:
'use client' import { useOptimistic } from 'react' import { updateUser } from './actions' export function EditForm({ user }) { const [optimistic, setOptimistic] = useOptimistic(user, (curr, next) => ({...curr, ...next})) async function submit(data: FormData) { setOptimistic({ name: data.get('name') }) // paint immediately await updateUser(user.id, data) // fire and let the server confirm } return <form action={submit}> <input name="name" defaultValue={optimistic.name} /> <button>save</button> </form> }
If the action fails, React rolls the optimistic state back automatically. If it succeeds, the new server-rendered tree (with the actually-updated value) replaces the optimistic one. The user perceives instant feedback; the network handles correctness in the background.
There's a remarkable economy to all this. The same action={fn} prop binds an HTML form to a function the bundler turned into an RPC endpoint, the envelope ships a piece of server state through an untrusted channel without exposing it, and the cache invalidates by tag — three problems that historically required three different libraries, three different mental models, and three sets of tests. The simplicity is what makes the protocol layer's correctness so important.
Next-Action header; closures are sent as encrypted $$bound; CSRF defence is layered (origin check, AEAD-encrypted closures, manifest validation). Next.js 16 brings updateTag (immediate, actions-only) and the two-arg revalidateTag form. Forms work without JS; useOptimistic is the JS-enabled enhancement on top.
chapter 08 · lifecycle
A single page navigation involves at least three distinct rendering passes — one to produce HTML, one to produce the , and one to the result in the browser. boundaries split each pass into flushable chunks; lets the most interactive part of the page wake up first. invert the direction. Let's walk both directions slowly.
This is what happens between window.location = '/dashboard' and the page becoming interactive. Press play to step through it.
async components their data. The renderer walks the tree, hitting <Suspense> as a boundary.I-rows pointing to chunks; emit L-rows reserved for future flushes.Content-Type: text/x-component stream; HTML and Flight rows interleave as the server flushes them.I-rows by loading the referenced chunks; L-rows fill in as the server flushes them.The same machinery in reverse. Press play:
$$bound from the reference) into a multipart body.Next-Action header. useOptimistic state, if any, paints immediately.$$bound; invoke the function.updateTag marks affected caches stale; revalidatePath re-renders affected routes.useOptimistic states are either confirmed or rolled back.The function under all of this on the server side is renderToReadableStream from react-server-dom-webpack/server (or the equivalent in Next.js 16). It returns a Web ReadableStream; Next.js pipes it through its response handler:
import { renderToReadableStream } from 'react-server-dom-webpack/server' export async function handleRequest(req: Request) { const tree = <RouteRoot url={req.url} /> const stream = await renderToReadableStream( tree, clientReferenceManifest ) return new Response(stream, { headers: { 'Content-Type': 'text/x-component' } }) }
The stream is active — when the renderer awaits a server-side promise inside a Suspense boundary, the function returns to the , lets the await resolve later, and writes the resolved subtree as a new row when it's ready. The HTTP connection stays open the whole time. Reverse proxies and CDNs that responses break this; Next.js sets Transfer-Encoding: chunked and disables compression buffering on streamed responses.
Before React 18, hydration was monolithic — one giant tree walk, blocking the main , no input handled until done. and React 18 changed this to selective hydration: each Suspense boundary is independently hydratable, the scheduler picks which to hydrate first based on user interaction.
A hydration mismatch is when the HTML the server rendered doesn't match what the client renders for the same component. Historically this was a hard error; React 18 introduced graceful recovery, with caveats.
suppressHydrationWarning only for truly values (the current time, a theme that depends on localStorage). Suppressing it elsewhere is hiding a real divergence — and the cost is real, just invisible.
Hydration is the seam where two universes — the static document, the live tree — try to become one. When it works you don't notice. When it fails, you get the uncanny: a button that looks the same but doesn't respond, a date that flickers, a layout that re-flows when you swore it was already there. The seam is real because the universes are real. The framework spends a great deal of effort pretending they aren't.
renderToReadableStream keeps the stream open for as long as are still resolving. Selective hydration uses interaction signals to prioritise which subtree wakes up first. Mismatches recover gracefully but aren't free — they double-render.
chapter 09 · nesting
The mental model people reach for first — "server components are higher, client components are leaves" — is wrong as soon as you need a , a theme wrapper, or a modal manager. Real apps interleave the two. The rule isn't structural; it's temporal: the server has to finish before the client knows anything, so a server component can render inside a client component only as a value the client received from above. Let's make this concrete.
The decides at what's in the server graph and what's in the client graph. At , the server graph executes first; its output is a tree containing client references. The client receives that tree and starts hydrating; client components can call useState, but they cannot reach back into the server graph from inside their bodies.
This is a one-way information flow at execution time. A client component cannot ask the server "render this server component for me right now with these ." The server already ran. The already shipped. The two universes are not equipotent at the same moment.
The way around this isn't a workaround — it's the intended pattern. A client component receives server-rendered content as children (or any prop). The server rendered the first, then handed the result to the client component as a value.
// providers/Theme.tsx 'use client' export function ThemeProvider({ children }: { children: React.ReactNode }) { const [theme, setTheme] = useState('light') return <ThemeCtx.Provider value={theme}>{children}</ThemeCtx.Provider> } // app/dashboard/page.tsx — server component import { ThemeProvider } from '@/providers/Theme' import { OrderList } from './OrderList' // server component export default async function Page() { const orders = await db.orders.recent() return ( <ThemeProvider> <OrderList orders={orders} /> // server-rendered subtree </ThemeProvider> ) }
The server rendered OrderList. The result — a tree with concrete elements — was passed as a children prop to the client's ThemeProvider. When the client , it sees the provider with a children . The provider wraps it; the is available; nothing crossed the boundary backwards.
A handful of patterns are not just discouraged — they cannot exist under the constraints. Knowing them up front saves you from chasing solutions to undecidable problems.
The phrase "" describes the React-Query world: a component mounts, the fires the request, the next render shows the data. The comes from each component independently deciding to after it renders. RSC enables render-as-you-fetch — the data fetch and the render are the same thing.
async function OrderDetail({ id }: { id: string }) { const [order, items, shipping] = await Promise.all([ db.orders.find(id), db.lineItems.forOrder(id), api.shipping.quote(id), ]) return <article>...</article> }
All three fetches start at the same time, on the server, before any byte goes to the network. The browser receives a fully-populated subtree in one . Combined with (Ch 4), the server can the page header while these three calls are still in flight, giving the user something to look at while the data lands.
It helps to think of await in a server component as a render-time data binding, not a fetch. The component is the query. The query is the layout. The two pieces that used to live in separate files — and separate mental models — collapse into a single co-located expression. This is the part of RSC people fall in love with; it's also the part that's hardest to give up once you've worked with it.
await at the data-needing component, parallelise with Promise.all, let Suspense decide when to flush.
chapter 10 ·
Pre-rendering is whatever the framework can do before a real user shows up. Next.js 16 reorganises this around the model: every page is dynamic by default, every cache is opt-in, and () is on whenever cacheComponents is enabled. The result is a single render that produces a static shell with dynamic holes — one HTTP response containing both shapes. Let's pull this apart.
When a route is requested (or pre-rendered at ), React does the work twice — different outputs, same source.
Produces the serialised tree (Ch 4). Component identity is preserved; client references are I-rows; are JSON-able values.
'use cache' functions return intoRuns in parallel — server also renders client components to HTML so the document has paintable content on first byte.
Before Cache Components, "is this page static?" was answered by an implicit set of rules: was there a cookies() call, a headers() read, an uncached fetch? Any of these tipped the page into dynamic territory. Next.js 16 inverts the default — everything is dynamic until you opt in to caching.
The model: a page consists of a static shell (chrome, layout, anything cached) and one or more dynamic holes (uncached data, user-specific content). The shell is pre-rendered at build time. The holes are <Suspense> boundaries; their ship with the shell, the real content in when the route is requested.
Shell ships immediately ( in < 100ms in the lucky case); holes resolve in parallel with the user reading the static part. This is the default in Next.js 16 when you enable cacheComponents: true — the old experimental.ppr flag is gone.
import type { NextConfig } from 'next' const config: NextConfig = { cacheComponents: true // PPR on, 'use cache' active, dynamic-by-default } export default config
A cached value moves through four states. Each cacheLife profile sets the durations between them. Click the buttons to step through.
The 'use cache' directive in Next.js 16 lives at any of three granularities; choose the smallest one that captures the variation you care about.
// FILE scope — every export in this file is cached 'use cache' export async function getProducts() { return db.products.all() } export async function getCategories() { return db.categories.all() } // FUNCTION scope — recommended starting point for migrations export async function getProduct(id: string) { 'use cache' cacheTag(`product:${id}`) return db.products.find(id) } // COMPONENT scope — cache the rendered output, not the data async function ProductCard({ id }: { id: string }) { 'use cache' cacheTag(`product:${id}`) cacheLife('days') const p = await db.products.find(id) return <article>...</article> }
request-scoped value (cookies, headers, user ID from ) will cache one user's result and serve it to another. The compiler-derived key won't see the . Either pass the variable as an explicit argument (so it's part of the key) or pull it inside the function via cookies()/headers() — which prevents caching entirely, which is the correct behaviour for per-user reads.
One subtle Next.js 16 rule: a cache with a lifetime under five minutes (cacheLife('seconds'), revalidate: 0, or any expire below 300s) is excluded from the static shell. It becomes a dynamic hole instead — wrapped in implicit , served at request time, streamed late. This is the framework saying "your data isn't actually static; we won't pretend it is."
cache() · React's per-request memoisationDistinct from 'use cache', React itself exports a cache helper that deduplicates calls within a single request. Three components on the same page all calling getUser('42') will hit the database once, not three times — no cross-request caching, just one-render-cycle memoisation.
import { cache } from 'react' export const getUser = cache(async (id: string) => db.users.find(id)) // three call sites in the same render → one db query
Use React.cache for dedup, 'use cache' for persistence. The two compose: a function can be wrapped in cache() and also marked 'use cache' — the React dedup runs first per render, the persistent cache afterwards.
Hydration replays the server render on the client to attach event listeners. Resumption — pioneered by Qwik — instead serialises the listeners themselves, so the client doesn't re-execute the render at all. RSC reduces the cost of hydration (Suspense-bounded, selectively-prioritised, ) but doesn't eliminate it. The cost is still real: the client has to walk the , match it against a re-rendered tree, and attach handlers.
Whether resumption is the next direction for React is an open question. The Compiler reduces re-render work; PPR reduces initial work; Cache Components reduces redundant work. The cumulative effect is that hydration matters less, even if the model hasn't fundamentally changed.
Caching is, famously, the second-hardest problem. The Next.js 16 model isn't a solution to it — it's an admission that the only thing you can do is make caching explicit and tagged, with a small number of well-defined invalidation . The old model (auto-cache everything; figure out when to bust later) gave up correctness for ergonomics. The new model gives up some ergonomics for a system you can reason about. Both choices have a body count; one of them lets you write a postmortem afterwards.
'use cache', tags via cacheTag, lifetimes via cacheLife. PPR is on by default when cacheComponents: true — static shell + dynamic holes in one response. Closures over request-scoped values in cached functions are a data-leak trap.
chapter 11 · conclusion
A talk like Delba's earns its applause by making something complicated feel clean. The cost of that clarity is the elision of the friction — the places where the model bends, where existing libraries fight it, where the bytes really do cost. This chapter is the honest part. None of it should change your decision to use ; all of it should change how you use it.
Two costs are non-zero and worth measuring on a real app before committing.
Three things, in order of certainty:
A frame I keep returning to: RSC is the moment React's contract with the server becomes explicit. For a decade the implicit contract was "you send me HTML and an entry script; I take it from there." The new contract is "you send me a stream of components, I'll send you the events back, the cache lives between us." Neither party has to pretend the other one doesn't exist. The negotiation, finally, is the API.