The RSC wire format, React Flight serialisation, how the client reconciler processes RSC payloads, React 19 async params/searchParams as Promises, and the React Compiler's automatic memoisation model.
A-1 — RSC Internals: The React Flight Protocol and React 19
Who this is for: Architects who want to understand what's actually happening when a Next.js App Router page renders. This module goes below the abstraction — into the React Flight wire format, how the client reconciler processes RSC payloads, why client-side navigation in the App Router preserves Client Component state, and what React 19 changed. This is the layer that makes everything else in the Architect phase make sense.
What React Flight Actually Is
When engineers say "React Server Components," they're describing a rendering model. When they ask what's transmitted over the wire, the answer is React Flight — a serialisation protocol designed specifically for streaming React component trees from server to client.
Flight is not JSON. It's not HTML. It's a line-delimited text format where each line represents one unit of work in the React tree. Understanding its structure is how you understand why RSC works the way it does and why it has the constraints it has.
A minimal Flight payload looks like this:
1:I["(app-client)/components/AddToCartButton.tsx",["static/chunks/app/components/AddToCartButton.js"],"default"]
0:["$","div",null,{"className":"product","children":[["$","h1",null,{"children":"Blue Widget"}],["$","p",null,{"children":"$29.99"}],["$","$1",null,{"productId":"abc123"}]]}]
Line by line:
1:I[...]— Module reference.Imeans import. This declares that a Client Component exists at the given file path and chunk. It never contains the component's source code — only a reference.0:[...]— React element tree. The server's rendered output, as a JSON-like structure."$1"references the module declared on line 1. The server rendered everything it could; where a Client Component appears, it left a placeholder that points to the client bundle.
This is the key insight: the server renders Server Components to their final output. For Client Components, it emits a module reference ($1, $2, etc.) and stops. The client downloads those module references, renders the Client Components in the browser, and stitches the result into the tree React received from the server.
The Payload Type System
React Flight uses single-letter prefixes to identify what each line contains:
| Prefix | Meaning |
|---|---|
I | Import — a module reference to a Client Component chunk |
M | Module — similar to I, alternative format |
T | Text — a large string value, usually inline HTML |
S | Symbol — a React special value (Suspense, Fragment, etc.) |
E | Error — a thrown error (for error boundary activation) |
H | Hint — a preload/prefetch directive |
Suspense boundaries in the payload cause the server to flush a partial payload immediately, with a placeholder for the pending content. When the async data resolves, the server writes another chunk with the filled-in content. The client reconciler knows how to patch the placeholder without re-rendering the static parts of the tree.
How the Client Reconciler Processes a Flight Payload
When you navigate in the App Router, the browser doesn't receive HTML — it receives a Flight payload for the new page's Server Components. React processes it through createFromFetch() (or createFromReadableStream() for streaming responses):
1. Fetch RSC payload: GET /new-page (Accept: text/x-component)
2. createFromFetch() begins streaming
3. React reads each line — builds module import map, builds element tree
4. For module references: React.lazy()-like deferred loading of the chunk
5. Reconciles the new RSC element tree against the existing VDOM
6. Patches only the changed parts — Client Component subtrees are left intact
Step 6 is what makes App Router navigation feel different from a page reload. When you navigate from /dashboard to /dashboard/settings, React doesn't unmount and remount the dashboard layout Client Components. It reconciles the server-rendered output of the new page against the existing tree, leaving any Client Component state intact.
This is why a Client Component inside a persistent layout doesn't lose its state during navigation — the reconciler sees it's the same component at the same position in the tree and preserves it.
Why Client Components Can't Be Imported by Server Components
This constraint — which trips up every engineer at least once — has a mechanical explanation in the Flight protocol.
When the server renders a Server Component tree, it serialises everything it can. If it encounters an import to a Client Component, it emits an I (import) reference and stops. It cannot run the Client Component code because Client Component code is designed to run in the browser (it uses hooks, event handlers, browser APIs).
If a Server Component could import and render a Client Component inline, the server would need to execute Client Component code — which would require the browser APIs to exist on the server. The import boundary exists precisely to prevent this.
What works: passing a Server Component's output as children or a prop to a Client Component. In this case, the Server Component renders its children first, producing a Flight serialisable tree. The Client Component receives it as already-rendered opaque children. No Client Component code runs on the server.
React 19: Async Params and the use() Hook
React 19 (which Next.js 15 requires) made params and searchParams Promises. This is a breaking change with a specific motivation: React's concurrent renderer can suspend on Promises, and making params Promises allows Next.js to integrate param resolution with React's suspend/resume model.
In practice:
tsx// Before (Next.js 14 / React 18) export default function Page({ params }: { params: { id: string } }) { const { id } = params; // synchronous } // After (Next.js 15 / React 19) export default async function Page({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; // async }
In Client Components, where you can't use async/await, React 19 provides the use() hook:
tsx'use client'; import { use } from 'react'; export default function ClientPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); // use() reads a Promise synchronously within React's model return <div>{id}</div>; }
use() is not just for Promises — it's a general-purpose hook for reading context and Promises in the render function. Unlike useContext, use() can be called inside conditionals and loops.
The React Compiler
The React Compiler (previously React Forget) is a build-time transform that analyses component code and inserts useMemo, useCallback, and memo calls automatically where they're safe.
tsx// Your source code function ProductCard({ product, onAdd }) { return ( <div onClick={() => onAdd(product.id)}> {product.name} </div> ); } // What the compiler emits (conceptually) const ProductCard = memo(function ProductCard({ product, onAdd }) { const handleClick = useCallback(() => onAdd(product.id), [onAdd, product.id]); return ( <div onClick={handleClick}> {product.name} </div> ); });
The compiler analyses whether a component's output is a pure function of its inputs. If it is, the compiler adds memoisation. If it isn't (the component has side effects the compiler can't reason about), the compiler leaves it alone.
Enable it in next.config.ts:
tsconst config: NextConfig = { experimental: { reactCompiler: true, }, };
If the compiler incorrectly memoises a component (rare, but possible with unusual patterns), opt out per-component:
tsxfunction ComponentWithSideEffects() { 'use no memo'; // ← opt this component out of React Compiler // ... }
The compiler is opt-in in Next.js 15. It's a performance win for most codebases — automated memoisation that's more correct and complete than what engineers typically write manually.
The taint API — Preventing Secret Leakage
React's experimental_taintObjectReference and experimental_taintUniqueValue mark objects and values so React throws an error if they're ever serialised into the RSC payload and sent to the client.
ts// lib/db.ts import { experimental_taintObjectReference as taintObjectReference } from 'react'; export async function getUser(id: string) { const user = await db.users.findUnique({ where: { id } }); if (user) { // Mark the raw user object — if anyone tries to pass it to a Client Component, React throws taintObjectReference( 'Do not pass raw user objects to Client Components — use a DTO', user ); } return user; }
ts// Mark a specific sensitive value — the actual secret string import { experimental_taintUniqueValue as taintUniqueValue } from 'react'; taintUniqueValue( 'Do not pass API keys to Client Components', process, process.env.STRIPE_SECRET_KEY );
If a tainted object or value is passed as a prop to a Client Component (which would serialise it through the Flight protocol), React throws at render time — in development and production. The error message is the first argument you passed to the taint function, which you can make actionable.
Enable it in next.config.ts:
tsconst config: NextConfig = { experimental: { taint: true, }, };
This is a belt-and-suspenders measure on top of TypeScript types and server-only imports. For applications that handle payment data, PII, or credentials, it's worth enabling.
Hydration Deep Dive
Hydration is the process where React takes the static HTML sent by the server and "wakes it up" — attaching event listeners and initialising state. It is not a re-render. React reads the existing DOM and reconciles it against the expected component tree without producing new DOM nodes.
What causes hydration mismatches:
The most common mismatch source is non-deterministic rendering:
tsx// ❌ Different value on server and client export default function Timestamp() { return <time>{new Date().toLocaleString()}</time>; } // ❌ Different value on server and client export default function RandomId() { return <div id={Math.random().toString(36)}></div>; } // ✅ Deterministic — same value on server and client export default function Timestamp({ date }: { date: string }) { return <time>{new Date(date).toLocaleString('en-US', { timeZone: 'UTC' })}</time>; }
suppressHydrationWarning silences the mismatch warning for a specific element:
tsx// ✅ Legitimate use: content that intentionally differs (browser extensions inject content here) <div suppressHydrationWarning> {typeof window !== 'undefined' ? window.__USER_DATA__ : null} </div>
Only suppress hydration warnings when you understand why the mismatch is expected and safe. Using it to silence bugs is a common mistake that leads to invisible client-side rendering errors.
Where We Go From Here
A-2 covers the full request lifecycle — from DNS resolution through CDN edge to the Next.js server, including cold start anatomy, ISR revalidation internals, and the instrumentation hooks that let you observe what's happening at the server startup and client boot layers. With A-1's understanding of the Flight protocol, A-2 explains the infrastructure that carries it.