cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

Build your own Next.js

Write a tiny React framework in about 200 lines: routes from folders, Server Components streamed as an RSC payload, client references and client navigation.

By the end, you can
  • 01
    Build the core of a React frameworkFind routes in folders, render Server Components to an RSC payload, and turn the payload into streamed HTML with React's own packages.
  • 02
    Connect the server and the browserReplace 'use client' files with client references, inline the payload in the HTML, hydrate from it, and navigate by fetching payloads.
  • 03
    Read Next.js with your framework as a mapName the file in Next.js 16.4.3 that does each part's job for real, and what Next.js adds on top: caching, prerendering, prefetching and Server Actions.

A tea shop on a framework you write

The Under the hood stage read Next.js from the inside: the server that finds a route, app-render, which turns the route into a React tree, client references, the RSC payload and the client router. Now you write a framework that does the same jobs on a small scale. A framework here is the code around your components that decides where each one runs and how a request becomes a page (Why Next.js? compares a framework with a library).

The framework is about two hundred lines in a folder called framework, and it runs the tea shop in a folder called app. It isn't Next.js and never imports it. It uses the same kind of parts, from npm: React 19.3.0, the version create-next-app installs with Next.js 16.4.3; react-server-dom-webpack 19.3.0, React's package that writes and reads the RSC payload (Next.js ships its own build of it, and of React, inside next/dist/compiled); and esbuild 0.28.2, a bundler: a tool that joins many files into a few. This is the whole project, recorded: node framework/build.mjs ran first, then node framework/server.mjs answered real requests, and real Chrome clicked through the shop.

The tea shop on the tiny frameworkNext.js
app/teas/[name]/framework/
app/teas/[name]/page.jsx
import { Suspense } from "react";import AddToCart from "../../add-to-cart.jsx";import { getReviews, teas } from "../../teas.js"; async function Reviews({ id }) {  const reviews = await getReviews(id);  return (    <ul>      {reviews.map((review) => (        <li key={review}>{review}</li>      ))}    </ul>  );} export default function TeaPage({ params }) {  const tea = teas[params.name];  if (!tea) return <p>No tea called {params.name}.</p>;  return (    <main>      <a href="/">All teas</a>      <h1>        {tea.name}: ${tea.price}      </h1>      <AddToCart />      <Suspense fallback={<p>Loading reviews…</p>}>        <Reviews id={params.name} />      </Suspense>    </main>  );}
Recorded from its own server · React 19.3.0
localhost:3000/teas/mintTea shop
Mint tea: $3

Add to cart In cart: 0

  • Cool and fresh.
  • Lovely after dinner.

The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.

In app/teas/[name]/page.jsx, shown first, TeaPage on line 16 is a Server Component. Line 17 reads the tea from app/teas.js, a stand-in for a database, and line 18 answers for a tea the shop doesn't sell. Line 25 renders AddToCart, the one Client Component, and lines 26 to 28 put the reviews behind a Suspense boundary, because getReviews waits 300 ms on purpose. The Browser tab shows /teas/mint as it arrived. The Terminal tab shows what build.mjs printed: the two routes it found, and the one client component. The label above the tabs says the recording is from the framework's own server, not from Next.js.

Each of the five files in framework does one job:

The framework, file by file
FileIts jobWhen and where it runs
framework/build.mjsFinds the routes and the client components, writes three small files, and makes three bundles with esbuild.Once, before the server starts, like next build.
framework/rsc.jsxMatches a path to a page, and renders the page's Server Components to an RSC payload.On the server, for each request, in the Server Components bundle.
framework/ssr.jsxReads the payload back into a React tree, and renders that tree to HTML.On the server, for each page load, in the HTML bundle.
framework/server.mjsAnswers each request: HTML with the payload in scripts, or the payload alone.On the server: it's the process that listens on the port, like next start.
framework/browser.jsxHydrates the page from the payload in the HTML, and navigates by fetching payloads.In the browser, as browser.js.
The app folder and the framework folder go into build.mjs. build.mjs makes three bundles: .tiny/rsc.cjs for Server Components, .tiny/ssr.cjs for the HTML, and .tiny/browser.js for the browser.app + frameworkbuild.mjs.tiny/rsc.cjsServer Components.tiny/ssr.cjsthe HTML.tiny/browser.jsthe browser
build.mjs reads your files once and makes three bundles. server.mjs loads the first two, for Server Components and for the HTML; the browser gets the third.
Real-life analogyA food cart that cooks a restaurant's menu

A food cart can't seat a hundred guests, but it cooks the same dishes as a restaurant, with the same recipes, and every step happens where you can see it: the cook takes the order, cooks, plates, and hands it over. Once you've run a cart, a restaurant kitchen is easier to read. Every station there is a bigger version of something the cart already does.

In real life: The restaurant's kitchen, with a station for every job
In Next.js: Next.js, with a part for every job
In real life: A food cart with one cook and one stove
In Next.js: Your framework: five files, one bundler, one server
In real life: The same recipes and the same ingredients
In Next.js: The same React and the same Flight package
In real life: Dishes that go out one course at a time
In Next.js: HTML and payload rows that stream as they're ready
In real life: A dish the guest finishes at the table
In Next.js: A Client Component, which runs again in the browser
The main idea

A React framework is three parts around React: a build that finds the routes and splits the code into a server part and a browser part, a server that renders Server Components to an RSC payload and then to HTML, and a browser part that hydrates from that payload and fetches new payloads to navigate. React does the rendering. The framework connects the parts.

React's packages, and Next.js's internals

react-server-dom-webpack's package description says it's meant to be built into frameworks, not imported by an app, and its README calls it experimental. Your framework is that kind of code; the shop's own files never import it. Each part of the framework is paired with the file in Next.js 16.4.3 that does the same job. Those files, functions and names are internals of that release: they show how it works, and any version can change them.

A framework for learning: don't deploy it

The tiny framework leaves out what a public site needs, on purpose, so every line can be read. Among what it lacks:

  • Error handling. A page that throws leaves its response open forever, and an address with a broken % escape, like /teas/%E0, makes decodeURIComponent throw where nothing catches it, which stops the whole server.
  • Caching and prerendering. Every request renders every page again.
  • Security headers. No Content Security Policy and no other protective headers, and no limits on how long a request may take.
  • Server Actions and forms. The browser can read pages, but has no way to send changes back.
  • Compression, caching headers for browser.js, and chunks for each route.

One part is safe as written: it serves only one file, and only for the exact path /browser.js, from memory. No part of an address ever becomes a path on the disk, so a request can't read other files.

