How a route becomes a React tree
Follow a request through Next.js 16's app-render: the loader tree, the layouts and boundaries it nests, metadata, then the RSC payload and the HTML.
- 01Follow app-render's entrySay what renderToHTMLOrFlight receives, which of its answers a request gets, and what it returns.
- 02Place every special fileRead a route's loader tree and its recorded payload, and tell where layout, template, error, loading, not-found and page end up.
- 03Trace metadata and the two rendersExplain the order metadata merges in, and how one render to the RSC payload feeds both the HTML and the browser.
From /teas/mint to the HTML
A request for /teas/mint arrives. The server has matched it to a route, and now that route has to become a page: your layouts, templates, loading and error files and the page, put together in the right order, rendered by React, and sent as HTML. In Next.js 16.4.3 one folder of code does that job: packages/next/src/server/app-render, and its main file, app-render.tsx.
The app is the tea shop's tea pages. The folder app/teas has every special file a folder can have, and two pages: /teas/mint, made during the build, and /teas/today, which calls connection() and so is made for each request. This is a real Next.js 16.4.3 project, built with next build and run with next start. Click the files to read them:
app/teas/layout.tsximport type { Metadata } from "next"; export const metadata: Metadata = { description: "Every tea we sell, with brewing notes.",}; export default function TeasLayout({ children }: LayoutProps<"/teas">) { return ( <section> <h2>Tea menu</h2> {children} </section> );}Brewing tip: use water just below boiling.
Cool and fresh.
The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.
app/teas/layout.tsx, shown first, wraps every tea page in a <section> with a Tea menu heading (lines 9 to 12). Beside it, template.tsx adds a brewing tip, loading.tsx says Loading the tea…, error.tsx and not-found.tsx each show one line, and mint/page.tsx is the page. In next.config.ts, line 8 turns off Turbopack's minifier, so the built files can be read. Open the Network tab: the HTML of /teas/mint nests the page inside the template's <div>, inside the layout's <section>.
A few words first. A segment is one part of the address, and one folder of app: /teas/mint has the root segment, teas and mint. The RSC payload is the text React writes when it renders Server Components: what they rendered, and references to the Client Components, as a list of numbered lines called rows. Flight is React's name for that format and the code that writes and reads it. The browser's React reads the payload to hydrate the Client Components; Server Components never hydrate, because their code never reaches the browser. 'use client' and 'use server' under the hood showed how a client reference gets into it. Here is the whole journey, in one picture:
/teas/mint. The tree is rendered once, to the RSC payload. The HTML is made from that payload, and the payload itself rides along in <script> tags, for the browser's React.A furniture shop gets an order, picks the parts, and writes the assembly instructions. It builds the shelf from those instructions, so it arrives ready to use, and tapes the instructions inside the box. At home, you can see the shelf at once, and you still have the instructions, to change it later.
- In real life: The order: which parts this bookshelf needs
- In Next.js: The loader tree: which files this route needs
- In real life: The factory putting the parts in the right order
- In Next.js: create-component-tree and LayoutRouter nesting the files
- In real life: The assembly instructions
- In Next.js: The RSC payload
- In real life: The shelf, built from the instructions and shipped ready
- In Next.js: The HTML, rendered from the payload
- In real life: The instructions taped inside the box
- In Next.js: The payload inlined in the HTML, for the browser's React
app-render turns a route's loader tree into one React tree, renders it once to the RSC payload, then makes the HTML from that payload and puts the payload inside the HTML. The server only places your special files, as props and providers; LayoutRouter, a Client Component, nests them when the tree renders.
app-render.tsx: what goes in and what comes out
app-render doesn't look at your app folder. During the build, the bundler writes one entry module for each page, from a template, build/templates/app-page.ts:
declare const tree: LoaderTree
declare const __next_app_require__: (id: string | number) => unknown
declare const __next_app_load_chunk__: (id: string | number) => Promise<unknown>
// INJECT:tree
// INJECT:__next_app_require__
// INJECT:__next_app_load_chunk__
const entrypoint = createAppPageEntrypoint({
tree,
page: 'VAR_DEFINITION_PAGE',
pathname: 'VAR_DEFINITION_PATHNAME',
require: __next_app_require__,
loadChunk: __next_app_load_chunk__,
interopDefault,
})Line 1 says the template uses a variable called tree without defining it, and line 6 marks where the build puts it in. Lines 10 to 17 hand it, with the page's name and address, to createAppPageEntrypoint, which makes the route's route module: the object the server calls to render it. The recording kept the entry the build wrote for /teas/mint. This is its tree, the route's loader tree:
const tree = [
"",
{
"children": [
"teas",
{
"children": [
"mint",
{
"children": [
"__PAGE__",
{},
{
metadata: {},
"page": [
__TURBOPACK__page__$23$10__,
"[project]/app/teas/mint/page.tsx"
]
},
[]
]
},
{
metadata: {}
},
[]
]
},
{
metadata: {},
"layout": [
__TURBOPACK__layout__$23$5__,
"[project]/app/teas/layout.tsx"
],
"error": [
__TURBOPACK__error__$23$6__,
"[project]/app/teas/error.tsx"
],
"loading": [
__TURBOPACK__loading__$23$7__,
"[project]/app/teas/loading.tsx"
],
"template": [
__TURBOPACK__template__$23$8__,
"[project]/app/teas/template.tsx"
],
"not-found": [
__TURBOPACK__not$2d$found__$23$9__,
"[project]/app/teas/not-found.tsx"
]
},
[]
]
},
{
"layout": [
__TURBOPACK__layout__$23$0__,
"[project]/app/layout.tsx"
],
"not-found": [
__TURBOPACK__not$2d$found__$23$1__,
"[project]/node_modules/next/dist/client/components/builtin/not-found.js"
],
"forbidden": [
__TURBOPACK__forbidden__$23$2__,
"[project]/node_modules/next/dist/client/components/builtin/forbidden.js"
],
"unauthorized": [
__TURBOPACK__unauthorized__$23$3__,
"[project]/node_modules/next/dist/client/components/builtin/unauthorized.js"
],
"global-error": [
__TURBOPACK__global$2d$error__$23$4__,
"[project]/node_modules/next/dist/client/components/builtin/global-error.js"
]
},
[]
];Each segment is a list of four things: its name, its children, its files, and, for a dynamic segment, the static folders beside it (empty here; app-dir-module.ts, lines 7 to 28).
- Line 2 is the root segment's name,
"". Its files are on lines 55 to 76. - Lines 4 and 5: its
childrenslot holds the segmentteas, whose files are on lines 29 to 51. - Lines 7 and 8: inside it,
mint. Its files, on lines 23 to 25, are only an emptymetadata: the folder has no layout. - Lines 10 and 11: the page is a segment of its own,
__PAGE__, which Next.js adds in the loader tree (it isn't part of the address), withpage.tsxon lines 15 to 18. - Each file is a pair: a function that loads the module when it's needed, and the file's path. Lines 31 to 50 list all five of the tea folder's files.
- Lines 60 to 75: the root has no
not-found.tsx,forbidden.tsx,unauthorized.tsxorglobal-error.tsxof yours, so the build put in Next.js's built-in ones. metadata: {}on lines 14, 24 and 30 is for metadata files likeicon.png. Themetadataobject you export is read from each module later.
Not every request reaches app-render. /teas/mint was made during the build, so next start sent the saved file, mint.html, whole. When Next.js must render a page at request time, the route module's render calls app-render's entry, renderToHTMLOrFlight (route-modules/app-page/module.ts, lines 174 to 195). During next build, its prerender calls prerenderToHTMLOrFlight instead, which works the same way and can stop early; Prerendering and Partial Prerendering, later in this stage, reads that one. The entry, shortened from app-render.tsx, lines 3401 to 3434:
export const renderToHTMLOrFlight: AppPageRender = (
req,
res,
pagePath,
query,
fallbackRouteParams,
renderOpts,
dev,
sharedContext,
routeMatch,
parsedRequestHeaders
) => {
// …
return workAsyncStorage.run(
workStore,
renderToHTMLOrFlightImpl,Lines 2 to 11 are what it receives: the request and the response, the page's name (/teas/mint/page), the query, the route params a prerender had to leave unknown, renderOpts, which holds the route's compiled module with its loader tree, and the request's parsed router headers. Lines 14 to 16 run the rest inside workAsyncStorage, a store that any code in the render can read to learn which route it's rendering. The rest is renderAppPage, which decides what to answer, shortened from lines 3015 to 3193:
if (isRSCRequest) {
// …
// MARK: RSC dynamic
const result = await generateDynamicFlightRenderResult(
ctx,
requestStore
)
// …
if (isPossibleActionRequest) {
// For action requests, we handle them differently with a special render result.
const actionRequestResult = await handleAction({
// …
const stream = await renderToStream(
// NOTE: in Cache Components (dev), if the render is restarted, it will use a different requestStore
// than the one that we're passing in here.
requestStore,
res,
ctx,
loaderTree,
// …
const result = new RenderResult(stream, options)Line 1: a request from the router, with rsc: 1, gets the payload alone; lines 4 to 7 are one of the functions that render it (there are others for prefetches and for next dev). Lines 9 to 11: a Server Action's request runs the action first. Everything else reaches line 13, renderToStream, with the loader tree on line 19, and line 21 wraps the stream it returns in a RenderResult. That's what renderToHTMLOrFlight returns: a stream of HTML that the server pipes into the response as it's made.
| The request | What renderAppPage does | What the response holds |
|---|---|---|
A router request, with the rsc: 1 header | Renders the RSC payload alone. | A text/x-component response: the payload's rows, no HTML. |
A Server Action's POST | Runs the action with handleAction first. | With JavaScript, an RSC response with the result and the new payload. A form posted without JavaScript gets HTML, or a 303 redirect. |
| Any other page request | Renders the payload, then HTML from it, with renderToStream. | HTML, with the payload inlined in <script> tags. |
create-component-tree: one segment at a time
renderToStream first builds the payload's tree with createFullComponentTree, in create-component-tree.tsx. It walks the loader tree from the root down, one segment at a time, and for each one, createComponentTreeInternal starts by reading the segment's files, from lines 193 to 226:
const {
layout,
template,
error,
loading,
'not-found': notFound,
forbidden,
unauthorized,
} = modules
// …
const [Template, templateStyles, templateScripts] = template
? await createComponentStylesAndScripts({
ctx,
filePath: template[1],
getComponent: template[0],
injectedCSS: injectedCSSWithCurrentLayout,
injectedJS: injectedJSWithCurrentLayout,
})
: [Fragment]Lines 1 to 9 take each kind of file from the segment's modules, the third item of its list in the loader tree. Lines 11 to 19 load the template's module with its CSS and scripts, or, when the folder has none, use React's Fragment, which renders only its children. The error, loading and not-found files are loaded the same way. Then the function calls itself for each child segment, and wraps each child's result in an element, from lines 696 to 718:
return [
parallelRouteKey,
createElement(LayoutRouter, {
parallelRouterKey: parallelRouteKey,
error: ErrorComponent,
errorStyles: wrappedErrorStyles,
errorScripts: errorScripts,
template:
// …
: templateNode,
templateStyles: templateStyles,
templateScripts: templateScripts,
notFound: notFoundComponent,
forbidden: forbiddenComponent,
unauthorized: unauthorizedComponent,Line 3 makes a LayoutRouter element for the slot, usually children. Its props are this folder's files: error on line 5, template on line 8, notFound on line 13, and the two access errors on lines 14 and 15. LayoutRouter is a Client Component from client/components/layout-router.tsx, so the server doesn't render it: in the RSC layer it's a client reference, and the payload gets an import row for it. Then the folder's own layout gets the slots as props, from lines 1068 to 1077:
serverSegment = createElement(
SegmentComponent,
{
...parallelRouteProps,
params: params,
},
// Force static children here so that they're validated.
// See https://github.com/facebook/react/pull/34846
parallelRouteProps.children
)Line 2 is your layout component. Line 4 passes each slot as a prop, and line 9 passes children again as the element's children. So the children your TeasLayout receives is that LayoutRouter element, not the page. Last, the folder's loading.tsx goes around the whole thing, in lines 1300 to 1310:
if (loading !== null) {
// If a loading.tsx boundary is present, wrap the component data in an
// additional context provider to pass the loading data to the next
// set of children.
// NOTE: The reason this is a separate wrapper from LayoutRouter is because
// not all segments render a LayoutRouter component, e.g. the root segment.
const LoadingBoundaryProvider = ctx.componentMod.LoadingBoundaryProvider
rsc = createElement(LoadingBoundaryProvider, {
loading: loading,
children: rsc,
})Lines 8 to 11 wrap the folder's element in a LoadingBoundaryProvider with the loading data. The comment on lines 5 and 6 says why it's a separate component: not every segment renders a LayoutRouter.
You can see the result in the recording. The recorder asked for /teas/mint's RSC payload. An import row is one row that names a Client Component's module, its chunks and an export. The page's client reference manifest, a file the build writes that maps each Client Component's module to its file and chunks, says which file each module id is. The import rows, with those files:
3:I[39756,["$2"],"default"] node_modules/next/dist/esm/client/components/layout-router.js, default
4:I[37457,["$2"],"default"] node_modules/next/dist/esm/client/components/render-from-template-context.js, default
8:I[39756,["$2"],"$7"] node_modules/next/dist/esm/client/components/layout-router.js, LoadingBoundaryProvider
a:I[75754,["$2","$9"],"default"] app/teas/error.tsx, default
f:I[97367,["$2"],"OutletBoundary"] node_modules/next/dist/esm/lib/framework/boundary-components.js, OutletBoundary
14:I[97367,["$2"],"$13"] node_modules/next/dist/esm/lib/framework/boundary-components.js, ViewportBoundary
17:I[97367,["$2"],"$16"] node_modules/next/dist/esm/lib/framework/boundary-components.js, MetadataBoundary
1a:I[68027,["$2"],"default",1] node_modules/next/dist/client/components/builtin/global-error.js, defaultLine 1 is LayoutRouter, line 2 RenderFromTemplateContext, line 3 LoadingBoundaryProvider, and line 4 your error.tsx: a "use client" file, so it's a reference too. Lines 5 to 7 are the metadata's boundaries, and line 8 Next.js's built-in global error page. The teas segment's row holds the tree for the tea folder. Written as JSX to read, with the props that only carry CSS and scripts left out, it's this:
<LoadingBoundaryProvider
loading={<p>Loading the tea…</p>}
>
<section>
<h2>Tea menu</h2>
<LayoutRouter
parallelRouterKey="children"
error={TeaError}
template={<div><p>Brewing tip: use water just below boiling.</p><RenderFromTemplateContext /></div>}
notFound={<p>No such tea.</p>}
/>
</section>
</LoadingBoundaryProvider>Lines 1 to 3: the LoadingBoundaryProvider with your loading.tsx's element. Lines 4 and 5: your TeasLayout already ran on the server; only its <section> and heading are left. Lines 6 to 11: the LayoutRouter it got as children, with error={TeaError} as a reference, the template as your <div> already rendered around RenderFromTemplateContext, and notFound as your <p>. The mint folder has no files, so its row is a bare LayoutRouter:
<LayoutRouter
parallelRouterKey="children"
template={<RenderFromTemplateContext />}
/>Line 3: with no template.tsx, the template is RenderFromTemplateContext alone, inside the Fragment from line 19 of the first excerpt. The page itself is in a row of its own, the __PAGE__ segment. How the payload writes all this as rows is for The RSC payload, later in this stage.
Where each special file is placed
The server placed the files; LayoutRouter nests them. It runs on the server for the first HTML and in the browser after hydration, and renders, for each slot, from layout-router.tsx, lines 666 to 728:
let templateValue = (
<ScrollHandler renderTree={renderTree}>
<ErrorBoundary
errorComponent={error}
// …
<LoadingBoundary
// …
loading={parentLoadingData}
>
<HTTPAccessFallbackBoundary
// …
>
<RedirectBoundary>
<InnerLayoutRouter
// …
/>
// …
<TemplateContext.Provider key={stateKey} value={templateValue}>
{templateStyles}
{templateScripts}
{template}
</TemplateContext.Provider>- Lines 18 to 22: the outside is the template.
TemplateContextholds everything else, and your template'sRenderFromTemplateContexttakes it from there and renders it where your{children}is. - Lines 3 and 4: inside the template,
ErrorBoundarywith yourerror.tsx. - Lines 6 and 8: inside that,
LoadingBoundary, aSuspensewhose fallback is the loading data theLoadingBoundaryProviderabove passed down. - Line 10: inside that,
HTTPAccessFallbackBoundary, withnot-found.tsx,forbidden.tsxandunauthorized.tsx. - Lines 13 and 14: a boundary for redirects, then
InnerLayoutRouter, which renders the next segment: heremint, then the page.
Templates and the file hierarchy showed this order from the outside. Now you can say why it is what it is: the layout is outside everything, because the LayoutRouter is its child; and error.tsx can't catch an error in its own folder's layout or template, because both are outside its ErrorBoundary.
LayoutRouter, and loading.tsx in a provider around the folder. LayoutRouter, a Client Component, does the nesting, in the order on the right.| File | Where create-component-tree puts it | Who renders it |
|---|---|---|
layout.tsx | The folder's own element, with each slot (like children) passed in as a prop. | React on the server: it's a Server Component. |
page.tsx | A segment of its own, __PAGE__, below its folder. | React on the server, inside the folder's last LayoutRouter. |
template.tsx | The template prop of the LayoutRouter in the folder's layout, around RenderFromTemplateContext. | LayoutRouter, which puts the rest inside it. |
error.tsx | The error prop of that LayoutRouter, as a client reference. | ErrorBoundary, inside the template. |
loading.tsx | A LoadingBoundaryProvider around the folder's own element. | LoadingBoundary, a Suspense inside the error boundary. |
not-found.tsx | The notFound prop of that LayoutRouter. | HTTPAccessFallbackBoundary, inside the loading boundary. |
The recorded HTML shows the nesting too. This is the body of /teas/mint, from the Network tab of the first example:
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<!--&-->
<section>
<h2>Tea menu</h2>
<!--&-->
<div>
<p>Brewing tip: use water just below boiling.</p>
<!--$-->
<!--&-->
<main>
<h1>Mint tea</h1>
<p>Cool and fresh.</p>
</main>
<!--$-->
<!--/$-->
<!--/&-->
<!--/$-->
</div>
<!--/&-->
</section>Lines 7 and 8 are the layout's <section> and heading. Lines 10 and 11 are the template's <div> and tip. Line 12, <!--$-->, is how React marks the start of a Suspense boundary in HTML: the loading boundary, already resolved, because the page was made during the build. The page's <main> is inside it, on lines 14 to 17. (Lines 2 to 5 are the metadata's hidden <div>, below.) /teas/today waits for a request, so its HTML arrives with the fallback in the same place:
<section>
<h2>Tea menu</h2>
<!--&-->
<div>
<p>Brewing tip: use water just below boiling.</p>
<!--$?-->
<template id="B:0"></template>
<p>Loading the tea…</p>
<!--/$-->
</div>
<!--/&-->
</section>Line 8 is loading.tsx's <p>, inside the template's <div> (line 4) and after the tip: the layout and the template are on screen while the page waits. Further down, the same response streams the page in a hidden <div>, which React moves into place:
<div hidden="" id="S:1">
<main>
<h1>Tea of the day: Green tea</h1>
</main>
</div>Streaming explains the B:0 and S:1 markers. Try taking files away:
app/teas/layout.tsximport type { Metadata } from "next"; export const metadata: Metadata = { description: "Every tea we sell, with brewing notes.",}; export default function TeasLayout({ children }: LayoutProps<"/teas">) { return ( <section> <h2>Tea menu</h2> {children} </section> );}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled- Experiments (use with caution): ⨯ turbopackMinify Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (4/4) Finalizing page optimization ... Route (app)┌ ○ /_not-found├ ○ /teas/mint└ ◐ /teas/today ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content # The files next build wrote for the prerendered pages, in .next/server/app# (the server's .js code and the .json lists left out), with their sizes.next/server/app/_global-error.html 9.1 kB.next/server/app/_global-error.meta 681 bytes.next/server/app/_global-error.rsc 4.6 kB.next/server/app/_global-error.segments/__PAGE__.segment.rsc 4.3 kB.next/server/app/_global-error.segments/_full.segment.rsc 4.6 kB.next/server/app/_global-error.segments/_tree.segment.rsc 115 bytes.next/server/app/_not-found.html 7.5 kB.next/server/app/_not-found.meta 774 bytes.next/server/app/_not-found.rsc 4.5 kB.next/server/app/_not-found.segments/_full.segment.rsc 4.5 kB.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc 4.0 kB.next/server/app/_not-found.segments/_tree.segment.rsc 172 bytes.next/server/app/teas/mint.html 6.9 kB.next/server/app/teas/mint.meta 903 bytes.next/server/app/teas/mint.rsc 4.4 kB.next/server/app/teas/mint.segments/_full.segment.rsc 4.4 kB.next/server/app/teas/mint.segments/_tree.segment.rsc 215 bytes.next/server/app/teas/mint.segments/teas/mint/__PAGE__.segment.rsc 3.9 kB.next/server/app/teas/today.html 1.5 kB.next/server/app/teas/today.meta 4.4 kB.next/server/app/teas/today.segments/_full.segment.rsc 4.3 kB.next/server/app/teas/today.segments/_tree.segment.rsc 216 bytes.next/server/app/teas/today.segments/teas/today/__PAGE__.segment.rsc 3.7 kBThe build lists ○ /teas/mint and ◐ /teas/today. In the Network tab, the payload of /teas/mint gives the tea layout's LayoutRouter an error, a template and a notFound.
Without template.tsx, the tea folder's payload row has template={<RenderFromTemplateContext />}, like mint's. Without loading.tsx, there's no LoadingBoundaryProvider, so no Suspense around /teas/today, and its connection() stops the build: Loading UI is, underneath, that one Suspense.
Resolving metadata
The page's <title> and <meta> tags come from the metadata exports of every layout and page on the route (The Metadata API teaches them). Before it builds the tree, getRSCPayload calls createMetadataComponents with the loader tree. When those components render, resolve-metadata.ts collects one item per segment, from lines 227 to 252:
await collectMetadata({
tree,
metadataItems,
// …
for (const key in parallelRoutes) {
const childTree = parallelRoutes[key]
await resolveMetadataItemsImpl(
metadataItems,
childTree,
currentTreePrefix,
currentParams,
optionalCatchAllParamName,
searchParams,
errorConvention,
errorMetadataItem,
interpolatedParams
)
}Lines 1 to 3 collect the segment's own item: its layout's export (or its page's, for __PAGE__), and its metadata files. Lines 5 to 18 then do the same for each child. So the items are in the tree's order, root first. For /teas/mint that's four: the root layout, the tea layout, mint (no layout, so nothing), and the page. Then accumulateMetadata merges them in that order, from lines 510 to 559:
for (let i = 0; i < metadataItems.length; i++) {
const staticFilesMetadata = metadataItems[i][1]
// …
resolvedMetadata = await mergeMetadata(route, pathname, {
resolvedMetadata,
// …
// If the layout is the same layer with page, skip the leaf layout and leaf page
// The leaf layout and page are the last two items
if (i < metadataItems.length - 2) {
titleTemplates = {
title: resolvedMetadata.title?.template || null,
openGraph: resolvedMetadata.openGraph?.title.template || null,
twitter: resolvedMetadata.twitter?.title.template || null,
}
}
}Line 1 goes through the items in order. Line 5 merges each one into resolvedMetadata: a field the item sets replaces the one before. Lines 10 to 16 keep the title template for the items after it, except for the last two, the leaf's layout and page. Next.js calls each generateMetadata before this loop starts (prerenderMetadata, lines 383 to 395), and gets the parents' resolved metadata when the loop reaches it. The recorded head of /teas/mint:
<title>Mint tea | Tea shop</title>
<meta name="description" content="Every tea we sell, with brewing notes.">Line 1: the page's Mint tea with the root layout's %s | Tea shop. Line 2: the tea layout's description replaced the root layout's.
resolve-metadata collects one item per segment, from the root down, then merges them in that order: a later field replaces an earlier one, and the parents' title template is applied to the page's title.One detail of the merge surprises people. A layout that sets a title as a plain string sets the template for the segments below it to none, because a string title has no template. Try it:
app/teas/layout.tsximport type { Metadata } from "next"; export const metadata: Metadata = { description: "Every tea we sell, with brewing notes.",}; export default function TeasLayout({ children }: LayoutProps<"/teas">) { return ( <section> <h2>Tea menu</h2> {children} </section> );}GET /teas/mint200
The whole response is .next/server/app/teas/mint.html, a file next build wrote, byte for byte (the Terminal tab lists it).
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 15 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/1o6jvcf9smbzx.js">
<script src="/_next/static/chunks/0v8ccsftv8ajp.js" async=""></script>
<script src="/_next/static/chunks/0kxcdnce56bv1.js" async=""></script>
<script src="/_next/static/chunks/turbopack-1gh6_hegcwrtt.js" async=""></script>
<script src="/_next/static/chunks/30e5tuscv0mzh.js" async=""></script>
<script src="/_next/static/chunks/34k7bq_tk_eoi.js" async=""></script>
<title>Mint tea | Tea shop</title>
<meta name="description" content="Every tea we sell, with brewing notes.">
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<!--&-->
<section>
<h2>Tea menu</h2>
<!--&-->
<div>
<p>Brewing tip: use water just below boiling.</p>
<!--$-->
<!--&-->
<main>
<h1>Mint tea</h1>
<p>Cool and fresh.</p>
</main>
<!--$-->
<!--/$-->
<!--/&-->
<!--/$-->
</div>
<!--/&-->
</section>
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/1o6jvcf9smbzx.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>The tea layout sets only a description. The page's Mint tea gets the root layout's template: Mint tea | Tea shop.
With title: "Teas" in the tea layout, /teas/mint's title is Mint tea. The fix is to give that layout a template of its own, like { default: "Teas", template: "%s | Tea shop" }.
Streaming metadata in Next.js 16
Where do the tags go? The metadata renders inside a hidden <div> in the body, in a Suspense of its own, from metadata.tsx, lines 123 to 140:
function MetadataWrapper() {
// Keep the same component structure in streaming and blocking renders.
// The blocker only holds the shell open when metadata must not stream.
// React requires top-level suspenseful metadata to be nested under a host
// element. Otherwise it becomes part of the document preamble and blocks
// shell flushing instead of streaming. Metadata tags are hoisted out, so
// this hidden wrapper remains empty.
return (
<MetadataBoundary>
<div hidden>
<Suspense name="Next.Metadata">
<Metadata />
</Suspense>
</div>
<MetadataBlocker />
</MetadataBoundary>
)
}Lines 10 to 14: a generateMetadata that waits doesn't hold up the rest of the page; its tags stream in when they're ready. React moves <title> and <meta> tags into the <head> (the comment on lines 6 and 7), so the <div hidden> stays empty: that's lines 2 to 5 of the recorded body above. Line 15's MetadataBlocker holds the page back instead, but only when serveStreamingMetadata is false, which the server decides from the visitor's User-Agent, in streaming-metadata.ts, lines 6 to 20:
export function shouldServeStreamingMetadata(
userAgent: string,
htmlLimitedBots: string | undefined
): boolean {
const pattern = htmlLimitedBots || HTML_LIMITED_BOT_UA_RE_STRING
if (cachedPattern !== pattern) {
cachedPattern = pattern
cachedRegex = new RegExp(pattern, 'i')
}
// Only block metadata for HTML-limited bots
if (userAgent && cachedRegex!.test(userAgent)) {
return false
}
return true
}Lines 11 to 13: for a bot that reads only HTML (the htmlLimitedBots setting, or Next.js's own list on line 5), metadata blocks; for everyone else it streams. The Metadata API shows that from the outside.
The RSC payload first, then the HTML
Now the tree is ready. getRSCPayload puts it, with the metadata and a few flags, into one object, shortened from app-render.tsx, lines 2107 to 2204:
const { Viewport, Metadata, MetadataOutlet } = createMetadataComponents({
tree,
// …
const initialTree = await createFullComponentTree({
ctx,
loaderTree: tree,
// …
t: {
t: initialTree,
h: {
r: initialHead,
p: isPossiblyPartialHead,
v: getMetadataVaryParamsAccumulator(),
},
},
// …
G: [GlobalError, globalErrorStyles],Line 1 makes the metadata components, line 4 the tree that create-component-tree builds. Line 9 puts the tree under t, line 11 the head with the metadata under h, and line 17 the global error page under G. That object is what React renders. For a page rendered whole at request time on Node.js, it goes like this, shortened from app-render.tsx, lines 4047 to 4263. Prerendering during next build, and the request-time part of a ◐ page, are read in Prerendering and Partial Prerendering, later in this stage.
const RSCPayload: RSCPayload & RSCPayloadDevProperties =
await workUnitAsyncStorage.run(
requestStore,
getRSCPayload,
tree,
ctx,
{ is404: res.statusCode === 404, isPrerendering: false }
)
// …
reactServerResult = new ReactServerResult(
workUnitAsyncStorage.run(
requestStore,
renderToNodeFlightStream,
ctx.componentMod,
RSCPayload,
clientModules,
// …
const appElement = (
<App
reactServerStream={reactServerResult.tee()}
// …
const { stream: htmlStream, allReady } = await getTracer().trace(
AppRenderSpan.renderToNodeFizzStream,
() =>
workUnitAsyncStorage.run(
requestStore,
renderToNodeFizzStream,
appElement,
// …
return await continueFizzStream(htmlStream, {
inlinedDataStream: createNodeInlinedDataStream(
reactServerResult.consume(),
nonce,
formState
),- Lines 1 to 8 build the payload's object with
getRSCPayload. - Lines 10 to 16: React's Flight server renders it to a stream, with
clientModulesfrom the client reference manifest. Your Server Components run here, once. - Lines 18 to 20: the HTML render's root is
App, given one copy of that stream,tee(). - Lines 22 to 28:
renderToNodeFizzStreamrendersAppwithreact-dom/server, React's HTML renderer (called Fizz inside React). - Lines 30 to 35:
continueFizzStreammerges the HTML with the other copy of the payload,consume(), turned into script tags bycreateNodeInlinedDataStream.
App doesn't render your components again. It reads the payload back, from lines 2388 to 2396:
preinitScripts()
const response = ReactClient.use(
getFlightStream<InitialRSCPayload>(
reactServerStream,
reactDebugStream,
debugEndTime,
nonce
)
)Line 3, getFlightStream, is React's Flight client with the manifest's ssrModuleMapping, as the references lesson showed, so Client Components render from their SSR copies. Line 2's use waits for the payload, and App renders the router with it, so the HTML is exactly what the payload describes. The other copy goes into the HTML as scripts, from stream-ops.node.ts, lines 959 to 1033:
let scriptContents = `(self.__next_f=self.__next_f||[]).push(${htmlEscapeJsonString(
JSON.stringify([INLINE_FLIGHT_PAYLOAD_BOOTSTRAP])
)})`
// …
pt.push(Buffer.from(`${startScriptTag}${scriptContents}</script>`))
// …
const decodedString = chunk.toString('utf-8')
htmlInlinedData = htmlEscapeJsonString(
JSON.stringify([INLINE_FLIGHT_PAYLOAD_DATA, decodedString])
)
// …
output.push(
Buffer.from(
`${startScriptTag}self.__next_f.push(${htmlInlinedData})</script>`
)
)Lines 1 to 3 and 5 write the first script, (self.__next_f=self.__next_f||[]).push([0]): an empty list on self (the browser's window). Lines 7 to 10 and 12 to 16 wrap each piece of the payload in a script that pushes [1, piece] onto it (INLINE_FLIGHT_PAYLOAD_DATA is 1). This is the recorded HTML of /teas/mint, which the recording shows is the file the build wrote, .next/server/app/teas/mint.html, sent whole. Its payload scripts, each cut short here:
(self.__next_f=self.__next_f||[]).push([0])
self.__next_f.push([1,"1:\"$Sreact.fragment\"\n2:\"/_next/static/chunks/… (2270 more characters)
self.__next_f.push([1,"0:{\"P\":null,\"c\":[\"\",\"teas\",\"mint\"],\"q\… (2409 more characters)
self.__next_f.push([1,"d:C\n1c:300\n15:[[\"$\",\"meta\",\"0\",{\"charSet… (119 more characters)
self.__next_f.push([1,"11:null\n18:[[\"$\",\"title\",\"0\",{\"children\"… (137 more characters)
self.__next_f.push([1,"1c:C\n19:C\n1b:C\n12:C\nb:C\n5:C\n"])
self.__next_f.push([1,"1e:false\n1d:null\n"])Line 1 is the empty list. Lines 2 to 7 each push a piece of the payload: the same rows the rsc: 1 request got, like 1:"$Sreact.fragment" and the root row 0:{…}. In the browser, Next.js's client reads self.__next_f and gives React the payload, so it can hydrate the Client Components without asking the server again. How the rows are read, and how the HTML and the payload stream side by side, is for The RSC payload.
The loader tree's shape, the payload's keys (t, h, G), module ids, chunk names and the self.__next_f scripts are Next.js 16.4.3's internals, from this recorded build. They can change in any release. What's promised is the documented behavior: the special files' order, metadata merging, and streaming.
Reading a route's tree yourself
In a Next.js 16.4.3 project built with Turbopack and run on Node.js, you can find these in your own build. The paths and shapes are this version's; other versions, webpack builds and the Edge runtime differ. Read them to learn and to debug, never from your app's code:
- The loader tree: build with
experimental: { turbopackMinify: false }, then search.next/server/chunks/ssrfor your page's path, like[project]/app/teas/mint/page.tsx. - The tree as rendered: in the browser's Network tab, a client-side navigation's request with
rsc: 1has the payload, with a row for each segment. - The payload inside the HTML: view the page's source, and search for
self.__next_f.
Sort what each part of Next.js does: which one does it?
- Reading a folder's
layout,templateanderrorfrom the loader tree - Putting
error.tsxinto aLayoutRouter's props - Wrapping the page in a
Suspensewithloading.tsxas the fallback - Catching an error thrown below a folder's template
- Applying the root layout's
%s | Tea shopto the page's title - Letting the tea layout's
descriptionreplace the root layout's
Sort each job: create-component-tree on the server, LayoutRouter, or resolve-metadata.
Common mix-ups
- "A layout's children is the page." No. It's a
LayoutRouterelement, with the folder's template and boundaries as props. The page is further down. - "error.tsx wraps the layout beside it." No. The layout renders the
LayoutRouterthat holdsErrorBoundary, so the layout and the template are outside it. - "The server renders the HTML from your components, then the payload separately." No. Server Components render once, to the payload. The HTML is rendered from the payload.
- "loading.tsx is special to Next.js's router." No. It becomes a plain
Suspensefallback, placed byLoadingBoundary. - "The page's metadata is merged first." No. Items merge from the root layout down, so the page's fields win because they come last.
- "The payload scripts are for search engines." No. They're for the browser's React, which reads them to hydrate the page without another request.
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 Next.js's source, and the excerpts above hold it.
This is part of the recorded loader tree of /teas/mint. What is the name of the segment that holds page.tsx?
"children": [
"mint",
{
"children": [
"__PAGE__",
{},
{
metadata: {},
"page": [
__TURBOPACK__page__$23$10__,
"[project]/app/teas/mint/page.tsx"
]
},
[]
]
},__PAGE__. The page is a segment of its own, below its folder mint, and its files hold "page" with the path [project]/app/teas/mint/page.tsx. That's why the payload has a separate row for it.
This is the tea folder's row of the recorded payload, as JSX. What is LayoutRouter's error prop?
<LoadingBoundaryProvider
loading={<p>Loading the tea…</p>}
>
<section>
<h2>Tea menu</h2>
<LayoutRouter
parallelRouterKey="children"
error={TeaError}
template={<div><p>Brewing tip: use water just below boiling.</p><RenderFromTemplateContext /></div>}
notFound={<p>No such tea.</p>}
/>
</section>
</LoadingBoundaryProvider>TeaError, from app/teas/error.tsx. It's a "use client" file, so the payload holds a reference to it; LayoutRouter passes it to ErrorBoundary.
Which component in layout-router.tsx puts the page in a Suspense with loading.tsx as its fallback?
let templateValue = (
<ScrollHandler renderTree={renderTree}>
<ErrorBoundary
errorComponent={error}
// …
<LoadingBoundary
// …
loading={parentLoadingData}
>
<HTTPAccessFallbackBoundary
// …
>
<RedirectBoundary>
<InnerLayoutRouter
// …
/>
// …
<TemplateContext.Provider key={stateKey} value={templateValue}>
{templateStyles}
{templateScripts}
{template}
</TemplateContext.Provider>LoadingBoundary. It renders <Suspense fallback={…}> with the loading element when the data isn't null, and only its children when the folder has no loading.tsx (layout-router.tsx, lines 465 to 498).
The tea layout now sets title: "Teas". What does /teas/mint's <title> say?
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Teas",
description: "Every tea we sell, with brewing notes.",
};
export default function TeasLayout({ children }: LayoutProps<"/teas">) {
return (
<section>
<h2>Tea menu</h2>
{children}
</section>
);
}
Mint tea. The tea layout's string title sets the template to none, so the page's Mint tea gets no suffix. The recorded build of this version shows <title>Mint tea</title>.
You delete app/teas/loading.tsx and build. Which route stops the build?
import { connection } from "next/server";
export default async function TodayPage() {
await connection();
return (
<main>
<h1>Tea of the day: Green tea</h1>
</main>
);
}
/teas/today. Its connection() waits for a request, and with no Suspense above it, the prerender can't make a shell. The recorded build stopped with Route "/teas/today": Next.js encountered uncached or runtime data during prerendering.
On Node.js, which function turns the payload into the self.__next_f script tags?
return await continueFizzStream(htmlStream, {
inlinedDataStream: createNodeInlinedDataStream(
reactServerResult.consume(),
nonce,
formState
),createNodeInlinedDataStream, in stream-ops.node.ts, lines 946 to 973. It writes the first script, (self.__next_f=self.__next_f||[]).push([0]), then pullFlightData wraps each piece of the payload in self.__next_f.push([1, …]).
Check your understanding
For each question, ask whether the server placed it or LayoutRouter rendered it, and in which order.
Question 1 of 6What does
renderToHTMLOrFlightgive back for an ordinary page request?Choose an answer to see the explanation.
Question 2 of 6Where does create-component-tree put a folder's
error.tsx?Choose an answer to see the explanation.
Question 3 of 6From the outside in, in what order do one folder's special files wrap the page?
Choose an answer to see the explanation.
Question 4 of 6In what order does resolve-metadata collect the metadata for
/teas/mint?Choose an answer to see the explanation.
Question 5 of 6While it makes the HTML, where does Next.js get the React tree to render?
Choose an answer to see the explanation.
Question 6 of 6What is
self.__next_fin the recorded HTML of/teas/mint?Choose an answer to see the explanation.
Key takeaways
- The build writes each page's loader tree into its entry: segments from the root down, each with its files, and the page as its own
__PAGE__segment. renderToHTMLOrFlightgets the request and the route's module, and returns aRenderResult: the payload alone forrsc: 1, an action's answer, or HTML.- create-component-tree makes, for each folder, a
LayoutRouterwith its template, error and not-found files as props, and aLoadingBoundaryProviderwith its loading file. The layout gets theLayoutRouteraschildren. LayoutRouternests them: template, error, loading, not-found, then the next segment.- Metadata is collected from the root down and merged in that order; a string title clears the template below it. In Next.js 16, it streams in a hidden
<div>unless the visitor is an HTML-only bot. - The tree renders once, to the RSC payload. The HTML is rendered from the payload, and the payload goes into the HTML as
self.__next_fscripts.
Remember the one-liner.
The server places your files in one tree, renders it once to the payload, and makes the HTML from that payload.
Coming next: The RSC payload. It reads the Flight stream row by row: the text/x-component responses, the RSC headers, and the self.__next_f data in the HTML.