React Server Components Wire Format
What Problem Does the Flight Protocol Solve?
React Server Components run on the server and send their output to the client. But they can't just send HTML — they need to send something richer. HTML loses the React component tree structure. It can't express "this is a Server Component that rendered a Client Component with these props." It can't stream partial results. It can't send references to client-side JavaScript modules.
The Flight protocol is React's custom wire format for serializing the RSC tree into a streamable, type-safe format that preserves component boundaries, client component references, and data payloads.
Traditional SSR:
Server → HTML string → Client parses HTML → Hydrates with JS
RSC with Flight:
Server → Flight payload (streaming) → Client reconstructs React tree → Renders
The Mental Model
Think of the Flight protocol like a blueprint with assembly instructions being sent from a factory (server) to a construction site (client).
Traditional SSR sends the finished building as a photo (HTML). The construction site has to reverse-engineer the photo to figure out where the electrical wiring goes (hydration).
The Flight protocol sends actual blueprints: "Here's a wall (div). Inside it, put a cabinet (Server Component output, already built). Next to it, plug in this specific appliance (Client Component reference at ./components/Counter.js), and here are its settings (props: {initial: 5})."
The construction site follows the blueprint exactly — no guesswork, no reverse-engineering. It knows which parts are pre-built (Server Component output) and which parts need to be assembled on-site (Client Components).
The Flight Payload Format
When you navigate to an RSC-powered page, the response is not HTML — it's a stream of Flight payload lines. Each line starts with an ID and a type marker.
Let's trace a simple page:
// Server Component
export default function ProductPage({ params }) {
const product = getProduct(params.id)
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton productId={product.id} price={product.price} />
</div>
)
}
// Client Component
'use client'
export function AddToCartButton({ productId, price }) {
const [added, setAdded] = useState(false)
return (
<button onClick={() => { addToCart(productId); setAdded(true) }}>
{added ? 'Added!' : `Add to Cart - $${price}`}
</button>
)
}
The Flight payload for this page looks like:
0:["$","div",null,{"children":[
["$","h1",null,{"children":"Wireless Headphones"}],
["$","p",null,{"children":"Premium noise-canceling headphones"}],
["$","$L1",null,{"productId":42,"price":299}]
]}]
1:I["./components/AddToCartButton.js","AddToCartButton"]
Let's decode each part:
Line 0: The rendered React tree
"$" → React element marker
"div" → HTML element type
null → key (none)
{children} → child elements
"$L1" → "Lazy reference to module chunk 1"
This is a Client Component placeholder
Line 1: Module reference for chunk 1
"I" → Module import instruction
Path → JavaScript module to load
Export → Named export to use
The client reads this payload, builds the React tree, encounters $L1, downloads AddToCartButton.js, renders the Client Component with the serialized props {productId: 42, price: 299}.
Streaming Flight Payloads
The Flight protocol is designed for streaming. The server doesn't wait for all data to be ready — it sends chunks as they resolve.
export default async function Dashboard() {
const user = await getUser() // Fast: 20ms
return (
<div>
<h1>Welcome, {user.name}</h1>
<Suspense fallback={<p>Loading stats...</p>}>
<Stats /> {/* Slow: 500ms */}
</Suspense>
</div>
)
}
async function Stats() {
const stats = await getStats()
return <StatsGrid data={stats} />
}
The Flight payload streams in two chunks:
// Chunk 1 (sent at ~25ms):
0:["$","div",null,{"children":[
["$","h1",null,{"children":"Welcome, Sarah"}],
["$","$Sreact.suspense",null,{
"fallback":["$","p",null,{"children":"Loading stats..."}],
"children":"$L2"
}]
]}]
// Chunk 2 (sent at ~525ms, when stats data resolves):
2:["$","div",null,{"className":"stats-grid","children":[
["$","div",null,{"children":"Revenue: $45,200"}],
["$","div",null,{"children":"Users: 12,340"}]
]}]
The client receives Chunk 1 immediately, renders the welcome message and the loading fallback. When Chunk 2 arrives 500ms later, React replaces the Suspense fallback with the resolved Stats content — no page refresh, no client-side fetch needed.
Flight payload type markers
Each line in the Flight payload starts with an ID and a type marker:
ID:TYPE[DATA]
Type markers:
(none) → React element tree (the default)
I → Module import reference (Client Component)
S → Symbol (like react.suspense, react.fragment)
HL → Hint for preloading resources
T → Text content
E → Error
Some real examples:
0:["$","div",null,{"children":"Hello"}] → React element
1:I["./Counter.js","Counter"] → Client module reference
2:HL["/_next/static/css/app.css","style"] → Preload hint for CSS
3:E{"message":"Data fetch failed","stack":...} → Error boundary trigger
The HL (hint) lines are particularly clever. Before the React tree even finishes streaming, the server can send hints to the browser to start preloading CSS, fonts, or JavaScript that it knows will be needed. This parallelizes resource loading with the Flight stream.
How Data Flows Through RSC
Understanding the data flow is critical for building efficient RSC applications:
┌─────────────────────────────────────────┐
│ SERVER │
│ │
│ ┌────────────────────┐ │
│ │ Server Component │ │
│ │ (runs on server) │ │
│ │ │ │
│ │ - Fetches data │ │
│ │ - Reads database │ │
│ │ - Access secrets │ │
│ │ │ │
│ │ Outputs: │ │
│ │ - HTML elements │ │
│ │ - Serialized props │ │
│ │ - Client Component │ │
│ │ references │ │
│ └────────┬───────────┘ │
│ │ │
│ Flight Protocol │
│ (streaming serialization) │
│ │ │
└───────────┼──────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ CLIENT │
│ │
│ Flight Decoder reads payload: │
│ - Renders HTML elements directly │
│ - Downloads Client Component JS │
│ - Hydrates Client Components with │
│ serialized props │
│ │
│ ┌────────────────────┐ │
│ │ Client Component │ │
│ │ (runs in browser) │ │
│ │ │ │
│ │ - useState/useEffect│ │
│ │ - Event handlers │ │
│ │ - Browser APIs │ │
│ └────────────────────┘ │
└─────────────────────────────────────────┘
What Can Be Serialized in Flight
The Flight protocol can serialize these types across the server/client boundary:
Serializable (can pass as props to Client Components):
✅ Strings, numbers, booleans, null, undefined
✅ Arrays and plain objects (containing serializable values)
✅ Date objects
✅ Map and Set
✅ Typed arrays (Uint8Array, etc.)
✅ React elements (JSX) — including Server Component output
✅ Promises (streamed via Suspense)
NOT serializable (cannot pass as props):
❌ Functions (closures capture server scope)
❌ Classes (non-plain objects)
❌ Symbols (except React-internal ones)
❌ DOM nodes
❌ Streams (Node.js streams, ReadableStream)
RSC Payload During Navigation
When you navigate between pages in a Next.js app, the browser doesn't request full HTML pages. It requests Flight payloads:
Initial page load (full HTML + Flight payload embedded):
GET /products/42
Response: Full HTML document with embedded Flight payload
→ Browser renders HTML immediately
→ React hydrates using the embedded Flight payload
Client-side navigation (Flight payload only):
GET /products/43 (with RSC headers)
Response: Just the Flight payload (no HTML wrapper)
→ React reads the Flight payload
→ Updates the component tree in place
→ No full page reload
// Request headers for RSC navigation:
RSC: 1 // "I want the Flight payload, not HTML"
Next-Router-State-Tree: [...] // Current router state for diffing
Next-Url: /products/43 // The target URL
This is why Next.js App Router navigations feel like SPA transitions — the browser fetches only the Flight payload for the new route, and React reconciles the new tree with the current one, updating only what changed.
Caching Flight Payloads
Next.js caches Flight payloads in the client-side Router Cache:
First visit to /products/42:
→ Full render on server
→ Flight payload sent and cached in Router Cache (in memory)
Navigate away to /products/43:
→ New Flight payload fetched and cached
Navigate back to /products/42:
→ Router Cache hit — instant navigation, no server request
→ In Next.js 15, dynamic pages are not cached in the Router Cache by default (staleTimes.dynamic defaults to 0). Static pages cache for 5 minutes. These values are configurable via staleTimes in next.config.
Production Scenario: Building an RSC-Powered Dashboard
A team builds a dashboard where the main layout is a Server Component that fetches shared data, and individual widgets are a mix of Server and Client Components:
// Server Component: Dashboard layout
export default async function Dashboard() {
const org = await getOrganization() // Server-side, no API needed
return (
<div>
<DashboardHeader org={org} />
<div className="grid grid-cols-3 gap-4">
{/* Server Component: data fetched on server */}
<Suspense fallback={<CardSkeleton />}>
<RevenueCard orgId={org.id} />
</Suspense>
{/* Client Component: needs interactivity for chart hover */}
<Suspense fallback={<CardSkeleton />}>
<InteractiveChart orgId={org.id} />
</Suspense>
{/* Server Component rendered, Client Component inside */}
<Suspense fallback={<CardSkeleton />}>
<UserList orgId={org.id} />
</Suspense>
</div>
</div>
)
}
The Flight payload for this page streams as:
Chunk 1: Layout + header (immediate)
→ DashboardHeader renders to HTML elements in the Flight payload
→ Three Suspense boundaries with fallbacks
Chunk 2: RevenueCard resolves (100ms)
→ Pure Server Component output — just HTML elements, zero client JS
Chunk 3: InteractiveChart resolves (300ms)
→ Contains a $L reference to the chart Client Component
→ Props include the pre-fetched chart data (serialized)
→ Client downloads chart JS and renders with the data
Chunk 4: UserList resolves (200ms)
→ Server-rendered user list HTML
→ Contains a $L reference to a "Load More" Client Component button
The beauty: RevenueCard ships zero JavaScript to the client. It's a Server Component that renders to plain HTML elements in the Flight payload. The data fetching, database queries, and rendering all happened on the server.
Common Mistakes
| What developers do | What they should do |
|---|---|
| Passing functions as props from Server Components to Client Components Functions capture server-side closure scope and cannot be serialized in the Flight protocol. If you need to trigger a server operation from a Client Component, use a Server Action (which serializes as a special reference). | Define event handlers and callbacks inside the Client Component, or use Server Actions for mutations |
| Creating an API route to fetch data that a Server Component could fetch directly Server Components eliminate the API layer for data fetching. Creating /api/getData and fetching it from a Server Component adds an unnecessary network hop. Just import your database client and query directly. | Fetch data directly in the Server Component — it runs on the server and has full access to databases, secrets, and internal services |
| Making every component a Client Component because you are unsure about the Server/Client boundary Every Client Component adds JavaScript to the browser bundle. Server Components ship zero JS. Keep the 'use client' boundary as deep in the tree as possible — push interactivity to leaf components. | Default to Server Components. Only add 'use client' when a component needs hooks, event handlers, or browser APIs |
| Assuming the Flight payload is a REST API that you can call directly The Flight format is undocumented, unstable, and can change between React versions. Never parse it manually or build tooling on top of its structure. Use React's official APIs (Server Components, Server Actions) to interact with it. | The Flight protocol is an internal wire format between React server and client runtimes — not a public API |
Key Rules
- 1The Flight protocol is React's streaming wire format for serializing Server Component trees, Client Component references, and data into a streamable payload.
- 2Server Components render to React elements in the Flight payload. Client Components appear as lazy module references ($L) with serialized props.
- 3Only serializable values can cross the server/client boundary: strings, numbers, objects, arrays, Dates, Maps, Sets. Functions and classes cannot be serialized.
- 4Flight payloads stream via Suspense boundaries — resolved chunks are sent as they complete, allowing progressive UI updates.
- 5Client-side navigation in Next.js fetches Flight payloads (not HTML), enabling SPA-like transitions without full page reloads.
- 6Server Components ship zero JavaScript to the client. Keep the 'use client' boundary as deep in the component tree as possible to minimize client bundle size.