Routes from folders

Next.js finds pages by reading folders (File-system routing). Your framework does the same in the first part of framework/build.mjs. Open its Terminal tab to see what it printed.

framework/build.mjs finds the routes and the client componentsNext.js
app/teas/[name]/framework/
framework/build.mjs
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";import { build } from "esbuild"; // 1. Routes from folders: each page.jsx in app is a page, and its folders are its path.const files = readdirSync("app", { recursive: true }).map((file) => `app/${file}`).sort();const routes = files  .filter((file) => file.endsWith("/page.jsx"))  .map((file) => ({ path: file.slice(3, -"/page.jsx".length) || "/", file })); // 2. Client components: the files that start with "use client".const clientFiles = files.filter((file) => file.endsWith(".jsx") && readFileSync(file, "utf8").startsWith('"use client"')); console.log("Routes");for (const route of routes) console.log(`  ${route.path.padEnd(14)} ${route.file}`);console.log("Client components");for (const file of clientFiles) console.log(`  ${file}`); // 3. Three files for the bundles: the route table as code, the client// components by id, and the client manifest.mkdirSync(".tiny", { recursive: true });const imports = (list, name) => list.map((file, i) => `import ${name}${i} from "../${file}";`).join("\n");writeFileSync(  ".tiny/routes.js",  `${imports(routes.map((route) => route.file), "Page")}export default [${routes.map((route, i) => `{ path: "${route.path}", Page: Page${i} }`).join(", ")}];`,);writeFileSync(  ".tiny/client-modules.js",  `${imports(clientFiles, "* as module")}const modules = { ${clientFiles.map((file, i) => `"${file}": module${i}`).join(", ")} };globalThis.__webpack_require__ = (id) => modules[id];`,);const manifest = Object.fromEntries(clientFiles.map((id) => [id, { id, chunks: [] }]));writeFileSync(".tiny/client-manifest.json", JSON.stringify(manifest, null, 2)); // 4. In the Server Components bundle, a client file becomes a client reference.const clientReferences = {  name: "client-references",  setup(bundler) {    bundler.onLoad({ filter: /\.jsx$/ }, ({ path }) => {      const id = path.slice(process.cwd().length + 1);      if (!clientFiles.includes(id)) return undefined;      return {        contents: `import { registerClientReference } from "react-server-dom-webpack/server";export default registerClientReference(() => {  throw new Error("${id} runs in the browser");}, "${id}", "default");`,        resolveDir: process.cwd(),      };    });  },}; // 5. Three bundles: Server Components, the HTML render, and the browser.const shared = { bundle: true, jsx: "automatic", define: { "process.env.NODE_ENV": '"production"' } };await build({ ...shared, entryPoints: ["framework/rsc.jsx"], outfile: ".tiny/rsc.cjs", platform: "node", conditions: ["react-server"], plugins: [clientReferences] });await build({ ...shared, entryPoints: ["framework/ssr.jsx"], outfile: ".tiny/ssr.cjs", platform: "node" });await build({ ...shared, entryPoints: ["framework/browser.jsx"], outfile: ".tiny/browser.js", format: "esm", minify: true });console.log("Built .tiny/rsc.cjs, .tiny/ssr.cjs and .tiny/browser.js");
Recorded from its own server · React 19.3.0
$ node framework/build.mjsRoutes  /              app/page.jsx  /teas/[name]   app/teas/[name]/page.jsxClient components  app/add-to-cart.jsxBuilt .tiny/rsc.cjs, .tiny/ssr.cjs and .tiny/browser.js $ node framework/server.mjsReady on port 3000
  • Line 5 reads every name under app, folders too (recursive: true), puts app/ in front of each, and sorts the list, so it's in the same order on every computer.
  • Lines 6 to 8 keep the files whose name ends in /page.jsx, and make each one a route. Its path is the text between app and /page.jsx, like /teas/[name], or / when nothing is left.
  • Line 11 finds the client components: the .jsx files whose text starts with "use client".
  • Lines 13 to 16 print both lists. The Terminal tab shows / from app/page.jsx, /teas/[name] from app/teas/[name]/page.jsx, and app/add-to-cart.jsx under Client components. app/teas.js is in neither list.

The server shouldn't read folders for every request, so build.mjs turns the route list into code: a file that imports every page. Lines 22 to 27 write .tiny/routes.js. The .tiny folder plays the part of Next.js's .next folder. For the shop, the file holds:

.tiny/routes.js, as build.mjs writes it for the shopJavaScript
import Page0 from "../app/page.jsx";
import Page1 from "../app/teas/[name]/page.jsx";
export default [{ path: "/", Page: Page0 }, { path: "/teas/[name]", Page: Page1 }];

The helper imports, on line 21 of build.mjs, wrote lines 1 and 2: one import for each page. Line 3 exports the table: each route's path, with the page component it imported. Then framework/rsc.jsx matches a request's path against the table:

framework/rsc.jsxJSX
import { renderToPipeableStream } from "react-server-dom-webpack/server";import clientManifest from "../.tiny/client-manifest.json";import routes from "../.tiny/routes.js"; // Finds the route for a path. "[name]" matches any one part and becomes a param.function match(pathname) {  const parts = pathname.split("/").filter(Boolean);  for (const route of routes) {    const pattern = route.path.split("/").filter(Boolean);    if (pattern.length !== parts.length) continue;    const params = {};    const fits = pattern.every((part, i) => {      if (part.startsWith("[")) params[part.slice(1, -1)] = decodeURIComponent(parts[i]);      return part.startsWith("[") || part === parts[i];    });    if (fits) return { Page: route.Page, params };  }  return null;} // Renders the page for a path to an RSC payload: a stream of rows.export function renderPayload(pathname) {  const found = match(pathname);  const page = found ? <found.Page params={found.params} /> : <h1>Not found</h1>;  const tree = (    <html lang="en">      <head>        <title>Tea shop</title>      </head>      <body>{page}</body>    </html>  );  return { status: found ? 200 : 404, payload: renderToPipeableStream(tree, clientManifest) };} 
  1. Line 7 splits the path into parts: /teas/mint becomes ["teas", "mint"].
  2. Line 8 tries each route in the table's order. Line 9 splits the route's path the same way, and line 10 skips a route with another number of parts.
  3. Lines 12 to 15 compare the parts one by one. A part in brackets matches anything, and line 13 keeps what it matched as a param: [name] against mint gives { name: "mint" }.
  4. Line 16 returns the first route that fits, with its params. Line 18 returns null when none does.
