The RSC payload
Read Next.js 16's RSC payload row by row: Flight's tags, the router's RSC headers and _rsc, self.__next_f in the HTML, streaming, and hydration.
- 01Read a payload row by rowSplit a recorded payload into rows, name the tags in these recordings (I, H, T, E, a plain JSON row) and decode the $ strings they use.
- 02Explain the router's requestsSay what rsc, next-router-prefetch, next-router-segment-prefetch, next-router-state-tree and _rsc ask for, and what text/x-component answer each gets.
- 03Follow the payload through the HTMLFind the payload in self.__next_f scripts, see how it streams beside the HTML, and trace how the browser's React hydrates from it.
The payload that travels with every page
How a route becomes a React tree ended with one render: Next.js renders your Server Components once, to the RSC payload, and makes the HTML from it. The RSC payload is the text React writes for a tree of Server Components: what they rendered, and references to the Client Components. React calls its format Flight. The payload consists of rows. The router asks for it with requests of its own, the HTML carries it in scripts, and the browser's React reads it back.
The app is the tea shop. /teas/mint is made during the build: a heading, a long story about the tea, an AddToCart button (a Client Component), and reviews from a cached function. /teas/today waits for a request with connection() inside a Suspense boundary, so it's a static shell with one hole. /teas/oolong's stock fails on purpose. This is a real Next.js 16.4.3 project, built with next build and run with next start:
app/teas/mint/page.tsximport { Suspense } from "react";import AddToCart from "./add-to-cart";import Reviews from "./reviews";import { getReviews } from "./get-reviews";import { story } from "./story"; export default function MintPage() { return ( <main> <h1>Mint tea</h1> <p>{story}</p> <AddToCart tea="Mint tea" price={4} packed={new Date("2026-04-20")} note={undefined} /> <Suspense fallback={<p>Loading reviews…</p>}> <Reviews reviews={getReviews()} /> </Suspense> </main> );}GET /teas/mint?_rsc=200
- Sent with the request
rsc1
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300Cache-Controls-maxage=900, stale-while-revalidate=31535100Content-Typetext/x-component
The body that arrived, as it arrived: the RSC payload, one row per line
#1:"$Sreact.fragment"
2:"/_next/static/chunks/3bif7kg6ax7bg.js"
3:I[22016,["$2"],""]
4:I[39756,["$2"],"default"]
5:I[37457,["$2"],"default"]
c:"/_next/static/chunks/14rjea88p4ef4.js"
d:I[31094,["$2","$c"],"default"]
e:"$Sreact.suspense"
f:I[47261,["$2","$c"],"default"]
11:I[97367,["$2"],"OutletBoundary"]
14:"ViewportBoundary"
15:I[97367,["$2"],"$14"]
17:"MetadataBoundary"
18:I[97367,["$2"],"$17"]
1b:I[68027,["$2"],"default",1]
:HL["/_next/static/chunks/0pq7am_isphfw.css","style"]
6:X
8:X
b:T42b,Our mint tea starts in a small garden in the hills, where the leaves are picked by hand early in the morning, before the sun gets hot. The leaves are spread on wide trays and dried slowly in the shade for three days, so they keep their green color and their cool smell. Then we cut them into small pieces, check every batch by hand, and pack them into paper boxes the same week. To brew it, use one spoon of leaves for each cup, pour water that has just stopped boiling, and wait four minutes. Drink it plain, or add a slice of lemon or a little honey. It has no caffeine, so many people drink it after dinner or before bed. In summer, brew it twice as strong, let it cool, and pour it over ice for a fresh cold drink. Each box holds fifty grams of leaves, which is enough for about twenty five cups. Keep the box closed, in a dry and dark place, and use the leaves within six months for the best taste. If the leaves lose their smell, they are old: brew them a little longer, or use them for iced tea. We test every new harvest ourselves before it goes on the shelf.13:X
a:[["children",{"s":"__PAGE__","h":514,"d":{"r":["$","$1","c",{"children":[["$","main",null,{"children":[["$","h1",null,{"children":"Mint tea"}],["$","p",null,{"children":"$b"}],["$","$Ld",null,{"tea":"Mint tea","price":4,"packed":"$D2026-04-20T00:00:00.000Z","note":"$undefined"}],["$","$e",null,{"fallback":["$","p",null,{"children":"Loading reviews…"}],"children":["$","$Lf",null,{"reviews":"$@10"}]}]]}],[["$","script","script-0",{"src":"/_next/static/chunks/14rjea88p4ef4.js","async":true,"nonce":"$undefined"}]],["$","$L11",null,{"children":["$","$e",null,{"name":"Next.MetadataOutlet","children":"$@12"}]}]]}],"p":true,"v":"$13"}}]]
9:[["children",{"s":"mint","h":514,"d":{"r":["$","$1","c",{"children":[null,["$","$L4",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],"p":true,"v":"$8"},"c":"$Qa"}]]
7:[["children",{"s":"teas","h":514,"d":{"r":["$","$1","c",{"children":[null,["$","$L4",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],"p":true,"v":"$8"},"c":"$Q9"}]]
1a:X
1c:X
1d:X
0:{"P":null,"c":["","teas","mint"],"q":"","i":false,"t":{"t":{"s":"","h":530,"d":{"r":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/0pq7am_isphfw.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/3bif7kg6ax7bg.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":[["$","nav",null,{"children":[["$","$L3",null,{"href":"/","children":"Home"}]," ",["$","$L3",null,{"href":"/teas/mint","children":"Mint tea"}]," ",["$","$L3",null,{"href":"/teas/today","children":"Tea of the day"}]]}],["$","$L4",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]]}]}]]}],"p":true,"v":"$6"},"c":"$Q7"},"h":{"r":["$","$1","h",{"children":[null,["$","$L15",null,{"children":"$L16"}],["$","$L18",null,{"children":[["$","div",null,{"hidden":true,"children":["$","$e",null,{"name":"Next.Metadata","children":"$L19"}]}],null]}],null]}],"p":true,"v":"$1a"}},"m":"$undefined","G":["$1b",[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/0pq7am_isphfw.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"S":true,"r":"$1c","s":"$1d","a":"$@1e","l":"$undefined","p":"$undefined","d":"$undefined","b":"NQzsD1K0uY4DBy6wTLdvG","u":"$@1f"}
8:C
1d:300
1d:300
10:["Fresh and bright.","My favorite after dinner."]
16:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
12:null
19:[]
1d:C
1a:C
1c:C
13:C
6:C
1f:false
1e:null
In app/teas/mint/page.tsx, shown first, line 11 renders the story, line 12 passes four props to AddToCart, and lines 13 to 15 give Reviews a promise from getReviews(). The Network tab opens on the request the recorder sent with the header rsc: 1, the way Next.js's router asks for a page. Its answer isn't HTML: its Content-Type is text/x-component, and its body starts like this:
#1:"$Sreact.fragment"
2:"/_next/static/chunks/3bif7kg6ax7bg.js"
3:I[22016,["$2"],""]Each line is a row. A row starts with a number and a colon, and the rest says what the row holds. Line 1 begins with a #, one byte Next.js puts in front of a prerendered payload; it isn't part of any row. Line 3 names a Client Component's module. Every part of the payload is built from rows like these, and the picture takes two of them apart:
T row says its length first, and ends after exactly that many bytes.A friend sends you a recipe by text, one message at a time. Each message has a number, and some start with a word that says what it is. The main message says "use the sauce from message 7", and message 7 may come later, when the sauce is ready. You start cooking with what you have, and fill in each part when its message arrives.
- In real life: Each message starts with its number
- In Next.js: Each row starts with its id and a colon
- In real life: A word at the start saying what the message is: a photo, a shop's address
- In Next.js: The tag letter: T for text, I for a Client Component's module
- In real life: "Use the sauce from message 7"
- In Next.js: "$7": the value of row 7
- In real life: "The sauce message comes when it's ready"
- In Next.js: "$@7": a promise, resolved when row 7 arrives
- In real life: A tip with no number: "buy fresh mint first"
- In Next.js: A hint row, :HL…, that asks the browser to preload a file
The RSC payload is a list of numbered rows. Each row is an id in hexadecimal, an optional tag letter, and data, usually JSON; strings that start with $ point to other rows or stand for values JSON can't hold. Next.js sends it as text/x-component when the router asks with rsc: 1, and inside the HTML, in self.__next_f scripts, for the first visit, so the browser's React can hydrate the page without asking again.
The rows are React's format, not Next.js's. Next.js 16.4.3 doesn't use the React in your node_modules for the App Router: it ships its own build in next/dist/compiled, React 19.3.0-canary-278794d7-20261002, made from one commit of React's main branch. Every React excerpt here is from that commit, and matches the compiled files the recorded app ran. These are React's internals, and the React Server Components internals lesson reads them in more depth, with a payload you can edit and run.
Reading the Flight format, row by row
React's Flight server writes every row in ReactFlightServer.js. Here is how each kind of row starts, from lines 3157 to 3159, 4809, 4824, 4836, 5214 and 4663 to 4666:
function serializeRowHeader(tag: string, id: number) {
return id.toString(16) + ':' + tag;
}
// …
const row = serializeRowHeader('I', id) + json + '\n';
// …
const row = ':H' + code + json + '\n';
// …
const row = id.toString(16) + ':' + json + '\n';
// …
const row = id.toString(16) + ':T' + binaryLength.toString(16) + ',';
// …
} else {
errorInfo = {digest};
}
const row = serializeRowHeader('E', id) + stringify(errorInfo) + '\n';- Lines 1 to 3: a row's header is its id in hexadecimal (
id.toString(16)), a colon, and a tag. Row 13 is writtend. - Line 5: an import row, tag
I, names a Client Component's module. - Line 7: a hint starts with only
:Hand a code letter. It has no id, because no other row points to it. - Line 9: a plain JSON row has no tag at all: the JSON follows the colon.
- Line 11: a text row, tag
T, gives the text's length in bytes, in hexadecimal, and a comma. The text follows, with no line break after it. - Lines 13 to 16: an error row, tag
E. In a production build, the error's information is only itsdigest.
This is the whole recorded payload of /teas/mint, one row per line, each cut to 100 characters:
#1:"$Sreact.fragment"
2:"/_next/static/chunks/3bif7kg6ax7bg.js"
3:I[22016,["$2"],""]
4:I[39756,["$2"],"default"]
5:I[37457,["$2"],"default"]
c:"/_next/static/chunks/14rjea88p4ef4.js"
d:I[31094,["$2","$c"],"default"]
e:"$Sreact.suspense"
f:I[47261,["$2","$c"],"default"]
11:I[97367,["$2"],"OutletBoundary"]
14:"ViewportBoundary"
15:I[97367,["$2"],"$14"]
17:"MetadataBoundary"
18:I[97367,["$2"],"$17"]
1b:I[68027,["$2"],"default",1]
:HL["/_next/static/chunks/0pq7am_isphfw.css","style"]
6:X
8:X
b:T42b,Our mint tea starts in a small garden in the hills, where the leaves are picked by hand earl…
13:X
a:[["children",{"s":"__PAGE__","h":514,"d":{"r":["$","$1","c",{"children":[["$","main",null,{"child…
9:[["children",{"s":"mint","h":514,"d":{"r":["$","$1","c",{"children":[null,["$","$L4",null,{"paral…
7:[["children",{"s":"teas","h":514,"d":{"r":["$","$1","c",{"children":[null,["$","$L4",null,{"paral…
1a:X
1c:X
1d:X
0:{"P":null,"c":["","teas","mint"],"q":"","i":false,"t":{"t":{"s":"","h":530,"d":{"r":["$","$1","c"…
8:C
1d:300
1d:300
10:["Fresh and bright.","My favorite after dinner."]
16:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device…
12:null
19:[]
1d:C
1a:C
1c:C
13:C
6:C
1f:false
1e:null- Line 1, after the
#: row 1 is the string"$Sreact.fragment". Lines 2 and 6 are strings too: the addresses of two JavaScript chunks. - Lines 3 to 15 are mostly import rows. Line 7, row
d, isAddToCart; line 9, rowf, isReviews; the others are Next.js's own Client Components, likeLinkon line 3. Each one is[module id, chunks, export], and the chunks are"$2"and"$c": pointers to the strings on lines 2 and 6. - Line 16 is a hint, with no id: preload the page's stylesheet.
- Line 19 is row
b, a text row with the story. - Lines 21 to 23 are plain JSON rows: Next.js's data for the segments
__PAGE__,mintandteas. Line 27 is row 0, the root, which points to them. - Lines 17, 18, 20 and 24 to 26 open streams with
X. Lines 29 and 30 send a value on one of them, and theCrows on lines 28 and 35 to 39 close them. Line 31 is the value of the reviews promise.
Imports and hints come first because React flushes its queues in that order: import rows (and symbols, like line 1), then hints, then plain rows and text, then errors (flushCompletedChunks, ReactFlightServer.js, lines 6589 to 6697). So the browser can start loading the chunks before it reads the rows that use them.
| The tag | What the row holds | Written by | In the recording |
|---|---|---|---|
| No tag (plain JSON) | A value as JSON: an element, an object, a list, a string or a number. | emitModelChunk | a:[["children",{"s":"__PAGE__",…, the page's segment. |
I | A Client Component's module: its id, the chunks to load, and the export. | emitImportChunk | d:I[31094,["$2","$c"],"default"], AddToCart. |
H | A hint with no id: preload a stylesheet, a font or a script. | emitHintChunk | :HL["/_next/static/chunks/….css","style"]. |
T | A long string, after its length in bytes (hexadecimal) and a comma, with no line break. | emitTextChunk | b:T42b,Our mint tea starts…, the 1,067-character story. |
E | An error. In production it holds only a digest. | emitErrorChunk | f:E{"digest":"154446600"} in /teas/oolong's payload. |
X, R, o, C | A stream: X opens an async iterable and R a ReadableStream; their values come later on rows with the same id (o for bytes), and C closes it. | serializeAsyncIterable, serializeReadableStream | 6:X … 6:C, and 1d:300, the stale time. |
D, J, N, W | Development rows: debug information, timings, console calls. A production client throws if it gets one. | emitDebugChunk and others, only in development | None: the recorded app is a production build. |
The table isn't every tag React has. Rows of raw bytes for typed arrays have tags of their own (A, O, U and others, besides the o in these recordings); the tea shop sends none of them. On the other side, the browser's Flight client reads each row by its tag, in ReactFlightClient.js, lines 5236 to 5327:
case 73 /* "I" */: {
resolveModule(response, id, row, streamState);
return;
}
case 72 /* "H" */: {
const code: HintCode = row[0] as any;
resolveHint(response, code, row.slice(1));
return;
}
case 69 /* "E" */: {
resolveErrorModel(response, id, row, streamState);
return;
}
case 84 /* "T" */: {
resolveText(response, id, row, streamState);
return;
}
// …
case 74 /* "J" */: {
if (enableProfilerTimer && enableAsyncDebugInfo) {
resolveIOInfo(response, id, row);
return;
}
// Fallthrough to share the error with Console entries.
}
case 87 /* "W" */: {
if (__DEV__) {
resolveConsoleEntry(response, row);
return;
}
throw new Error(
'Failed to read a RSC payload created by a development version of React ' +
'on the server while using a production version on the client. Always use ' +
'matching versions on the server and the client.',
);
}
// …
default: /* """ "{" "[" "t" "f" "n" "0" - "9" */ {
if (__DEV__ && row === '') {
resolveDebugHalt(response, id);
return;
}
// We assume anything else is JSON.
resolveModel(response, id, row, streamState);
return;
}Lines 1 to 17 hand I, H, E and T rows to their functions. Line 19 is a J row, which only development builds write (it describes the server's I/O, like a fetch, for DevTools); in a production client it falls through to line 26, and line 31 throws: a production client refuses a development server's payload. Lines 38 to 46: anything else is a plain JSON row, and line 43's comment says it in React's own words.
Long strings get a row of their own
The story didn't stay inside the page's JSON. While React writes a value, a long string goes elsewhere, from ReactFlightServer.js, lines 4297 to 4302:
// $FlowFixMe[invalid-compare]
if (value.length >= 1024 && byteLengthOfChunk !== null) {
// For large strings, we encode them outside the JSON payload so that we
// don't have to double encode and double parse the strings. This can also
// be more compact in case the string has a lot of escaped characters.
return serializeLargeTextString(request, value);Line 2: a string whose length is 1,024 or more (on a server that can count bytes) goes to serializeLargeTextString, which writes it as a T row and puts "$" and the row's id in its place. The comments on lines 3 to 5 say why: the text isn't escaped and parsed as JSON twice. A JavaScript string's length counts UTF-16 code units, the string's own units, not characters or bytes: an emoji is 2 units. The recorded story is plain ASCII, so its 1,067 units are also 1,067 characters and 1,067 bytes. In the recorded page row, the <p>'s children are only a pointer:
a:…{"children":"Mint tea"}],["$","p",null,{"children":"$b"}],["$","$Ld",null,{…Pick each version below, and look at the payload in the Network tab:
app/teas/mint/story.tsexport const story = "Our mint tea starts in a small garden in the hills, where the leaves are picked by hand early in the morning, before the sun gets hot. The leaves are spread on wide trays and dried slowly in the shade for three days, so they keep their green color and their cool smell. Then we cut them into small pieces, check every batch by hand, and pack them into paper boxes the same week. To brew it, use one spoon of leaves for each cup, pour water that has just stopped boiling, and wait four minutes. Drink it plain, or add a slice of lemon or a little honey. It has no caffeine, so many people drink it after dinner or before bed. In summer, brew it twice as strong, let it cool, and pour it over ice for a fresh cold drink. Each box holds fifty grams of leaves, which is enough for about twenty five cups. Keep the box closed, in a dry and dark place, and use the leaves within six months for the best taste. If the leaves lose their smell, they are old: brew them a little longer, or use them for iced tea. We test every new harvest ourselves before it goes on the shelf.";GET /teas/mint?_rsc=200
- Sent with the request
rsc1
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300Cache-Controls-maxage=900, stale-while-revalidate=31535100Content-Typetext/x-component
The body that arrived, as it arrived: the RSC payload, one row per line
#1:"$Sreact.fragment"
2:"/_next/static/chunks/3bif7kg6ax7bg.js"
3:I[22016,["$2"],""]
4:I[39756,["$2"],"default"]
5:I[37457,["$2"],"default"]
c:"/_next/static/chunks/14rjea88p4ef4.js"
d:I[31094,["$2","$c"],"default"]
e:"$Sreact.suspense"
f:I[47261,["$2","$c"],"default"]
11:I[97367,["$2"],"OutletBoundary"]
14:"ViewportBoundary"
15:I[97367,["$2"],"$14"]
17:"MetadataBoundary"
18:I[97367,["$2"],"$17"]
1b:I[68027,["$2"],"default",1]
:HL["/_next/static/chunks/0pq7am_isphfw.css","style"]
6:X
8:X
b:T42b,Our mint tea starts in a small garden in the hills, where the leaves are picked by hand early in the morning, before the sun gets hot. The leaves are spread on wide trays and dried slowly in the shade for three days, so they keep their green color and their cool smell. Then we cut them into small pieces, check every batch by hand, and pack them into paper boxes the same week. To brew it, use one spoon of leaves for each cup, pour water that has just stopped boiling, and wait four minutes. Drink it plain, or add a slice of lemon or a little honey. It has no caffeine, so many people drink it after dinner or before bed. In summer, brew it twice as strong, let it cool, and pour it over ice for a fresh cold drink. Each box holds fifty grams of leaves, which is enough for about twenty five cups. Keep the box closed, in a dry and dark place, and use the leaves within six months for the best taste. If the leaves lose their smell, they are old: brew them a little longer, or use them for iced tea. We test every new harvest ourselves before it goes on the shelf.13:X
a:[["children",{"s":"__PAGE__","h":514,"d":{"r":["$","$1","c",{"children":[["$","main",null,{"children":[["$","h1",null,{"children":"Mint tea"}],["$","p",null,{"children":"$b"}],["$","$Ld",null,{"tea":"Mint tea","price":4,"packed":"$D2026-04-20T00:00:00.000Z","note":"$undefined"}],["$","$e",null,{"fallback":["$","p",null,{"children":"Loading reviews…"}],"children":["$","$Lf",null,{"reviews":"$@10"}]}]]}],[["$","script","script-0",{"src":"/_next/static/chunks/14rjea88p4ef4.js","async":true,"nonce":"$undefined"}]],["$","$L11",null,{"children":["$","$e",null,{"name":"Next.MetadataOutlet","children":"$@12"}]}]]}],"p":true,"v":"$13"}}]]
9:[["children",{"s":"mint","h":514,"d":{"r":["$","$1","c",{"children":[null,["$","$L4",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],"p":true,"v":"$8"},"c":"$Qa"}]]
7:[["children",{"s":"teas","h":514,"d":{"r":["$","$1","c",{"children":[null,["$","$L4",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],"p":true,"v":"$8"},"c":"$Q9"}]]
1a:X
1c:X
1d:X
0:{"P":null,"c":["","teas","mint"],"q":"","i":false,"t":{"t":{"s":"","h":530,"d":{"r":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/0pq7am_isphfw.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/3bif7kg6ax7bg.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":[["$","nav",null,{"children":[["$","$L3",null,{"href":"/","children":"Home"}]," ",["$","$L3",null,{"href":"/teas/mint","children":"Mint tea"}]," ",["$","$L3",null,{"href":"/teas/today","children":"Tea of the day"}]]}],["$","$L4",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]]}]}]]}],"p":true,"v":"$6"},"c":"$Q7"},"h":{"r":["$","$1","h",{"children":[null,["$","$L15",null,{"children":"$L16"}],["$","$L18",null,{"children":[["$","div",null,{"hidden":true,"children":["$","$e",null,{"name":"Next.Metadata","children":"$L19"}]}],null]}],null]}],"p":true,"v":"$1a"}},"m":"$undefined","G":["$1b",[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/0pq7am_isphfw.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"S":true,"r":"$1c","s":"$1d","a":"$@1e","l":"$undefined","p":"$undefined","d":"$undefined","b":"NQzsD1K0uY4DBy6wTLdvG","u":"$@1f"}
8:C
1d:300
1d:300
10:["Fresh and bright.","My favorite after dinner."]
16:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
12:null
19:[]
1d:C
1a:C
1c:C
13:C
6:C
1f:false
1e:null
The story has 1,067 characters. In the payload it's a row of its own, b:T42b,Our mint tea…, and the <p>'s children is only "$b".
With one sentence, the story is inside the <p>, and there's no T row. The rule is about the string's length, not about where it comes from: a long prop for a Client Component travels the same way.
Errors carry a digest
/teas/oolong's Stock throws The supplier didn't answer while the request is rendered. The recorder asked for its payload with rsc: 1. Where the stock would go, the payload has "$L" and an id, and that row is:
f:E{"digest":"154446600"}Only a digest: a short code that names the error without saying what it was. The message stayed on the server. next start printed it, with the same digest:
⨯ Error: The supplier didn't answer
digest: '154446600'Line 1 has the message, and line 2 the digest that matches the row. In the browser, React's Flight client rejects that row's value with an error that has only the digest (resolveErrorModel), so the error boundary from app/teas/oolong/error.tsx catches an error whose error.digest is that code. That's line 14 of the excerpt above: production writes {digest} and nothing else, so a message with private details never reaches the browser.
1:"$Sreact.fragment"- Written by
- serializeSymbolReference in ReactFlightServer.js
- Read by
- parseModelString, case S, in ReactFlightClient.js
A plain JSON row holding a string. A string that starts with $S stands for a symbol: the client turns it into Symbol.for("react.fragment"), React's mark for <>…</>. Other rows point to it with $ and its id.
Values that start with $
JSON has strings, numbers, booleans, null, lists and objects. A React tree has more: elements, symbols, dates, promises, undefined, and pointers to other rows. Flight writes each of them as a string that starts with $, from ReactFlightServer.js, lines 3073 to 3145:
function serializeByValueID(id: number): string {
return '$' + id.toString(16);
}
function serializeLazyID(id: number): string {
return '$L' + id.toString(16);
}
function serializePromiseID(id: number): string {
return '$@' + id.toString(16);
}
// …
function serializeSymbolReference(name: string): string {
return '$S' + name;
}
// …
function serializeUndefined(): string {
return '$undefined';
}
function serializeDate(date: Date): string {
// JSON.stringify automatically calls Date.prototype.toJSON which calls toISOString.
// We need only tack on a $D prefix.
return '$D' + date.toJSON();
}Lines 1 to 11 point to another row: $ for its value, $L for a lazy value, $@ for a promise, each with the id in hexadecimal. Line 14 writes a symbol by its name, line 18 writes undefined, and line 24 a date. Find them in the page's row, around AddToCart:
a:…"}],["$","$Ld",null,{"tea":"Mint tea","price":4,"packed":"$D2026-04-20T00:00:00.000Z","note":"$undefined"}],["$","$e",null,{"f…["$","$Ld",null,{…}]is an element:"$"marks it, its type is"$Ld"(rowd,AddToCart's import, as a lazy value), its key isnull, and the object holds its props."packed":"$D2026-04-20T00:00:00.000Z"is theDatefrom line 12 ofpage.tsx."note":"$undefined"isnote={undefined}.- The next element's type is
"$e": rowe,"$Sreact.suspense", theSuspensefrom line 13.
The browser's Flight client turns each one back, in parseModelString, ReactFlightClient.js, lines 2659 to 2847:
if (value[0] === '$') {
if (value === '$') {
// …
return REACT_ELEMENT_TYPE;
}
switch (value[1]) {
case '$': {
// This was an escaped string value.
return value.slice(1);
}
case 'L': {
// Lazy node
const id = parseInt(value.slice(2), 16);
const chunk = getChunk(response, id);
// …
return createLazyChunkWrapper(chunk, 0);
}
case '@': {
// Promise
const id = parseInt(value.slice(2), 16);
const chunk = getChunk(response, id);
// …
return chunk;
}
// …
case 'S': {
// Symbol
return Symbol.for(value.slice(2));
}
// …
case 'Q': {
// Map
const ref = value.slice(2);
return getOutlinedModel(response, ref, parentObject, key, createMap);
}
// …
case 'u': {
// matches "$undefined"
// Special encoding for `undefined` which can't be serialized as JSON otherwise.
return undefined;
}
case 'D': {
// Date
return new Date(Date.parse(value.slice(2)));
}- Line 2: a lone
"$"is React's element marker. - Lines 7 to 9:
$$is a real string that starts with$, escaped. - Lines 11 to 16:
$Lreads the id in hexadecimal (parseInt(…, 16)), gets that row's chunk, and wraps it likeReact.lazy, so React can suspend on it until the row has arrived. - Lines 18 to 23:
$@returns the row's chunk itself, which is a promise. - Line 28:
$SbecomesSymbol.for(name); lines 31 to 34:$Qbecomes aMap. - Line 40:
$undefinedbecomesundefined; line 44:$Dbecomes aDate.
| The string | What the client makes of it | In the recording |
|---|---|---|
$ | The element marker, React's $$typeof, at the start of an element's list. | ["$","main",null,{…}] is a <main>. |
$ and an id | The value of that row. | "children":"$b" is the story in row b. |
$L and an id | A lazy value: React suspends on it until the row arrives or the module loads. | "$Ld" is the AddToCart element's type. |
$@ and an id | A promise, resolved when that row arrives. | "reviews":"$@10". |
$S and a name | Symbol.for(name). | "$Sreact.suspense" is <Suspense>. |
$Q and an id | A Map, built from that row's entries. | "c":"$Q9", a segment's children. |
$D and a date | A Date. | "packed":"$D2026-04-20T00:00:00.000Z". |
$undefined | undefined: the client deletes that key from the object. | "note":"$undefined". |
These are the $ strings in these recordings, not all of them. In this React build, $h and an id is a Server Function (a server reference), and $T a temporary reference, used when a Server Function sends values back; the tea shop uses neither. Older React builds wrote server references as $F; the build Next.js 16.4.3 ships has no $F.
undefined has one more step. While the client walks an object, a value that comes back undefined is deleted from it, in ReactFlightClient.js, lines 5685 to 5698:
for (const k in value) {
if (hasOwnProperty.call(value, k)) {
if (k === __PROTO__) {
delete (value as any)[k];
} else {
const walked = reviveModel(response, (value as any)[k], value, k);
if (walked !== undefined) {
(value as any)[k] = walked;
} else {
delete (value as any)[k];
}
}
}
}Lines 9 and 10: the key is deleted. So AddToCart gets props without a note key, which is the same as note={undefined} for a component. You can see it in the recording: Next.js makes the page's prefetch data by decoding the full payload and writing it again, one stream per segment (app-render.tsx, lines 11052 to 11063), and in that prefetch the props are {"tea":"Mint tea","price":4,"packed":"$D2026-04-20T00:00:00.000Z"}],…, with no note.
Next.js's root row
The rows are React's; what's in them is Next.js's. Row 0 is the object getRSCPayload built in app-render, and its keys are short, as the type in app-router-types.ts, lines 421 to 437, says:
export type InitialRSCPayload = {
/** buildId, can be empty if the x-nextjs-build-id header is set */
b?: string
/** initialCanonicalUrlParts */
c: string[]
/** initialRenderedSearch */
q: string
/** couldBeIntercepted */
i: boolean
/** initial transport data — the tree + head for hydration */
t: FullTransportData
/** missingSlots */
m: Set<string> | undefined
/** GlobalError */
G: [React.ComponentType<any>, React.ReactNode | undefined]
/** supportsPerSegmentPrefetching */
S: booleanLine 3, b, is the build's id; line 5, c, the address's segments; line 11, t, the tree and the head; line 15, G, the global error page; line 17, S, whether the build made per-segment prefetches. The recorded row 0 begins and ends like this:
0:{"P":null,"c":["","teas","mint"],"q":"","i":false,"t":{"t":{"s":"",…
…"S":true,"r":"$1c","s":"$1d","a":"$@1e","l":"$undefined","p":"$undefined","d":"$undefined","b":"NQzsD1K0uY4DBy6wTLdvG","u":"$@1f"}Line 1: c is ["","teas","mint"]. Line 2 holds keys the type above doesn't list, from later in the same file: s, the stale time, is "$1d", the stream whose value 300 came on lines 29 and 30 of the payload. Their values are pointers, so the root can be written before the things it points to are ready.
And the # before row 1? It's one byte Next.js puts in front of a prerendered payload when partial prefetching is on, from app-render.tsx, lines 1750 to 1761:
/**
* Prepends a single ASCII byte to the chunks indicating whether the response
* is partial (contains dynamic holes): '~' (0x7e) for partial, '#' (0x23)
* for complete.
*/
function prependIsPartialByteToChunks(
chunks: Uint8Array[],
isPartial: boolean
) {
const markerByte = isPartial ? 0x7e : 0x23
return [new Uint8Array([markerByte]), ...chunks]
}Line 10: ~ for a payload with holes, # for a complete one. The router reads and removes that byte before React sees the rows (fetch-server-response.ts, lines 376 to 398). It's Next.js 16.4.3's, not Flight's: the payload inside the HTML, and payloads rendered for a request, have no such byte.
Row ids, tags, the $ strings and their order are React 19.3.0-canary-278794d7-20261002's Flight format, and the keys of row 0 and the # byte are Next.js 16.4.3's. Module numbers, chunk names and ids are this recorded build's. Any of them can change in a release; read them to understand and debug, never from your app's code.
text/x-component and the router's headers
Who asks for a payload with rsc: 1? Next.js's client router, when it prefetches a link or navigates. These steps were taken in real Chrome against the same running shop. Open the Steps tab:
app/teas/mint/page.tsximport { Suspense } from "react";import AddToCart from "./add-to-cart";import Reviews from "./reviews";import { getReviews } from "./get-reviews";import { story } from "./story"; export default function MintPage() { return ( <main> <h1>Mint tea</h1> <p>{story}</p> <AddToCart tea="Mint tea" price={4} packed={new Date("2026-04-20")} note={undefined} /> <Suspense fallback={<p>Loading reviews…</p>}> <Reviews reviews={getReviews()} /> </Suspense> </main> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Loose-leaf tea, shipped fresh.
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Requests the page made during this step (6), without the files it loaded
GET/200a whole pageGET/200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /
GET/teas/mint200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /
GET/teas/today200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /
GET/teas/mint200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /teas/mint/__PAGE__
- next-url
- /
GET/teas/today200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /teas/today/__PAGE__
- next-url
- /
- Open /. One page load. The page's own content came inside the HTML, so nothing asks for it. The three links in the layout's
<nav>are prefetched at once: three requests withnext-router-segment-prefetch: /_tree, one per link, then the page segments of/teas/mintand/teas/today, like/teas/mint/__PAGE__. - Click Mint tea. No request at all. The page is prerendered and has no hole, so its prefetch had everything.
- Click Tea of the day. One request, with
next-router-state-treeandnext-url: /teas/mint, and no prefetch header: the page's hole needs a request. - Open /teas/mint, then click Add to cart. A full page load this time, then a click that sends nothing: the count goes up because
AddToCartruns in the browser.
The header names are constants in app-router-headers.ts:
export const RSC_HEADER = 'rsc' as const
export const ACTION_HEADER = 'next-action' as const
// …
export const NEXT_ROUTER_STATE_TREE_HEADER = 'next-router-state-tree' as const
export const NEXT_ROUTER_PREFETCH_HEADER = 'next-router-prefetch' as const
// …
export const NEXT_ROUTER_SEGMENT_PREFETCH_HEADER =
'next-router-segment-prefetch' as const
export const NEXT_HMR_REFRESH_HEADER = 'next-hmr-refresh' as const
export const NEXT_URL = 'next-url' as const
export const RSC_CONTENT_TYPE_HEADER = 'text/x-component' as const
// …
export const FLIGHT_HEADERS = [
RSC_HEADER,
NEXT_ROUTER_STATE_TREE_HEADER,
NEXT_ROUTER_PREFETCH_HEADER,
NEXT_HMR_REFRESH_HEADER,
NEXT_ROUTER_SEGMENT_PREFETCH_HEADER,
] as const
export const NEXT_RSC_UNION_QUERY = '_rsc' as constLine 1 is rsc, and line 11 is the answer's type, text/x-component. Lines 13 to 19 list the headers that change the answer; the server names them in the response's Vary header, and the recorded answers have Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch. Line 21 is the _rsc parameter. A navigation sets the first headers in fetch-server-response.ts, lines 152 to 168:
const headers: RequestHeaders = {
// Enable flight response
[RSC_HEADER]: '1',
// Provide the current router state
[NEXT_ROUTER_STATE_TREE_HEADER]: prepareFlightRouterStateForRequest(
flightRouterState,
options.isHmrRefresh
),
}
if (process.env.NODE_ENV === 'development' && options.isHmrRefresh) {
headers[NEXT_HMR_REFRESH_HEADER] = '1'
}
if (nextUrl) {
headers[NEXT_URL] = nextUrl
}Line 3 asks for the payload. Lines 5 to 8 encode the router's state tree, the list of segments it needs. Lines 15 to 17 add next-url, the page the request comes from, which an intercepting route needs. The recorded click sent this tree, URL-encoded; decoded, it reads:
["",{"children":["teas",{"children":["today",{"children":["__PAGE__",{},null,"refetch",49314]},null,null,49250]},null,null,49250]},null,null,49234]Each segment is a list: its name, its children, then more fields. The root "" holds teas, which holds today, which holds __PAGE__, marked "refetch": render from here. The numbers are the segments' prefetch hints. The answer follows that mark: the root, teas and today come back with "r":null, no rendered content, and only the page has data. How the router builds and keeps this tree is for The client router, in the next module.
| The header | Sent with | What it tells the server |
|---|---|---|
rsc: 1 | Every request of the router. | Answer with the RSC payload (text/x-component), not HTML. |
next-router-prefetch: 1 | A prefetch, before any click. | This is a prefetch: answer from what the build saved, and don't render request-time data. |
next-router-segment-prefetch | A prefetch of one part of the route. | Which part: /_tree for the route's segments, or a segment's path like /teas/mint/__PAGE__. |
next-router-state-tree | A navigation. | The route's segments, encoded, marking where rendering should start (refetch). |
next-url | Prefetches and navigations from a page. | The page the request comes from, for routes that can be intercepted. |
_rsc (in the address) | Every request with the headers above. | A hash of those headers, so a cache that ignores headers keeps each answer apart. |
What _rsc is
Every one of these requests has ?_rsc= and a short code in its address. It's a hash of the four router headers, from cache-busting-search-param.ts, lines 24 to 58:
function createCacheBustingSearchParamInput(
// …
return [
prefetchHeader ?? '0',
normalizeCacheBustingInput(segmentPrefetchHeader),
normalizeCacheBustingInput(stateTreeHeader),
normalizeCacheBustingInput(nextUrlHeader),
].join(',')
}
// …
async function computeCacheBustingSearchParamFromInput(
input: string
): Promise<string> {
// Truncate SHA-256 to 96 bits to keep `_rsc` compact
const digest = await globalThis.crypto.subtle.digest(
'SHA-256',
textEncoder.encode(input)
)
return encodeCacheBustingSearchParam(
new Uint8Array(digest).subarray(0, CACHE_BUSTING_SEARCH_PARAM_DIGEST_BYTES)
)
}Lines 3 to 8 join the prefetch header, the segment header, the state tree and next-url with commas, with 0 for any that's missing. Lines 15 to 21 hash that text with SHA-256 and keep the first 12 bytes, written in base64 with - and _ (base64url). For the /_tree prefetches, the text is 1,/_tree,0,/, so all three, for three different pages, had the same _rsc=5CB68i4pnAekjehf. Why have it at all? A shared cache, like a CDN, may store answers by their address alone and ignore Vary. Then the HTML and the payload of /teas/mint could be mixed up. With the hash in the address, each set of headers has its own address. The server checks it, in base-server.ts, lines 2500 to 2543:
const expectedHash = await computeCacheBustingSearchParam(
routerPrefetch,
segmentPrefetchRSCRequest,
headers[NEXT_ROUTER_STATE_TREE_HEADER],
headers[NEXT_URL]
)
const actualHash =
getRequestMeta(req, 'cacheBustingSearchParam') ??
new URL(req.url || '', 'http://localhost').searchParams.get(
NEXT_RSC_UNION_QUERY
)
let matchesHash = expectedHash === actualHash
// …
if (!matchesHash) {
// …
setCacheBustingSearchParamWithHash(url, expectedHash)
res.statusCode = 307
res.setHeader('location', `${url.pathname}${url.search}`)
res.body('').send()
return null
}Lines 1 to 6 compute the hash the headers should have, lines 7 to 11 read the one in the address, and lines 15 to 22 answer 307 to the right address when they differ. The recorder tried it: the /_tree prefetch headers with _rsc=wrong got 307 and location: /teas/mint?_rsc=5CB68i4pnAekjehf.
/teas/mint every time; the headers choose the answer. The _rsc value in each address is a hash of those headers, so a cache that only looks at addresses keeps the four answers apart.Pick each request below, and compare what was sent with what came back:
GET /teas/mintWhat came back
(self.__next_f=self.__next_f||[]).push([0])
self.__next_f.push([1,"1:\"$Sreact.fragment\"\n2:\"/_next/static/chunks/3bif7kg6ax7bg.…"])
self.__next_f.push([1,"0:{\"P\":null,\"c\":[\"\",\"teas\",\"mint\"],\"q\":\"\",\"i\":f…"])
self.__next_f.push([1,"8:C\n1d:300\n1d:300\n10:[\"Fresh and bright.\",\"My favorite af…"])No router headers: the server answers with HTML, the payload inside it in self.__next_f scripts. This page was prerendered, so it's the built file teas/mint.html.
The /_tree prefetch is the smallest answer: only the segments and their hints, so the router knows the route's shape. Here it is whole:
:HL["/_next/static/chunks/0pq7am_isphfw.css","style"]
3:[["children",{"s":"__PAGE__","h":49314}]]
2:[["children",{"s":"mint","h":49250,"c":"$Q3"}]]
1:[["children",{"s":"teas","h":49250,"c":"$Q2"}]]
0:{"b":"NQzsD1K0uY4DBy6wTLdvG","t":{"t":{"s":"","h":49234,"c":"$Q1"}}}
Line 1 is the stylesheet's hint. Lines 2 to 4 are the segments __PAGE__, mint and teas, and line 5 the root; each one above the page points to its children with $Q, a Map. These prefetch answers were written during the build, as files in .next/server/app/teas/mint.segments/, which the Terminal tab lists. Which answer the router keeps, and for how long, is for The client router; Linking and navigating and Instant navigations show prefetching from the app's side.
The payload inside the HTML: self.__next_f
On a first visit there's no router yet, so no rsc: 1 request. The payload comes inside the HTML. The recorder kept the HTML of /teas/mint exactly as it arrived; the recording shows it's the file the build wrote, .next/server/app/teas/mint.html, whole. These are its scripts that touch self.__next_f, each cut short:
(self.__next_f=self.__next_f||[]).push([0])
self.__next_f.push([1,"1:\"$Sreact.fragment\"\n2:\"/_next/static/chunks/3bif7kg6…"])
self.__next_f.push([1,"0:{\"P\":null,\"c\":[\"\",\"teas\",\"mint\"],\"q\":\"\",\…"])
self.__next_f.push([1,"8:C\n1d:300\n1d:300\n10:[\"Fresh and bright.\",\"My favor…"])
self.__next_f.push([1,"16:[[\"$\",\"meta\",\"0\",{\"charSet\":\"utf-8\"}],[\"$\"…"])
self.__next_f.push([1,"12:null\n19:[]\n"])
self.__next_f.push([1,"1d:C\n1a:C\n1c:C\n13:C\n6:C\n"])
self.__next_f.push([1,"1f:false\n1e:null\n"])Line 1 makes the list: self is the browser's window, and push([0]) marks the start. Lines 2 to 8 each push [1, text]: a piece of the payload, as a JSON string. Join the seven texts and you get, byte for byte, the rsc: 1 answer without its first #: the same rows, cut wherever the server's stream happened to send a chunk. A piece can end in the middle of a row, so the rows are only read once the pieces are joined. app-render showed the function that writes these scripts, createNodeInlinedDataStream. Two details from it: the JSON is escaped for HTML, so a </script> in your data can't end the script early, and a piece that isn't valid UTF-8 text is sent as base64, as [3, …] (stream-ops.node.ts, lines 1012 to 1028).
The visible markup is the page the reader sees: the <h1>, the story, the list of reviews. The HTML response carries more: the payload, with every rendered value and every Client Component's props. In the recorded response, Fresh and bright. is there 2 times: once in the <li>, and once in a payload script. So is the story. What stays on the server is the components' code, the code that loaded the data, and values that were neither rendered nor passed as props.
Streaming the HTML and the payload together
/teas/today is ◐: a static shell from the build, and a hole rendered for each request. Partial Prerendering showed it from the outside, and Streaming with Suspense showed a hole arrive in a hidden <div>. The recorder kept the whole response. Laid out as the parts it arrived in, in order:
1517 bytes of .next/server/app/teas/today.html: the shell
<script>(self.__next_f=self.__next_f||[]).push([0])</script>
<script>self.__next_f.push([1,"…"])</script> 37 rows: 1, 2, 3 … c, 1b, 1a
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" noModule=""></script>
<div hidden id="S:0"><p>Tea of the day: Green tea</p></div>
<script>$RB=[];$RV=function(a){…};$RC("B:0","S:0")</script>
<script>self.__next_f.push([1,"…"])</script> 9 rows: 1c, 1c, 1c … 1c, 1c, 1c
</body></html>- Line 1: the first 1517 bytes are
.next/server/app/teas/today.html, byte for byte: the heading andChoosing today's tea…, made bynext build. The shell has no payload scripts at all. - Line 2 starts
self.__next_f, as on/teas/mint. - Line 3 is one script with this request's whole payload, rendered now: the layouts, the page, and, near the end, row
c,["$","p",null,{"children":"Tea of the day: Green tea"}], the hole's content, which the page's row pointed to with"$Lc". - Lines 5 and 6: the hole's HTML, in a hidden
<div id="S:0">, and React's inline script$RC("B:0","S:0"), which moves it into the place of the fallback, markedB:0. - Line 7: one more payload script. Its rows all have the id
1c: a stream (R) whose bytes arrive inorows. They carry a second, smaller payload: the page as a prefetch would see it, which the router can keep (the root row'sppoints to it). - Line 8: Next.js holds the closing tags back until the very end, so everything streamed lands inside the body.
next build; everything under it was rendered for this request, HTML and payload scripts in the same stream. Next.js holds back </body></html> until the end.Who decides that order? React renders the HTML from the payload, so the two streams are made side by side, and Next.js merges them into one response. The payload's scripts are slipped in between HTML chunks, from stream-ops.node.ts, lines 150 to 175:
if (delayDataUntilFirstHtmlChunk) {
// Buffer the inlined data stream until we've left the current Task so
// it's inserted after flushing the shell.
await atLeastOneTask()
}
try {
const iterator = dataStream[Symbol.asyncIterator]()
while (true) {
const { done, value } = await iterator.next()
if (done) {
donePulling = true
return
}
// Prioritize HTML over RSC data: the SSR render produces HTML from
// the same RSC stream, so yield a task to let HTML flush first.
if (!delayDataUntilFirstHtmlChunk && !htmlStreamFinished) {
await atLeastOneTask()
}
target.push(value)
}
} catch (err) {
target.destroy(err as Error)
}
}- Lines 1 to 5: when resuming a shell, wait one task first, so the payload goes after the shell already sent (
delayDataUntilFirstHtmlChunk). - Lines 8 to 14: read the payload's scripts, one chunk at a time, until the stream ends.
- Lines 16 to 20: otherwise, wait one task before each chunk, so HTML that's ready goes first, as the comment says.
- Line 21: put the payload chunk into the response, between HTML chunks.
So the order of the payload's scripts and the hole's HTML depends on what's ready first. In this recording, the hole resolved at once, and its row came in the same script as the rest of the payload, before the hole's HTML. A slower hole would send the shell's rows first and its own row later, beside its HTML. What doesn't change: the shell first, the bootstrap script before any payload, each hole's HTML before its $RC, and the closing tags last.
Hydrating from the payload
In the browser, the inline scripts run as the HTML arrives, usually before Next.js's own JavaScript has loaded, so the pieces wait in the array. When the client code arrives, app-index.tsx takes them, from app-index.tsx, lines 176 to 190:
const nextServerDataLoadingGlobal = (self.__next_f = self.__next_f || [])
// Consume all buffered chunks and clear the global data array right after to release memory.
// Otherwise it will be retained indefinitely.
nextServerDataLoadingGlobal.forEach(nextServerDataCallback)
nextServerDataLoadingGlobal.length = 0
// Patch its push method so subsequent chunks are handled (but not actually pushed to the array).
nextServerDataLoadingGlobal.push = nextServerDataCallback
let readable: ReadableStream<Uint8Array> = new ReadableStream({
start(controller) {
nextServerDataRegisterWriter(controller)
},
})- Line 1 uses the array the scripts made, or makes it if no script ran yet.
- Lines 5 and 6 hand every piece already in it to
nextServerDataCallback, then empty the array. - Line 9 replaces the array's
push. From now on, a script that streams in later callsnextServerDataCallbackdirectly, and nothing is stored in the array. - Lines 11 to 15 make the
ReadableStreamthe pieces flow into.
nextServerDataCallback reads the first number of each push, in app-index.tsx, lines 81 to 112:
function nextServerDataCallback(seg: FlightSegment): void {
if (seg[0] === 0) {
initialServerDataBuffer = []
} else if (seg[0] === 1) {
if (!initialServerDataBuffer)
throw new Error('Unexpected server data: missing bootstrap script.')
if (initialServerDataWriter) {
initialServerDataWriter.enqueue(encoder.encode(seg[1]))
} else {
initialServerDataBuffer.push(seg[1])
}
} else if (seg[0] === 2) {
initialFormStateData = seg[1]
} else if (seg[0] === 3) {
// …
}
}Line 2: 0 starts the buffer. Lines 4 to 12: 1 is a piece of text, put into the stream if it's open (line 9), or kept until it is (line 11). Line 13: 2 is a form's state, for a Server Action posted before hydration. Line 15: 3 is a piece in base64. Then React reads the stream, from app-index.tsx, lines 269 to 423:
initialServerResponse = createFromReadableStream<InitialRSCPayload>(
readable,
{
callServer,
findSourceMapURL,
debugChannel,
startTime: 0,
}
)
// …
} else {
React.startTransition(() => {
ReactDOMClient.hydrateRoot(appElement, reactEl, {
...reactRootOptions,
formState: initialFormStateData,
})
})Lines 1 to 9: createFromReadableStream, React's Flight client, reads the rows and returns the root row's value. Lines 12 to 17: hydrateRoot renders the router with it, inside a transition. Hydration is React matching its tree with the HTML already on the screen, reusing the DOM, and attaching event handlers. Only Client Components hydrate: AddToCart's module loads, its chunks named by its import row, and its button starts working. Server Components never hydrate; their code isn't in the browser, and what they rendered is already in the payload.
push is nextServerDataCallback. Either way the rows reach one stream, which React decodes and hydrates from.The Steps tab above shows the parts you can see from outside. Opening /teas/mint directly, a full page load, made no request for its payload: it was inline. Then clicking Add to cart raised the count to 1 with no request: the button on the server's HTML had hydrated. Hydration internals, in the React course, follows what hydrateRoot does inside React.
Reading payloads in your own app
In a Next.js 16.4.3 app built with next build and run with next start, you can find all of this in Chrome's DevTools. Read it to learn and to debug; never write app code that depends on it.
- The payload of a navigation: Network panel, filter by
_rsc, pick a request and open Response. Its Headers showrsc: 1and the other router headers. - The payload of the first visit: open the page's source and search for
self.__next_f. Join the[1, …]strings to get the rows. - A stuck page: if a
$Lor$@row never arrives, the part of the page waiting for it stays on its fallback. Search the payload for the id after$L. - A production error: the browser has only the digest from the
Erow; search the server's log for it.
Sort each recorded row by the kind of row it is:
d:I[31094,["$2","$c"],"default"]3:I[22016,["$2"],""]:HL["/_next/static/chunks/0pq7am_isphfw.css","style"]b:T42b,Our mint tea starts in a small garden…f:E{"digest":"154446600"}10:["Fresh and bright.","My favorite after dinner."]1:"$Sreact.fragment"
Look at the character right after the colon: a tag letter, or the start of JSON.
Common mix-ups
- "The RSC payload is HTML." No. It's React's rows of data,
text/x-component. The HTML is made from it, on the server. - "Client Components' code is in the payload." No. An import row names the module and its chunks; the browser loads the code from
/_next/static. - "Data a Server Component renders stays on the server." No. What it renders, and every prop it passes to a Client Component, is in the payload, and so in the HTML response. Only its code and the values it doesn't render or pass stay.
- "_rsc stops the browser from caching." No. It's a hash of the router headers, the same for the same headers, so a cache that keys on the address can keep each answer.
- "The browser fetches the payload after loading the page." No. On a first visit it's inline, in
self.__next_fscripts; only the router's navigations and prefetches fetch it. - "Server Components hydrate from the payload." No. Only Client Components hydrate. The payload gives React the whole tree, with Server Components' output already in it.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints. Some ask you to find a line in the source, and the excerpts above hold it.
This is part of the page's row. Which row holds the value of the reviews promise?
a:…],"children":["$","$Lf",null,{"reviews":"$@10"}]}]]}],[["$"…Row 10 (16 in decimal): 10:["Fresh and bright.","My favorite after dinner."], on line 31 of the payload. The client resolves the promise when that row arrives, and use(reviews) reads the list.
How many bytes of text does this row carry?
b:T42b,Our mint tea starts in a small garden in the hills, …0x42b is 1,067 bytes. The story is plain ASCII text, so that's also 1,067 characters, over the 1,024 string units that send a string to its own row.
Chrome prefetched Tea of the day's page segment with these headers. What text does the router hash to make its _rsc?
rsc: 1
next-router-prefetch: 1
next-router-segment-prefetch: /teas/today/__PAGE__
next-url: /1,/teas/today/__PAGE__,0,/. It has no state tree, so that part is 0. Its SHA-256, cut to 12 bytes in base64url, is 0aoqpUHLdnvTaaP6: the _rsc real Chrome sent with this prefetch.
A hole's payload script arrives after Next.js's client code has run. Which function does its self.__next_f.push(…) call?
const nextServerDataLoadingGlobal = (self.__next_f = self.__next_f || [])
// Consume all buffered chunks and clear the global data array right after to release memory.
// Otherwise it will be retained indefinitely.
nextServerDataLoadingGlobal.forEach(nextServerDataCallback)
nextServerDataLoadingGlobal.length = 0
// Patch its push method so subsequent chunks are handled (but not actually pushed to the array).
nextServerDataLoadingGlobal.push = nextServerDataCallback
let readable: ReadableStream<Uint8Array> = new ReadableStream({
start(controller) {
nextServerDataRegisterWriter(controller)
},
})nextServerDataCallback. Line 9 makes it the array's push, so a streamed script's piece goes straight into the ReadableStream React reads (or the buffer, if the stream isn't open yet).
/teas/oolong's stock throws an error with a message. In a production build, what does its E row carry to the browser?
} else {
errorInfo = {digest};
}
const row = serializeRowHeader('E', id) + stringify(errorInfo) + '\n';Only digest: the recorded row is f:E{"digest":"154446600"}. The message The supplier didn't answer is only in the server's log, next to the same digest.
The click on Tea of the day sent this state tree. Which segment comes back with rendered content?
["",{"children":["teas",{"children":["today",{"children":["__PAGE__",{},null,"refetch",49314]},null,null,49250]},null,null,49250]},null,null,49234]__PAGE__. It's marked "refetch", so the server renders the page. The recorded answer has "r":null for the root, teas and today, and the page's row with its content.
Check your understanding
For each question, ask what the row or the request says, and who reads it: React's Flight client, or Next.js's router.
Question 1 of 7What does the row
d:I[31094,["$2","$c"],"default"]tell the browser?Choose an answer to see the explanation.
Question 2 of 7Why does
:HL["/_next/static/chunks/0pq7am_isphfw.css","style"]have nothing before its colon?Choose an answer to see the explanation.
Question 3 of 7A Server Component passes a 2,000-character description to a
<p>. How does it travel in the payload?Choose an answer to see the explanation.
Question 4 of 7What is the
_rscvalue in a router request's address?Choose an answer to see the explanation.
Question 5 of 7Besides the visible list on
/teas/mint, where else isFresh and bright.in the HTML response?Choose an answer to see the explanation.
Question 6 of 7In the recorded response for
/teas/today, what comes first?Choose an answer to see the explanation.
Question 7 of 7After Next.js's client code runs, what happens when a later script calls
self.__next_f.push([1, rows])?Choose an answer to see the explanation.
Key takeaways
- A Flight row is an id in hexadecimal, a colon, an optional tag, and data.
Iis a Client Component's module,Ha hint with no id,Tlong text with its byte length,Ean error, and no tag means JSON. - Strings that start with
$point to rows ($,$L,$@) or stand for values JSON can't hold ($S,$D,$Q,$undefined). - The router asks with
rsc: 1, plus prefetch, segment and state tree headers;_rscis a hash of them, so each answer has its own address. - On a first visit, the payload is inline:
self.__next_f.push([1, …])scripts, streamed between the HTML's chunks. app-index.tsxreplaces that array'spush, feeds a stream tocreateFromReadableStream, andhydrateRoothydrates the Client Components.
The RSC payload is rows of data the router fetches, or the HTML carries in scripts.
React writes and reads the rows; Next.js chooses what goes in them and how they travel.
Coming next: Prerendering and Partial Prerendering. It reads how next build notices request-time code, stops there, saves the shell, and resumes the rest on request.