An editor window for the app folder lists page.jsx, teas/[name]/page.jsx, add-to-cart.jsx and teas.js. page.jsx becomes the route slash. teas/[name]/page.jsx becomes the route /teas/[name]. add-to-cart.jsx and teas.js are not pages. match on /teas/mint gives the params name: mint.apppage.jsxteas/[name]/page.jsxadd-to-cart.jsxteas.js//teas/[name]not pages: no routename: "mint"/teas/mintthe params
Each page.jsx is a route, named by its folders; the other files have no route. For /teas/mint, match() picks /teas/[name], and the part in brackets becomes a param.

Next.js does both jobs in more steps. normalizeAppPath, in app-paths.ts, lines 23 to 52, turns a file's path into a route: it skips route groups like (shop), slots like @modal, and the last page or route part. For each request, the router server tries static routes first, then [param] routes, then catch-alls (sorted-routes.ts, lines 28 to 63). Your loop tries routes in file order, so a /teas/[name] route would beat a /teas/new page: [ sorts before n. Next.js also gives a page its params as a promise, since Next.js 15 (Dynamic routes); yours is a plain object. From next dev to a response follows a request through all of it.

Rendering Server Components to a stream

renderPayload, on lines 21 to 34 of rsc.jsx, renders a page to the RSC payload: the text that describes what Server Components rendered, with references to the Client Components. React calls its format Flight. The Network tab below opens on the request the recorder sent with the header rsc: 1, the way the framework's router asks for a page.

framework/rsc.jsx renders the page to an RSC payloadNext.js
app/teas/[name]/framework/
framework/rsc.jsx
import { renderToPipeableStream } from "react-server-dom-webpack/server";import clientManifest from "../.tiny/client-manifest.json";import routes from "../.tiny/routes.js"; // Finds the route for a path. "[name]" matches any one part and becomes a param.function match(pathname) {  const parts = pathname.split("/").filter(Boolean);  for (const route of routes) {    const pattern = route.path.split("/").filter(Boolean);    if (pattern.length !== parts.length) continue;    const params = {};    const fits = pattern.every((part, i) => {      if (part.startsWith("[")) params[part.slice(1, -1)] = decodeURIComponent(parts[i]);      return part.startsWith("[") || part === parts[i];    });    if (fits) return { Page: route.Page, params };  }  return null;} // Renders the page for a path to an RSC payload: a stream of rows.export function renderPayload(pathname) {  const found = match(pathname);  const page = found ? <found.Page params={found.params} /> : <h1>Not found</h1>;  const tree = (    <html lang="en">      <head>        <title>Tea shop</title>      </head>      <body>{page}</body>    </html>  );  return { status: found ? 200 : 404, payload: renderToPipeableStream(tree, clientManifest) };}
Recorded from its own server · React 19.3.0

GET /teas/mint200

Sent with the request
rsc 1
Response headers
content-type text/x-component
vary rsc

The body that arrived, as it arrived: the RSC payload, one row per line

1:"app/add-to-cart.jsx"
2:I["$1",[],"default"]
3:"$Sreact.suspense"
0:["$","html",null,{"lang":"en","children":[["$","head",null,{"children":["$","title",null,{"children":"Tea shop"}]}],["$","body",null,{"children":["$","main",null,{"children":[["$","a",null,{"href":"/","children":"All teas"}],["$","h1",null,{"children":["Mint tea",": $",3]}],["$","$L2",null,{}],["$","$3",null,{"fallback":["$","p",null,{"children":"Loading reviews…"}],"children":"$L4"}]]}]}]]}]
4:["$","ul",null,{"children":[["$","li","Cool and fresh.",{"children":"Cool and fresh."}],["$","li","Lovely after dinner.",{"children":"Lovely after dinner."}]]}]

Line 23 matches the path. Line 24 makes the page's element, with the params as a prop, or a Not found heading. Lines 25 to 32 put it in a document: <html>, a <head> with the title, and <body>. That part of the tree belongs to the framework, like Next.js's root layout, but an app can't change it. Line 33 hands the tree to renderToPipeableStream from react-server-dom-webpack/server, React's Flight server, with the client manifest from line 2: a lookup table from each client reference to the module the browser loads for it. It returns the status too: 200, or 404 when no route matched.

renderToPipeableStream starts the render and returns an object whose pipe sends the rows into a Node.js stream. React writes rows at once and keeps writing as async components finish. This is the body the recording kept, one row per line:

The RSC payload of /teas/mint (recorded)Text
1:"app/add-to-cart.jsx"
2:I["$1",[],"default"]
3:"$Sreact.suspense"
0:["$","html",null,{"lang":"en","children":[["$","head",null,{"children":["$","title",null,{"children":"Tea shop"}]}],["$","body",null,{"children":["$","main",null,{"children":[["$","a",null,{"href":"/","children":"All teas"}],["$","h1",null,{"children":["Mint tea",": $",3]}],["$","$L2",null,{}],["$","$3",null,{"fallback":["$","p",null,{"children":"Loading reviews…"}],"children":"$L4"}]]}]}]]}]
4:["$","ul",null,{"children":[["$","li","Cool and fresh.",{"children":"Cool and fresh."}],["$","li","Lovely after dinner.",{"children":"Lovely after dinner."}]]}]
  1. Line 1 is row 1: a plain string, the client file's id. Each row starts with its id and a colon.
  2. Line 2 is row 2, an import row, with the tag I: the module "$1" (row 1's string), no chunks to load first ([]), and the export "default". It's the client reference for AddToCart.
  3. Line 3 is row 3. "$Sreact.suspense" stands for React's Suspense, a symbol, which JSON can't hold.
  4. Line 4 is row 0, the root: the <html> element and everything in it, each element as ["$", type, key, props]. TeaPage isn't in it: it already ran, and only what it returned is there. "$L2" is AddToCart, a lazy reference to row 2. The Suspense element has its fallback, and "$L4" as its children: row 4 isn't there yet.
  5. Line 5 is row 4, the reviews' list. React wrote it once getReviews had finished, in the same response.

The rows and their order are React's. React wrote rows 1 to 3 before row 0, because row 0 points at them. Server Components inside: the Flight protocol explains every tag and $ value, and The RSC payload reads a Next.js payload, row by row.

Two Reacts in one server

Server Components run on a special build of React. The react package and react-server-dom-webpack/server each pick their file by a condition named react-server: a name in a package's exports field that chooses one file over another. React's Server Components build has no useState or useEffect, and the Flight server's plain entry only throws an error that asks for the condition. Node.js can set a condition for a whole process (--conditions react-server), but your server needs both Reacts in one process: one for the payload, one for the HTML. So the condition goes to the bundler instead. Line 58 of build.mjs bundles rsc.jsx with conditions: ["react-server"] into .tiny/rsc.cjs, and line 59 bundles ssr.jsx with the usual React into .tiny/ssr.cjs. Next.js does the same with layers in one module graph (SWC and Turbopack).

In Next.js, create-component-tree.tsx builds a much bigger tree from your files: a layout, template, loading, error and not-found file for every folder, around the page (How a route becomes a React tree). It renders that tree with React's own build that ships inside Next.js, and with Turbopack, with react-server-dom-turbopack instead of the webpack package (entrypoints.ts, lines 35 to 59).

Client references: one file, three copies

app/add-to-cart.jsx starts with "use client", which marks where the client part of the app begins (Client Components):

app/add-to-cart.jsxJSX
"use client";
import { useEffect, useState } from "react";

export default function AddToCart() {
  const [count, setCount] = useState(0);
  const [hydrated, setHydrated] = useState(false);
  useEffect(() => setHydrated(true), []);
  return (
    <p>
      <button disabled={!hydrated} onClick={() => setCount(count + 1)}>
        Add to cart
      </button>{" "}
      In cart: {count}
    </p>
  );
}

Line 5 keeps the count in state. Line 6 starts hydrated as false, and the Effect on line 7 sets it to true. Effects only run in the browser, so the HTML has a disabled button (line 10), and React turns it on once it has hydrated it.

TeaPage imports it on line 2 of its file. In the Server Components bundle, that import mustn't bring the component's code: React's Server Components build has no state, and this code belongs to the browser. So build.mjs swaps the file there, with an esbuild plugin: a function esbuild calls while it bundles. Lines 38 to 54 of build.mjs are the plugin. Its onLoad, on line 42, runs for each .jsx file esbuild loads. Line 43 turns the file's full path into its id, like app/add-to-cart.jsx, and line 44 leaves every other file alone (returning undefined lets esbuild read the file as usual). For a client file, lines 45 to 51 give esbuild this module instead:

What the plugin gives the Server Components bundle for app/add-to-cart.jsxJavaScript
import { registerClientReference } from "react-server-dom-webpack/server";
export default registerClientReference(() => {
  throw new Error("app/add-to-cart.jsx runs in the browser");
}, "app/add-to-cart.jsx", "default");

registerClientReference, from React's Flight server, takes a function, an id and an export's name, and marks the function as a client reference. The function throws if anything calls it: on the server, AddToCart can be rendered, never run. When TeaPage renders it, React looks its id up in the client manifest, which lines 35 and 36 of build.mjs wrote:

.tiny/client-manifest.json, as build.mjs writes it for the shopJSON
{
  "app/add-to-cart.jsx": {
    "id": "app/add-to-cart.jsx",
    "chunks": []
  }
}

Each client file gets the id the browser knows its module by (line 3), and the chunks to load before it (line 4). The browser gets one file that holds every client component, so there are no chunks to load. React wrote this entry into the payload as rows 1 and 2:

Rows 1 and 2 of the recorded payloadText
1:"app/add-to-cart.jsx"
2:I["$1",[],"default"]

The other two bundles get the real component. ssr.jsx and browser.jsx both start by importing .tiny/client-modules.js, which lines 28 to 34 of build.mjs wrote:

.tiny/client-modules.js, as build.mjs writes it for the shopJavaScript
import * as module0 from "../app/add-to-cart.jsx";
const modules = { "app/add-to-cart.jsx": module0 };
globalThis.__webpack_require__ = (id) => modules[id];

Line 1 imports the real file, and line 2 keeps it under its id. Line 3 is the part React needs. react-server-dom-webpack was made for webpack, so when a row names a module, React's Flight client asks for it with webpack's own loading function, __webpack_require__. There's no webpack here, so the framework defines that one function, as a global: an id in, a module out. One file has become three copies:

One file, app/add-to-cart.jsx, with three arrows to three bundles. In rsc.cjs it is only a registerClientReference call. In ssr.cjs it is the real AddToCart, which makes the button's HTML. In browser.js it is the real AddToCart, which runs the clicks.add-to-cart.jsxrsc.cjsregisterClientReference(…)ssr.cjsthe real AddToCart: HTMLbrowser.jsthe real AddToCart: clicks
The plugin gives the Server Components bundle a client reference instead of AddToCart. The other two bundles get the real component: ssr.cjs to make its HTML, browser.js to hydrate it and run its clicks.

Next.js makes the same three copies with its compiler ('use client' and 'use server' under the hood). Turbopack writes the server copy of a client file with one registerClientReference call for each export, and webpack builds use a loader whose module-proxy.ts re-exports createClientModuleProxy. Its client reference manifest maps each file to the browser's module and chunks, and to a separate copy for the HTML, which use-flight-response.tsx, lines 47 to 72, gives React's Flight client as ssrModuleMapping. Your framework needs no such map: one id names the module in both ssr.cjs and browser.js. It also handles only a default export, and no "use server" files.

The payload and the HTML in one response

A first visit needs HTML, so the browser can show the page before any JavaScript runs. framework/ssr.jsx makes it from the payload:

framework/ssr.jsxJSX
import "../.tiny/client-modules.js";
import { use } from "react";
import { renderToPipeableStream } from "react-dom/server";
import { createFromNodeStream } from "react-server-dom-webpack/client";

// Turns an RSC payload into HTML: React reads the rows back into a tree, then renders it.
export function renderHtml(payload, onShellReady) {
  const tree = createFromNodeStream(payload, { moduleMap: null, serverModuleMap: null, moduleLoading: null });
  function Root() {
    return use(tree);
  }
  return renderToPipeableStream(<Root />, { bootstrapModules: ["/browser.js"], onShellReady });
}
  1. Line 1 defines __webpack_require__ for this bundle, with the real AddToCart behind it.
  2. Line 8 gives the payload stream to createFromNodeStream, React's Flight client for Node.js. It returns a promise of the tree the rows describe, with the real AddToCart where row 2 named it. The three nulls are what a bigger framework passes: a map from the browser's module ids to the server's, a map for Server Functions, and how to load chunks. This framework needs none of them.
  3. Lines 9 to 11 make a component that reads that promise with use, so React waits for row 0.
  4. Line 12 renders it to HTML with renderToPipeableStream from react-dom/server: the same name as the Flight server's function, from another package. bootstrapModules adds a <script type="module"> for /browser.js, and onShellReady runs when the shell is ready: everything outside the Suspense boundaries that are still waiting.

framework/server.mjs sends that HTML, and the payload with it. The browser needs both: the HTML to show, the payload to hydrate.

framework/server.mjsJavaScript
import { readFileSync } from "node:fs";
import { createServer } from "node:http";
import { createRequire } from "node:module";
import { PassThrough, Writable } from "node:stream";

const require = createRequire(import.meta.url);
const { renderPayload } = require("../.tiny/rsc.cjs");
const { renderHtml } = require("../.tiny/ssr.cjs");
const browserCode = readFileSync(".tiny/browser.js");
const escapeJson = (json) => json.replace(/[&<>\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
// Sends React's HTML, with each piece of the payload in a script tag between React's writes.
function sendHtml(res, status, payload) {
  const flight = payload.pipe(new PassThrough());
  const decoder = new TextDecoder();
  let pieces = [];
  let started = false;
  let open = 2;
  const writePieces = () => {
    for (const text of pieces) res.write(`<script>(self.__tiny ||= []).push(${escapeJson(JSON.stringify(text))})</script>`);
    pieces = [];
  };
  const end = () => {
    if (--open > 0) return;
    writePieces();
    res.end("</body></html>");
  };
  flight.on("data", (chunk) => {
    pieces.push(decoder.decode(chunk, { stream: true }));
    if (started) writePieces();
  });
  flight.on("end", end);
  const html = new Writable({
    write(chunk, encoding, next) {
      res.write(chunk.toString().replace("</body></html>", ""));
      started = true;
      queueMicrotask(writePieces);
      next();
    },
    final(next) {
      end();
      next();
    },
  });
  const render = renderHtml(flight, () => {
    res.writeHead(status, { "content-type": "text/html; charset=utf-8", vary: "rsc" });
    render.pipe(html);
  });
}

createServer((req, res) => {
  const { pathname } = new URL(req.url, "http://localhost");
  if (pathname === "/browser.js") {
    res.writeHead(200, { "content-type": "text/javascript" });
    return res.end(browserCode);
  }
  const { status, payload } = renderPayload(pathname);
  if (req.headers.rsc !== "1") return sendHtml(res, status, payload);
  // A client navigation asks for the payload alone.
  res.writeHead(status, { "content-type": "text/x-component", vary: "rsc" });
  payload.pipe(res);
}).listen(process.env.PORT ?? 3000, () => console.log(`Ready on port ${process.env.PORT ?? 3000}`));

Lines 6 to 9 load the two server bundles and keep browser.js in memory. Lines 50 to 61 answer each request: line 52 serves browser.js, line 56 renders the payload, line 57 sends HTML unless the request has rsc: 1, and lines 59 and 60 send the payload alone, as text/x-component, for the router. Both answers say vary: rsc, so a cache keeps an address's HTML and its payload apart. Next.js varies its answers the same way, on more headers: setVaryHeader sends Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch (base-server.ts, lines 2415 to 2428). Line 61 starts listening on PORT and prints Ready on port 3000, as the Terminal tab shows.

sendHtml, lines 12 to 48, joins two streams into one response. Line 13 pipes the payload into a PassThrough stream, flight, that two readers share. Line 44 starts the HTML render, which reads it. Line 27 reads it too: line 28 decodes each chunk to text (stream: true keeps a character that's split between two chunks whole) and keeps it in pieces. Line 19 writes each piece as a script, (self.__tiny ||= []).push("…"). JSON.stringify makes the piece a string, and escapeJson, on line 10, writes five characters as \u escapes, which a script reads back as the same text: < and >, so a </script> in your data can't end the script early; &; and the line separators U+2028 and U+2029, which JSON allows in a string but JavaScript before ES2019 didn't. Next.js escapes the same five, with htmlEscapeJsonString (htmlescape.ts, lines 4 to 26).

Where a script goes matters: in the middle of a tag, it would break the page. React writes its HTML in bursts, and nothing else runs during a burst. So the pieces go in at two places only. Line 36 writes them right after a burst of React's HTML: queueMicrotask waits until the burst is over. Line 29 writes a piece that arrives while React is waiting. Line 34 holds back React's closing </body></html>, and lines 22 to 26 write it once both streams have ended, after the last piece, so every script is inside <body>.

The recording kept the response for /teas/mint exactly as it arrived. React's first write, the shell, one tag per line:

The shell of /teas/mint, one tag per line (recorded)HTML
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="modulepreload" fetchPriority="low" href="/browser.js"/>
<title>Tea shop</title>
</head>
<body>
<main>
<a href="/">All teas</a>
<h1>Mint tea<!-- -->: $<!-- -->3</h1>
<p>
<button disabled="">Add to cart</button> <!-- -->In cart: <!-- -->0</p>
<!--$?-->
<template id="B:0">
</template>
<p>Loading reviews…</p>
<!--/$-->
</main>

Line 4 asks the browser to start loading browser.js early. Line 12 is AddToCart's HTML, with disabled, as useState(false) made it. Lines 13 to 17 are the Suspense boundary while it waits: React's marker comments, an empty <template> with the id B:0, and the fallback, Loading reviews…. The rest arrived after it, one part per line here, each cut short in the middle:

The rest of the response, one part per line (recorded, cut short)HTML
<script>requestAnimationFrame(function(){$RT=performance.now()});</script>
<script type="module" src="/browser.js" id="_R_" async=""></script>
<script>(self.__tiny ||= []).push("1:\"app/add-to-cart.jsx\"\n2:…:\"$L4\"}]]}]}]]}]\n")</script>
<script>(self.__tiny ||= []).push("4:[\"$\",\"ul\",null,{\"child…ter dinner.\"}]]}]\n")</script>
<div hidden id="S:0"><ul><li>Cool and fresh.</li><li>Lovely after dinner.</li></ul></div>
<script>$RB=[];$RV=function(a){$RT=performance.now();for(var b=0…d(b)};$RC("B:0","S:0")</script>
</body></html>
  1. Lines 1 and 2 end React's first write: a small script of React's, and the <script type="module"> from bootstrapModules.
  2. Line 3 is the first piece of the payload, rows 1, 2, 3 and 0, written right after React's write.
  3. Line 4 is the second piece, row 4: getReviews had finished.
  4. Line 5 is React's HTML for the reviews, hidden in <div hidden id="S:0">. Line 6 is React's script that ends with $RC("B:0","S:0"): it moves the reviews into the place of the fallback.
  5. Line 7 is the closing tags, written last by line 25 of server.mjs.

Join the texts of the two payload scripts and you get, byte for byte, the payload the router got for /teas/mint in the Network tab.

One HTML response for /teas/mint, top to bottom: React's shell with Loading reviews; a script with payload rows 1, 2, 3 and 0, from sendHtml; a script with row 4, the reviews, from sendHtml; React's hidden div S:0 with the reviews; React's script that swaps it in; and the closing tags, written last by sendHtml.GET /teas/mintshell: …, Loading reviews…Reactscript: rows 1, 2, 3, 0sendHtmlscript: row 4, the reviewssendHtml<div hidden id="S:0">React$RC("B:0","S:0")React</body></html>sendHtml
The order the parts arrived in, top to bottom. React writes the HTML; sendHtml puts each piece of the payload (shaded) after React's writes, and the closing tags last.

Now take the Suspense boundary out of the tea page, and see what the HTML holds. Before you switch, guess where the reviews will be.

Playground: what does Suspense change in the response?
The tea shop on the tiny framework, with this version of app/teas/[name]/page.jsxNext.js
app/teas/[name]/framework/
app/teas/[name]/page.jsx
import { Suspense } from "react";import AddToCart from "../../add-to-cart.jsx";import { getReviews, teas } from "../../teas.js"; async function Reviews({ id }) {  const reviews = await getReviews(id);  return (    <ul>      {reviews.map((review) => (        <li key={review}>{review}</li>      ))}    </ul>  );} export default function TeaPage({ params }) {  const tea = teas[params.name];  if (!tea) return <p>No tea called {params.name}.</p>;  return (    <main>      <a href="/">All teas</a>      <h1>        {tea.name}: ${tea.price}      </h1>      <AddToCart />      <Suspense fallback={<p>Loading reviews…</p>}>        <Reviews id={params.name} />      </Suspense>    </main>  );}
Recorded from its own server · React 19.3.0

GET /teas/mint200

Response headers
content-type text/html; charset=utf-8
vary rsc

The HTML that arrived, one element per line, with the code of its 5 scripts left out

<!DOCTYPE html>
<html lang="en">
  <head>
    <link rel="modulepreload" fetchpriority="low" href="/browser.js">
    <title>Tea shop</title>
  </head>
  <body>
    <main>
      <a href="/">All teas</a>
      <h1>Mint tea<!-- -->: $<!-- -->3</h1>
      <p><button disabled="">Add to cart</button> <!-- -->In cart: <!-- -->0</p>
      <!--$?-->
      <template id="B:0"></template>
      <p>Loading reviews…</p>
      <!--/$-->
    </main>
    <script>…</script>
    <script type="module" src="/browser.js" id="_R_" async=""></script>
    <script>…</script>
    <script>…</script>
    <div hidden="" id="S:0">
      <ul>
        <li>Cool and fresh.</li>
        <li>Lovely after dinner.</li>
      </ul>
    </div>
    <script>…</script>
  </body>
</html>
Try it yourself
With Suspense

React sends the shell with Loading reviews… at once, then the reviews in a hidden part, with a script that swaps them in.

Both versions were built with node framework/build.mjs and run with node framework/server.mjs; the Network tab shows the HTML of /teas/mint as it arrived, without the scripts' code. Only app/teas/[name]/page.jsx changes. Reset goes back to the first version.

Without Suspense, nothing marks the reviews as a part that may wait, so React can't send a shell without them. The first HTML holds the reviews, with no fallback, no hidden <div> and no $RC script: the whole page waits for its slowest part. With Suspense, the page arrives in two steps. Streaming with Suspense shows the same choice in Next.js.

Next.js writes its payload the same way, into self.__next_f.push scripts, in stream-ops.node.ts, lines 946 to 1033. It escapes the JSON for HTML too, and sends a chunk that isn't valid UTF-8 text as base64 instead of decoding it with stream: true. It also does what yours can't: for a page with a static shell, the first bytes come from a file next build wrote, and only the parts that need the request are rendered (The RSC payload, Prerendering and Partial Prerendering).

Hydrating from the inlined payload

browser.js runs in the browser. Its first job is hydration: React reconciles the Client Components with the HTML already on screen, reusing the DOM it finds, and attaches their event handlers (Server rendering and hydration). For that, React needs the tree the server rendered, and the payload in the HTML has it.

framework/browser.jsxJSX
import "../.tiny/client-modules.js";import { startTransition, use, useEffect, useState } from "react";import { hydrateRoot } from "react-dom/client";import { createFromFetch, createFromReadableStream } from "react-server-dom-webpack/client"; // The payload the HTML carried, as a stream: the pieces already pushed, then the rest.const encoder = new TextEncoder();const inlined = new ReadableStream({  start(controller) {    const pieces = (self.__tiny ||= []);    for (const text of pieces) controller.enqueue(encoder.encode(text));    pieces.push = (text) => controller.enqueue(encoder.encode(text));    if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", () => controller.close());    else controller.close();  },});const firstPage = createFromReadableStream(inlined); function Router() {  const [page, setPage] = useState(firstPage);  useEffect(() => {    // A navigation fetches the new page's payload, and React renders it in place.    const navigate = (pathname) => {      const next = createFromFetch(fetch(pathname, { headers: { rsc: "1" } }));      startTransition(() => setPage(next));    };    const onClick = (event) => {      const link = event.target.closest("a");      if (!link || link.origin !== location.origin || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey) return;      event.preventDefault();      history.pushState(null, "", link.href);      navigate(link.pathname);    };    const onBack = () => navigate(location.pathname);    document.addEventListener("click", onClick);    addEventListener("popstate", onBack);    return () => {      document.removeEventListener("click", onClick);      removeEventListener("popstate", onBack);    };  }, []);  return use(page);} hydrateRoot(document, <Router />); 
  1. Line 1 comes first on purpose: it defines __webpack_require__ before React's Flight client loads, because that client reads it as soon as it runs.
  2. Lines 8 to 16 turn the scripts into a stream. The scripts can run before browser.js or after it: browser.js runs as soon as it has loaded, while the page may still be arriving. So line 10 takes the list the scripts made, or makes it. Line 11 feeds in the pieces already there, and line 12 replaces the list's push, so each later script feeds its piece straight into the stream.
  3. Lines 13 and 14 close the stream when the HTML has finished arriving: DOMContentLoaded fires then, and if it already has, the stream closes at once.
  4. Line 17 starts reading the payload with createFromReadableStream, React's Flight client for browsers.
  5. Line 45 calls hydrateRoot on the whole document, with Router, whose line 42 gives React the page that promise resolves to.

React then walks the HTML. What the Server Components rendered is reused as it is; only AddToCart runs, its Effect sets hydrated, and the button turns on. The recording's Steps tab shows it in real Chrome:

Hydrating /teas/mint, then a clickNext.js
app/teas/[name]/framework/
app/add-to-cart.jsx
"use client";import { useEffect, useState } from "react"; export default function AddToCart() {  const [count, setCount] = useState(0);  const [hydrated, setHydrated] = useState(false);  useEffect(() => setHydrated(true), []);  return (    <p>      <button disabled={!hydrated} onClick={() => setCount(count + 1)}>        Add to cart      </button>{" "}      In cart: {count}    </p>  );}
Recorded from its own server · React 19.3.0

Each step was taken in real Chrome against node framework/server.mjs. Pick one to see the page after it.

localhost:3000/teas/mintTea shop
All teas
Mint tea: $3

Add to cart In cart: 1

  • Cool and fresh.
  • Lovely after dinner.
Focus
the button “Add to cart”
Scrolled
0 px down

No requests during this step: the page already had what it needed

Step 1 opens /teas/mint and waits until Lovely after dinner. shows. Step 2 clicks Add to cart. The recorder only clicks a button that's enabled, so the click waited for hydration; afterwards the page says In cart: 1. The step made no request: the count is state in the browser.

The Server Components never hydrate, and their code isn't in browser.js. browser.jsx imports .tiny/client-modules.js, which imports only the files that start with "use client"; nothing in the browser bundle imports TeaPage, Reviews or app/teas.js. What reaches the browser is what they rendered: the text and props in the payload, like Mint tea and the reviews, in the HTML and again in the scripts.

Next.js's app-index.tsx does the same with self.__next_f: lines 158 to 173 close the stream on DOMContentLoaded, lines 176 to 190 feed the pieces already there and replace push, and line 419 calls hydrateRoot on the document. The RSC payload follows those lines with a real page.

What the real thing adds

Your framework does each job in a few lines. Next.js does each one for apps with thousands of pages, and many more jobs besides. Each row is one part: what it is in your framework, what Next.js 16.4.3 does in its place, and where the course teaches it. Read down the table as a map of the course.

Your framework and Next.js, part by part
Your frameworkNext.jsWhere the course teaches it
build.mjs lists every page.jsx in app.next build collects pages, layouts, route handlers and every special file, compiles them, and writes manifests into .next.Inside next build, File-system routing
match() tries the routes in file order.The router server checks headers, redirects, proxy.ts, rewrites and files first, then tries routes static first, then [param], then catch-alls.From next dev to a response, Dynamic routes
One <html> tree around the page, and a plain params object.A tree per segment: layouts, templates, loading, error and not-found files, with params as a promise. Layouts keep their state across navigations.How a route becomes a React tree, Layouts and pages
An esbuild plugin turns a client file's default export into a client reference.SWC and Turbopack build three copies of every client file in one module graph, for every export, and write the client reference manifest.'use client' and 'use server' under the hood, SWC and Turbopack
One browser.js holds every client component.Each route loads only the chunks its Client Components need, and the HTML lists them.Shrinking the JavaScript bundle
Every page is rendered again for every request.Pages are prerendered during the build when they can be. With Partial Prerendering, a static shell is sent at once and the request-time parts are resumed and streamed.Partial Prerendering, Prerendering and Partial Prerendering
No cache: every request calls the stand-in database.'use cache' stores a function's result under a key, with cacheLife and cacheTag, in cache handlers.Cache Components, Caching under the hood
The router fetches a whole page's payload after each click.The client router sends its state tree, keeps segments in the segment cache, prefetches links on screen, and only fetches what changed.The client router, Linking and navigating
No way to send a form to the server.Server Actions: a 'use server' function gets an action id, and one POST returns the result and the new UI.Server Actions, Server Actions under the hood
Nothing runs before the page renders.proxy.ts runs before routing, to redirect, rewrite or set headers.proxy.ts, proxy.ts and runtimes under the hood
A page that throws leaves the response unfinished.error.js boundaries, notFound(), and errors sent to the browser as a digest only.Error handling, Not found, forbidden and unauthorized
Images and fonts are plain files, if you add a route for them.next/image resizes images on request and caches them; next/font downloads fonts during the build and computes fallback metrics.Optimizing images, Optimizing fonts, Images and fonts under the hood
node framework/server.mjs on one machine.next start, the standalone output, static exports, and adapters that turn a build into what a platform runs.Deploying Next.js, Adapters and other platforms

A few rows change how you write an app, not only how fast it runs:

  • Caching and prerendering decide when your code runs. In your framework, TeaPage runs for every request. In Next.js with Cache Components, a page can be prerendered during the build, and a function with 'use cache' can return a stored result, so your code may run once for many visitors (Cache Components).
  • Server Actions add a way back. Your framework only sends pages. Next.js's 'use server' functions let the browser call the server with an action id, and get the result and the new UI in one response (Server Actions under the hood).
  • Errors need a plan. sendHtml gives React only onShellReady. If a page throws outside a Suspense boundary, React calls onShellError instead, which the framework never passed, so nothing writes or ends the response. The fix is in the same calls: give react-dom/server's renderToPipeableStream an onShellError that sends a 500 page and ends the response, and an onError that logs; give the Flight server's renderToPipeableStream an onError too; and listen for error on the piped streams, so a broken connection can't stop the server. Next.js renders the nearest error.js boundary or its global error page, and in production sends the browser only the error's digest (Error handling).

Your framework as a map of Next.js

Next.js's files for each job are big, but each has a counterpart you've now written. When you open Next.js's source (A tour of the Next.js repository), start from the part of your framework that does the same job. Sort each piece of Next.js 16.4.3 into the part of your framework it matches:

Which part of your framework does this do?
  • normalizeAppPath in app-paths.ts
  • sorted-routes.ts
  • create-component-tree.tsx
  • react-server-dom-turbopack/server, loaded in the vendored entrypoints
  • createClientModuleProxy, re-exported by module-proxy.ts
  • clientModules in the client reference manifest
  • use-flight-response.tsx, with ssrModuleMapping
  • createNodeInlinedDataStream in stream-ops.node.ts
  • app-index.tsx replacing self.__next_f.push
  • link.tsx calling e.preventDefault()
Try it yourself
0 of 10 correct

Sort each piece of Next.js 16.4.3 into the part of your framework that does the same job.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Then read a real app's traffic the same way. In any Next.js app, open DevTools' Network tab and reload: the HTML ends with self.__next_f.push scripts, your sendHtml. Click a link: a fetch with the header rsc: 1 comes back as text/x-component, your navigate.

Common mix-ups

  • "Next.js is mostly a router." No. Routing is about twenty lines of your framework. Most of Next.js is about when code runs and what's sent: prerendering, caching, streaming, chunks for each route, and prefetching.
  • "The RSC payload is HTML." No. It's rows that describe React elements and references. ssr.jsx turns it into HTML, and browser.jsx gives it to React to hydrate and to navigate.
  • "A Client Component skips the server." No. ssr.cjs renders AddToCart to HTML on the server: the recorded shell has its disabled button. Only its clicks need the browser.
  • "Server Components hydrate too." No. Their code isn't in browser.js. React reuses their HTML and reads what they rendered from the payload.
  • "react-server-dom-webpack needs webpack." No. It needs webpack's function names: __webpack_require__ to load a module, and __webpack_chunk_load__ only when a row lists chunks. Your framework defines the first and lists no chunks.
  • "Streaming needs a second request." No. The shell, both payload pieces and the reviews' HTML came in one response, in the order the recording kept.
  • "Next.js works exactly like this framework." No. The jobs and React's packages are the same, but Next.js's code is different and does much more, and its internals change between versions.

Practice exercises

These exercises are about the tiny framework, which can't run inside this page. Answer first, then use the hints.

Exercise 1 · Warm-upPredict the route table

The shop gets an About page in app/about/page.jsx, which imports app/about/team.jsx. Which new path does node framework/build.mjs list under Routes?

Starter codeText
app/
  about/
    page.jsx
    team.jsx
  add-to-cart.jsx
  page.jsx
  teas/
    [name]/
      page.jsx
  teas.js

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upFind the client reference

    These are the first rows of the recorded payload of /teas/mint. Which row is the client reference for AddToCart?

    Starter codeText
    1:"app/add-to-cart.jsx"
    2:I["$1",[],"default"]
    3:"$Sreact.suspense"

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeAdd a root layout

      Write app/layout.jsx as below, and change the framework so it uses this layout instead of its own <html> tree. What text comes first in the body of every page?

      Starter codeJSX
      export default function Layout({ children }) {
        return (
          <html lang="en">
            <head>
              <title>Tea shop</title>
            </head>
            <body>
              <header>Tiny Tea Co.</header>
              {children}
            </body>
          </html>
        );
      }
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeAdd a loading file

        The tea page now waits for its tea with await getTea(params.name). Put this file in app/teas/[name]/loading.jsx, and change the framework so it shows it while the page waits, as Next.js does with loading.js. Which text is in the shell of /teas/mint now?

        Starter codeJSX
        export default function Loading() {
          return <p>Loading tea…</p>;
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeAdd a not-found page

          Put this file in app/not-found.jsx, and make the framework show it for every path no route matches. Which status does /cart answer with?

          Starter codeJSX
          export default function NotFound() {
            return (
              <main>
                <h1>Not on the menu</h1>
                <a href="/">See all teas</a>
              </main>
            );
          }
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeStatic before dynamic

            The shop adds app/teas/new/page.jsx, beside the [name] folder. What does /teas/new show with the framework as written? Then fix the framework so the new page wins.

            Starter codeJSX
            export default function NewTeas() {
              return (
                <main>
                  <h1>New this week</h1>
                  <p>Lemon tea is back.</p>
                </main>
              );
            }
            

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask which part of the framework does the work: the build, the server, or the browser.

              The Build your own Next.js quiz · 7 questionsScore: first tries count
              1. Question 1 of 7In the recorded payload of /teas/mint, what is row 2?

                Read the code, then predictText
                1:"app/add-to-cart.jsx"
                2:I["$1",[],"default"]

                Choose an answer to see the explanation.

              2. Question 2 of 7Why does build.mjs bundle framework/rsc.jsx with conditions: ["react-server"]?

                Choose an answer to see the explanation.

              3. Question 3 of 7With both app/teas/[name]/page.jsx and app/teas/new/page.jsx, what does the tiny framework, as first written, show for /teas/new?

                Read the code, then predictPop out in the code editor (opens in a new tab)JSX
                export default function NewTeas() {
                  return (
                    <main>
                      <h1>New this week</h1>
                      <p>Lemon tea is back.</p>
                    </main>
                  );
                }
                

                Choose an answer to see the explanation.

              4. Question 4 of 7After hydration, what does a click on Green tea fetch?

                Choose an answer to see the explanation.

              5. Question 5 of 7In the recorded HTML response for /teas/mint, which of these arrives first?

                Choose an answer to see the explanation.

              6. Question 6 of 7Which of the shop's code ends up in browser.js?

                Choose an answer to see the explanation.

              7. Question 7 of 7Which of these does Next.js's router do on a navigation that the tiny router doesn't?

                Choose an answer to see the explanation.

              Key takeaways

              • A React framework is a build, a server and a browser part around React. Yours is about two hundred lines, and each part has a counterpart in Next.js.
              • Routes come from folders during the build: each page.jsx becomes a route, written into code the server imports, and match() turns a part in brackets into a param.
              • Server Components render to an RSC payload with react-server-dom-webpack/server, in a bundle built with the react-server condition. A "use client" file is a client reference there, and the payload carries its id, not its code.
              • The HTML is rendered from the payload, and the payload travels in the HTML, in scripts put between React's writes. Suspense lets the shell go first.
              • The browser hydrates from the payload in the HTML, and navigates by fetching payloads with rsc: 1 and rendering them in a transition.
              • Next.js adds what makes this fast and safe for big apps: prerendering and caching, chunks for each route, a router with a segment cache and prefetching, Server Actions, proxy.ts, error boundaries, and deployment outputs.

              Remember the one-liner.
              React renders; a framework finds the routes, splits the code, renders Server Components once to a payload, consumes it to make the HTML, and inlines the same payload for React.

              Coming next: what you build. This is the last lesson of the Next.js course. The Next.js learning path lists every lesson, to review any stage, and Project: a full-stack app puts the whole course into one app. Or keep growing this framework: give it layouts that keep their state, a cache for getReviews, or prefetching on hover, and compare each with the lesson that shows how Next.js does it.

              CompleteFrontend Clear concepts. Working examples.