Next.js NotesRohit’s interview study guide
Chapter 03

Server and Client Components

What runs on the server, what ships to the browser, and the rules for the boundary between them, with the real build errors and the real RSC payload.

7 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Server Components by default

Added

In the App Router, every component is a Server Component unless something opts it into the client. A Server Component runs only on the server, at build time or per request. It can be async, read a database, use Node.js APIs and secrets, and import big libraries, and none of that code is sent to the browser. What the browser gets is its output: the rendered elements, as part of the RSC payload, plus the HTML made from it. Client Components, the ones in or below a 'use client' file, are the components you know from plain React: they're rendered to HTML on the server too, and then their code ships to the browser and hydrates.

A mental model: think of what the browser receives for each kind of component. For a Server Component it gets a printed page: finished output, no recipe. For a Client Component it gets a photo and a flat-pack kit: the server-rendered HTML (the photo, so the user sees something at once) and the JavaScript to assemble the real, working thing (hydration). Printed pages are cheap to send and can't change by themselves; kits cost download and assembly time but can react to the user. A good App Router page is mostly printed pages with a few small kits in it.

The table that settles most "where does this run" questions:

Renders on the serverRuns in the browserIts code is in the client bundle
Server ComponentYes (build time or per request)NoNo
Client ComponentYes, to HTML on the first loadYes (hydration, then every re-render)Yes

"Client" doesn't mean "client-only": a Client Component also renders on the server. "Server" does mean "server-only": a Server Component's code never reaches the browser. This app has an async Server Component that reads "the database" and a small Client Component inside it:

app/product-list.tsx
import { getProducts } from "@/lib/products";
import { formatPrice } from "@/lib/format";
import { LikeButton } from "./like-button";

export async function ProductList() {
  const products = await getProducts();
  console.log(`ProductList rendered, window is ${typeof window}`);

  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>
          {p.name}: {formatPrice(p.priceCents)} <LikeButton initialLikes={p.likes} />
        </li>
      ))}
      <li>Rendered with Node.js {process.version}</li>
    </ul>
  );
}
What’s happening
  1. page.tsx, product-list.tsx, lib/products.ts and lib/format.ts have no directive, so they're Server Components and server modules by default. like-button.tsx starts with "use client", so LikeButton is the one Client Component.
  2. ProductList is an async function component: it awaits getProducts(), which uses Node's node:timers/promises to stand in for a 300 ms database query. Only Server Components can be async. It also reads process.version, a Node.js value that doesn't exist in a browser.
  3. The await is uncached I/O, so with Cache Components (on in new Next.js 16 apps) the list can't be part of the build-time HTML. The <Suspense> around it lets Next.js prerender everything else (the <h1> and the "Loading products…" fallback) as a static shell, and stream the list per request. That's why the route below is ◐, Partial Prerender (chapter 05 covers it; caching getProducts with 'use cache' would make it fully static).
  4. For each product, ProductList renders a <LikeButton initialLikes={…} />. On the server this doesn't run LikeButton's code inside the Server Component render; it records a reference to a client module plus the props { initialLikes: 12 }. Only initialLikes crosses to the browser: costCents is never rendered or passed, so it never leaves the server.
  5. On the first load, Next.js also renders LikeButton to HTML on the server (so the button shows "♥ 12" before any JavaScript runs), then the browser downloads like-button.tsx's code, runs it again and attaches onClick: hydration. Clicking updates its state in the browser; nothing goes back to the server.
  6. What this demonstrates: the data access, the formatting code and the price data stayed on the server. The only component code the browser receives is the button.
AdvancedShop app: data, page, button, route table

The rest of the shop app

The data module, the price formatter, the page with its Suspense boundary, the Client Component, and the route table the build printed:

lib/products.ts
import { setTimeout as sleep } from "node:timers/promises";

export type Product = { id: string; name: string; priceCents: number; costCents: number; likes: number };

const rows: Product[] = [
  { id: "p1", name: "Trail running shoes", priceCents: 14999, costCents: 6200, likes: 12 },
  { id: "p2", name: "Merino base layer", priceCents: 8999, costCents: 3100, likes: 7 },
];

export async function getProducts(): Promise<Product[]> {
  await sleep(300); // stands in for a database query
  return rows;
}
lib/format.ts
const money = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" });

export function formatPrice(cents: number) {
  return money.format(cents / 100);
}
app/page.tsx
import { Suspense } from "react";
import { ProductList } from "./product-list";

export default function Page() {
  return (
    <main>
      <h1>Shop</h1>
      <Suspense fallback={<p>Loading products…</p>}>
        <ProductList />
      </Suspense>
    </main>
  );
}
app/like-button.tsx
"use client";

import { useState } from "react";

export function LikeButton({ initialLikes }: { initialLikes: number }) {
  const [likes, setLikes] = useState(initialLikes);
  console.log(`LikeButton rendered, window is ${typeof window}`);

  return <button onClick={() => setLikes(likes + 1)}>♥ {likes}</button>;
}
next build
Route (app)
┌ ◐ /
└ ○ /_not-found


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
AdvancedWhere each log line appears

I ran it with next build && next start -p 4503, requested / once with curl, and opened it once in headless Chrome. The server terminal printed, for each request:

Output
ProductList rendered, window is undefined
LikeButton rendered, window is undefined
LikeButton rendered, window is undefined
ProductList rendered, window is undefined

and Chrome's console, for the page load:

Output
log: LikeButton rendered, window is object
log: LikeButton rendered, window is object
What’s happening
  1. LikeButton logs in both places: twice in the terminal (one per product, while Next.js renders the HTML) and twice in the browser (hydration). That's the "Client Components render on the server too" row of the table, observed. window is undefined in the first and an object in the second, which is why browser-only code in a Client Component belongs in effects and event handlers, not in the render body.
  2. ProductList logs only in the terminal. Its code isn't in the browser, so it can't log there.
  3. ProductList logged twice per request in this production run (one curl), while LikeButton's server log appeared once per product. The second ProductList render is Partial Prefetching (partialPrefetching: true, the template's setting, added in Next.js 16.3): after the normal request render, Next.js 16.4.3 runs an RSC-only "runtime prerender" of the page and embeds its result in the payload so the client can cache it (chapter 04 has the details). I confirmed it by logging React's work-unit type inside ProductList (request, then prerender-runtime) and by rebuilding with partialPrefetching: false: one ProductList log per request. The extra pass renders no HTML, which is why LikeButton's SSR log doesn't double. The lesson: render must be free of side effects, because you don't control how many times Next.js renders a Server Component.
  4. After hydration I clicked the first button: the browser logged one more LikeButton rendered, the text became "♥ 13", and the terminal logged nothing. Client state changes re-render Client Components in the browser only.
AdvancedWhat reached the browser: bundle and payload

What reached the browser

The build output is where the "zero JavaScript" claim can be checked. I searched every file in .next/static (everything the browser can download) for strings that only appear in each module:

Output
$ for s in costCents "Trail running" "en-CA" "ProductList rendered" "LikeButton rendered" "Rendered with Node"; do …grep -rl "$s" .next/static | wc -l…; done
costCents              static JS files: 0
Trail running          static JS files: 0
en-CA                  static JS files: 0
ProductList rendered   static JS files: 0
LikeButton rendered    static JS files: 1
Rendered with Node     static JS files: 0

The product names do reach the browser, as data inside the HTML response. Next to the <li> elements, the HTML carries the same tree in a script tag, and this row (from curl -s localhost:4503/, unescaped for reading) is ProductList's output:

Output
1a:"/_next/static/chunks/1vc0wyi3-23y0.js"
1b:I[15226,["$2","$1a"],"LikeButton"]
8:["$","ul",null,{"children":[[["$","li","p1",{"children":["Trail running shoes",": ","$$149.99"," ",["$","$L1b",null,{"initialLikes":12}]]}],["$","li","p2",{"children":["Merino base layer",": ","$$89.99"," ",["$","$L1b",null,{"initialLikes":7}]]}]],["$","li",null,{"children":["Rendered with Node.js ","v26.7.0"]}]]}]
What’s happening
  1. Row 1b is an I (import) row: "the client module with id 15226, in these chunks, export LikeButton". That's the client reference. Row 1a is the chunk's URL.
  2. Row 8 is ProductList's rendered output: plain elements as ["$", type, key, props] arrays. The <li> keys p1 and p2 came from key={p.id}.
  3. Inside each <li>, ["$","$L1b",null,{"initialLikes":12}] is the LikeButton element: type "the thing row 1b points to", props { initialLikes: 12 }. That's all the browser learns about the server's product row.
  4. "$$149.99" is the formatted price. Strings that start with $ are escaped as $$, because $ marks references in this format. formatPrice ran on the server; only its result is here.
  5. There's no costCents and no code for ProductList, getProducts or formatPrice. How the RSC payload works reads the rest of the format.
AdvancedWhat a Server Component can't use

A Server Component runs once per render, on the server, with no browser and no re-render loop, so it can't hold state, run effects or handle events. Next.js enforces this at build time: in a Server Component, react resolves to React's server build, which doesn't export the client hooks. I listed the exports of the server build bundled with Next.js 16.4.3 (next/dist/compiled/react/cjs/react.react-server.production.js):

Available in Server ComponentsNot available (Client Components only)
use, cache, cacheSignal, useId, useMemo, useCallback, useDebugValueuseState, useReducer, useEffect, useLayoutEffect, useRef, useContext, createContext
Suspense, Activity, ViewTransition, Fragment, memo, lazy, forwardRef, Children, cloneElementuseTransition, useActionState, useOptimistic, useSyncExternalStore, class components (Component)

useMemo and useCallback exist only so shared code can call them; on the server they just run the function. Using a client hook fails the build. This page:

app/page.tsx
import { useState } from "react";

export default function Page() {
  const [count, setCount] = useState(0);
  return <p>{count}</p>;
}

fails next build with (paths shortened to the project, colours removed):

Output
Error: Turbopack build failed with 1 error:
./src/app/page.tsx:1:10
Error: You're importing a module that depends on `useState` into a React Server Component module. This API is only available in Client Components. To fix, mark the file (or its parent) with the `"use client"` directive.
    Learn more: https://nextjs.org/docs/app/api-reference/directives/use-client
> 1 | import { useState } from "react";
    |          ^^^^^^^^

It fails at the import, before anything renders: Turbopack knows which graph the module is in and has a list of client-only React APIs. I tried each hook in the right-hand column in its own page: useEffect, useRef, useReducer, useLayoutEffect, useTransition, useActionState, useOptimistic, useSyncExternalStore and createContext all failed with this same message. useContext was the exception: it got past the compiler and failed while prerendering, with TypeError: (0 , c.useContext) is not a function (Context, providers and third-party libraries has the details and the fix).

Event handlers fail one step later. This compiles, because onClick is just a prop in JSX:

app/page.tsx
export default function Page() {
  return <button onClick={() => console.log("hi")}>Say hi</button>;
}

but the page is static, so next build prerenders it, and React refuses to put a function into the output:

Output
Error occurred prerendering page "/". Read more: https://nextjs.org/docs/messages/prerender-error
Error: Event handlers cannot be passed to Client Component props.
  {onClick: function onClick, children: ...}
            ^^^^^^^^^^^^^^^^
If you need interactivity, consider converting part of this to a Client Component.
What’s happening
  1. The page renders on the server, and its output has to be serialised into the RSC payload. A <button> with an onClick function can't be: there's no way to send a function's code and closure to the browser as data.
  2. React's message says "Client Component props" even though <button> is a DOM element: from the RSC serialiser's point of view, any element that ends up in the browser is rendered by the client, and its props must cross the wire.
  3. Because the route is static, the error appears during next build. In a dynamic part of a page, the build passes and the same mistake fails per request. I moved the button into an async component that awaits connection() inside <Suspense>: the build printed ◐, curl got 200 OK (the static shell had already been sent), the terminal logged the same "Event handlers cannot be passed" error with a digest, and Chrome replaced the page with "This page couldn’t load … ERROR 4236530517", the digest. A passing build doesn't prove dynamic parts are safe.
  4. The fix is to move the button (and its handler) into a 'use client' component, or use built-in HTML behaviour that needs no JavaScript: a <form action={serverAction}>, a <details> element, a link.

Browser globals fail too: window, document and localStorage don't exist in a Server Component (the log above printed window is undefined), so touching them throws during the server render.

AdvancedWhen to use Server or Client Components

When to reach for each

  • Server Component (the default): fetching data, reading cookies and headers, using secrets, rendering Markdown or syntax highlighting with heavy libraries, layout and static content. Anything that doesn't need to respond to the user in the browser.
  • Client Component: state (useState, useReducer), effects, event handlers, browser APIs (localStorage, IntersectionObserver, geolocation), custom hooks that use those, and libraries built on them.
  • Neither needed: a lot of "interactivity" is built into HTML: a <form> posting to a Server Action, <details>/<summary>, <video controls>, links. Next.js's boundary guide makes the same point: a button or form doesn't need a Client Component when the browser provides the behaviour.
AdvancedBefore Server Components: the Pages Router
Which logs, where?

In the rsc-default app, a user opens / in a fresh tab, waits for the page, and clicks the "♥ 7" button once. Counting only the lines this app logs, what does the server terminal show for that visit, and what does the browser console show?

Show answer

Terminal: ProductList rendered twice (once with partialPrefetching: false) and LikeButton rendered, window is undefined twice. Browser: LikeButton rendered, window is object three times.

What’s happening
  1. The request reaches the server. The static shell is already built; the ProductList hole renders now: ProductList awaits the products and logs on the server, then logs again in Partial Prefetching's runtime prerender (see above).
  2. Next.js renders the HTML for the hole, which includes the two LikeButtons: two server logs with window is undefined.
  3. The browser parses the HTML (the buttons already show "♥ 12" and "♥ 7"), downloads LikeButton's chunk and hydrates: two browser logs with window is object. ProductList never logs in the browser because its code isn't there.
  4. The click calls setLikes(8) on the second button, which re-renders only that LikeButton, in the browser: one more browser log, and nothing on the server.

#The 'use client' boundary

Added

'use client' is the most misread line in Next.js. It doesn't mean "this component runs in the browser" (it also runs on the server), and it doesn't have to go on every interactive file. It marks a module as an entry point into the client module graph: this file, and everything it imports, directly or indirectly, is client code that must be bundled for the browser. Server modules can import it, but what they get back for each export is a client reference, a pointer the server can render and pass props to, not code it can run.

The mental model is two countries and one door. The server graph and the client graph are separate countries, each with its own compiled copy of any module both of them use. A 'use client' file is a door in the border: the server can hand things through it (rendered elements and serialisable props) and can say "put that component here", but it can't reach through and call a function on the other side. Once code is on the client side, everything it imports comes along with it. There's no door back except props: a client module can't import a Server Component and keep it on the server.

Server module graph runs only on the server Client module graph SSR on the server, then the browser 'use client' app/page.tsx lib/reading-time.ts comment-box.tsx (reference) components/comment-box.tsx char-count.tsx lib/use-draft.ts lib/reading-time.ts id + export Code crosses only through imports inside the client graph; the server holds a reference. A module both graphs import (reading-time.ts) is compiled into each.
The 'use client' file is where the server graph stops and the client graph starts.

This app has a server page with a client comment box. The comment box imports a component and a custom hook that have no directive, and a helper the page uses too:

components/comment-box.tsx
"use client";

import { CharCount } from "./char-count";
import { readingTime } from "@/lib/reading-time";
import { useDraft } from "@/lib/use-draft";

export function CommentBox() {
  const [text, setText] = useDraft("comment-draft");

  return (
    <form>
      <textarea aria-label="Comment" value={text} onChange={(e) => setText(e.target.value)} />
      <CharCount text={text} max={280} />
      <p>Your comment: {readingTime(text)}</p>
    </form>
  );
}
What’s happening
  1. Turbopack starts from app/page.tsx in the server graph (pages and layouts always start there). The page imports reading-time.ts, so that module joins the server graph, and calls it: 120 × 6 = 720 words, 720 / 200 rounds to "4 min read", computed at build time (the prerendered HTML says <p>4 min read</p>).
  2. The page also imports comment-box.tsx. That file starts with "use client", so the server graph stops there: instead of compiling the component into the server graph, Turbopack puts in a client reference for its CommentBox export.
  3. comment-box.tsx becomes an entry of the client graph, and its imports follow it: char-count.tsx, use-draft.ts and a second copy of reading-time.ts. None of them has a directive, and none needs one. useDraft can call useState and useEffect and touch localStorage (in the effect) because it's only ever reached from a client module.
  4. CharCount isn't "a Client Component file" by nature: it has no hooks and no directive. It's client code here only because a client module imports it. If a Server Component imported it, that copy would be a Server Component. The import chain decides, not the file.
  5. At runtime, the page's HTML has the article and a textarea already showing "280 characters left" and "Your comment: 1 min read" (from Math.max(1, …) on an empty draft). After hydration, useDraft's effect reads the saved draft, and typing re-renders only the comment box. In headless Chrome I typed "Hello from the client graph" (27 characters): the counter went 280 → 253, localStorage held the text, and after a reload the textarea showed it again.
  6. What this demonstrates: one directive, at the entry, put four modules in the browser bundle, and the page's own code and its 720-word text stayed out of it.
AdvancedComment app: helper, hook, page, route table

The rest of the comment app

The shared helper, the custom hook and the counter (none has a directive), the page, and the route table:

lib/reading-time.ts
export function readingTime(text: string) {
  const words = text.trim().split(/\s+/).filter(Boolean).length;
  return `${Math.max(1, Math.round(words / 200))} min read`;
}
lib/use-draft.ts
import { useEffect, useState } from "react";

// A custom hook with no directive: it's client code because only client modules import it.
export function useDraft(key: string) {
  const [text, setText] = useState("");

  useEffect(() => {
    setText(localStorage.getItem(key) ?? "");
  }, [key]);

  function update(value: string) {
    setText(value);
    localStorage.setItem(key, value);
  }

  return [text, update] as const;
}
components/char-count.tsx
export function CharCount({ text, max }: { text: string; max: number }) {
  const left = max - text.length;
  return <output style={{ color: left < 0 ? "crimson" : undefined }}>{left} characters left</output>;
}
app/page.tsx
import { CommentBox } from "@/components/comment-box";
import { readingTime } from "@/lib/reading-time";

const body = "Server Components render on the server. ".repeat(120);

export default function Page() {
  return (
    <article>
      <h1>Notes on the boundary</h1>
      <p>{readingTime(body)}</p>
      <p>{body}</p>
      <CommentBox />
    </article>
  );
}
next build
Route (app)
┌ ○ /
└ ○ /_not-found


○  (Static)  prerendered as static content
AdvancedProof: three compiled copies and a reference

After the build I searched the compiled output (.next/server holds server code, .next/static what the browser can download) for a string from each module. File names are shortened: Turbopack names chunks after the module path.

Output
$ grep -rl "min read" .next/server/chunks .next/static
.next/server/chunks/ssr/…components_comment-box_tsx_….js
.next/server/chunks/ssr/[root-of-the-server]__0cz-0n6btfkdd._.js
.next/static/chunks/2b2qtdj4z9d1n.js
$ grep -rl "comment-draft" .next/server/chunks .next/static
.next/server/chunks/ssr/…components_comment-box_tsx_….js
.next/static/chunks/2b2qtdj4z9d1n.js
$ grep -rl "Notes on the boundary" .next/server/chunks .next/static
.next/server/chunks/ssr/[root-of-the-server]__0cz-0n6btfkdd._.js

And in the server graph's chunk, this is what comment-box.tsx compiled to (an excerpt of the minified code):

Output
…{throw Error("Attempted to call CommentBox() from the server but CommentBox is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.")},"[project]/…/src/components/comment-box.tsx","CommentBox");…
…b.exports=a.r(32895).vendored["react-rsc"].React…
What’s happening
  1. reading-time.ts ("min read") is in three compiled files. [root-of-the-server]… is the server graph's chunk with the page (it's the only one with "Notes on the boundary"). …comment-box_tsx_….js is the client graph compiled for SSR, the copy that renders the comment box to HTML on the server. 2b2qtdj4z9d1n.js is the client graph for the browser.
  2. use-draft.ts ("comment-draft") is in the two client copies only. The server graph never imports it.
  3. The page's own text is only in the server graph's chunk. It reaches the browser as HTML and payload data, never as code.
  4. In the server graph, CommentBox is a stub that throws if called, labelled with the module path and the export name: that's the client reference. Rendering <CommentBox /> in a Server Component doesn't call it; React serialises it as a reference (the I rows from Server Components by default).
  5. The server graph also loads vendored["react-rsc"]: the React server build from the previous topic, which is why useState can't even be imported there. Next.js's own error traces name the three layers [Server Component], [Client Component SSR] and [Client Component Browser] (server-only, client-only and environment safety shows one).
AdvancedWhere to put the directive

Put 'use client' on the smallest component that needs the browser, not on the page or layout that contains it. Everything the marked file imports ships, so a directive at the top of a page ships the page's data-handling code too. To measure it, I built the same product page twice. Both parse their data with zod (npm install zod; 4.6.7 in the sandbox), a common choice for validating CMS data:

lib/product.ts
import { z } from "zod";

const Product = z.object({
  id: z.string(),
  name: z.string().min(1),
  priceCents: z.number().int().positive(),
  description: z.string(),
});

// Imagine this came from a CMS; the schema guards against bad data.
const raw = {
  id: "p1",
  name: "Trail running shoes",
  priceCents: 14999,
  description: "Light, grippy and made for wet trails.",
};

export function getProduct() {
  return Product.parse(raw);
}
components/add-to-cart.tsx
"use client";

import { useState } from "react";

export function AddToCart({ name }: { name: string }) {
  const [added, setAdded] = useState(false);
  return <button onClick={() => setAdded(true)}>{added ? `${name} added` : "Add to cart"}</button>;
}
app/leaf/page.tsx
import { getProduct } from "@/lib/product";
import { AddToCart } from "@/components/add-to-cart";

export default function Page() {
  const product = getProduct();
  return (
    <main>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCart name={product.name} />
    </main>
  );
}
app/top/page.tsx
"use client";

import { useState } from "react";
import { getProduct } from "@/lib/product";

export default function Page() {
  const product = getProduct();
  const [added, setAdded] = useState(false);
  return (
    <main>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <button onClick={() => setAdded(true)}>{added ? `${product.name} added` : "Add to cart"}</button>
    </main>
  );
}
next build
Route (app)
┌ ○ /_not-found
├ ○ /leaf
└ ○ /top


○  (Static)  prerendered as static content

I loaded each route in a fresh headless Chrome (empty cache) against next start and summed the JavaScript resources from performance.getEntriesByType("resource"):

Output
/leaf  6 files  transferKB 134.2  decodedKB 447.1
/top   6 files  transferKB 163.5  decodedKB 550.1
What’s happening
  1. Both pages look identical and both are ○ Static: the HTML is prerendered either way, because Client Components render to HTML at build time too. "Static" in the route table says nothing about how much JavaScript a page ships.
  2. In /leaf, getProduct() and zod run in the Server Component at build time. The browser gets the finished <h1> and <p>, plus AddToCart's code and its one prop, name.
  3. In /top, the directive makes the page a client entry, so lib/product.ts, zod, the raw data and the parse all join the client graph. The browser downloads them and parses the product again during hydration, to produce markup it already has.
  4. The difference is 103 KB of decoded JavaScript (30 KB over the wire, compressed): one 105,804-byte chunk containing zod, the only chunk in .next/static with zod's code. The baseline 447 KB is React and the Next.js router, which every App Router page loads.
  5. The general rule: anything above the directive in the tree stays on the server; anything below it, and everything it imports, ships. Push the directive down to the leaf that has the state.
AdvancedPractical rules for placing the directive

Practical rules

  • You need the directive only at entry points: files whose exports are rendered by Server Components. A component imported only by other client modules is already client code. Adding the directive to it anyway is harmless, but it says "the server may render me directly", so its props must then be serialisable (see the next topic).
  • Where to look in a codebase: search for 'use client' and "use client". Each hit is a root of a client subtree; everything it imports is in the bundle. A 'use client' in app/layout.tsx or a top-level page means most of the app is client-rendered React with SSR, and you've lost most of what Server Components give you.
  • Pages, layouts and special files (page, layout, template, not-found, loading) can be Client Components, but you give up async and server data access in them. error.tsx and global-error.tsx must be Client Components (chapter 11).
  • Not the same as 'use server': 'use server' marks Server Functions (actions) the client can call (chapter 07). Server Components need no directive.
Which modules ship?

A Server Component page imports Header (no directive) and SearchBox ('use client'). Header imports Logo (no directive). SearchBox imports useDebounce (no directive) and lib/format.ts (no directive), and Header imports lib/format.ts too. Which modules' code is in the browser bundle?

Show answer

SearchBox, useDebounce and lib/format.ts. Header and Logo are not.

What’s happening
  1. The page is in the server graph. Header and Logo are reached only through server imports, so they're Server Components: their output reaches the browser as payload data, their code doesn't.
  2. SearchBox has the directive, so it's a client entry: its code ships, and the server graph holds a reference to it.
  3. useDebounce is imported by SearchBox, so it joins the client graph without needing a directive.
  4. lib/format.ts is imported from both sides, so it's compiled into both graphs: the server graph's copy serves Header, the client graph's copy serves SearchBox (for SSR and the browser). Only the client graph's browser copy ships.
  5. If Header later needed a dropdown, the fix is a new 'use client' HeaderMenu leaf, not a directive on Header, which would pull Logo and Header's code into the bundle too.

#Passing data across the boundary

Added

When a Server Component renders a Client Component, the props don't travel as JavaScript references: the server and the browser don't share memory. React serialises the props into the RSC payload, and the browser rebuilds them. So the rule is simple to state: props from server to client must be serialisable. Primitives, plain objects and arrays, Date, Map, Set, BigInt, typed arrays, JSX and Promises can cross. Ordinary functions and class instances can't. The one kind of function that can cross is a Server Function ('use server'), and it crosses as a reference: an id the browser can call back with, not the code.

A mental model: crossing the boundary is posting a parcel, not handing something over in person. You can post copies of documents (data), a printed photo (rendered JSX), an IOU to be settled later (a Promise, which the server fills in when it resolves), and a phone number for calling the server back (a Server Action). You can't post a person: a function with its closure, or an object whose behaviour lives in its class, only means something in the memory it was created in.

What can cross, and how each value looked in the real payload of the app below (and of single-value test pages for the rest):

ValueCrosses?Encoded in the payload as
string, number, boolean, nullYesJSON (a string starting with $ is escaped as $$)
undefined, NaN, Infinity, -0Yes"$undefined", "$NaN", "$Infinity", "$-0"
DateYes"$D2026-10-25T09:30:00.000Z"
BigIntYes"$n12345678901234567890"
Map, SetYes"$Q…" / "$W…" pointing at a row with the entries
Typed arrays, ArrayBuffer, FormData, iterators, ReadableStreamYestheir own row types ($K for FormData, $i for an iterator)
Symbol.for("size")Yesan outlined row "$Ssize" (React's own "$Sreact.fragment" is the same thing); a local Symbol() fails
JSX (including Server Components' output)Yes["$", type, key, props] arrays
A PromiseYes"$@…", a row that arrives when it resolves
A Server Function ('use server')Yes, as a reference"$h…" pointing at {"id":"40b9…","bound":null}
Any other function, a class instance, a null-prototype objectNobuild or render error (below)

This page passes one of almost everything to a Client Component that reports what it received:

app/page.tsx
import { Suspense } from "react";
import { Inspect } from "@/components/inspect";
import { Reviews } from "@/components/reviews";
import { getReviews } from "@/lib/reviews";
import { saveNote } from "./actions";

export default function Page() {
  const reviews = getReviews(); // started here, not awaited

  return (
    <main>
      <Inspect
        when={new Date("2026-10-25T09:30:00Z")}
        tags={new Set(["trail", "waterproof"])}
        stock={new Map([["M", 4], ["L", 0]])}
        views={BigInt("12345678901234567890")}
        missing={undefined}
        ratio={NaN}
        badge={<strong>Staff pick</strong>}
        saveAction={saveNote}
      />
      <Suspense fallback={<p>Loading reviews…</p>}>
        <Reviews reviews={reviews} />
      </Suspense>
    </main>
  );
}
What’s happening
  1. The page calls getReviews() without awaiting it and passes the pending Promise to Reviews. The page itself isn't async, so it renders immediately; the 500 ms wait happens inside the Promise. Because that wait is uncached I/O, the Suspense boundary becomes a dynamic hole and the route is ◐ (below).
  2. Inspect gets a Date, a Set, a Map, a BigInt, undefined, NaN, a JSX element and a Server Action. Each is serialised into the payload with its own tag (the payload is quoted next), so the browser can rebuild the real type, not a JSON approximation: instanceof Date is true on the client.
  3. badge={<strong>Staff pick</strong>} is rendered output. JSX crossing the boundary is how Server Components get placed inside Client Components (Composition patterns).
  4. saveAction={saveNote} passes a function, which would normally fail. It works because saveNote is exported from a 'use server' file: the bundler replaced it with a server reference, and the browser gets a stub that POSTs to the server. The prop name ends in Action on purpose: the Next.js docs say the TypeScript plugin accepts function-typed props on a Client Component only when they're named action or end in Action, because a Server Function's type looks like any other function's. (I didn't run the editor plugin; next build's type check accepted it either way.)
  5. In Reviews, use(reviews) suspends until the Promise's value arrives, showing the fallback meanwhile. The data request started on the server during the render, so the browser doesn't fetch anything itself.
AdvancedProps app: action, Client Components, route table

The rest of the props app

The slow data function, the Server Action, the two Client Components, and the route table:

lib/reviews.ts
import { setTimeout as sleep } from "node:timers/promises";

export async function getReviews(): Promise<string[]> {
  await sleep(500); // a slow service
  return ["Great grip", "Runs small"];
}
app/actions.ts
"use server";

export async function saveNote(formData: FormData) {
  console.log("saveNote received", formData.get("note"));
}
components/inspect.tsx
"use client";

import type { ReactNode } from "react";

type Props = {
  when: Date;
  tags: Set<string>;
  stock: Map<string, number>;
  views: bigint;
  missing: undefined;
  ratio: number;
  badge: ReactNode;
  saveAction: (formData: FormData) => Promise<void>;
};

export function Inspect(p: Props) {
  const rows: [string, string][] = [
    ["when", `${p.when instanceof Date} ${p.when.toISOString()}`],
    ["tags", `${p.tags instanceof Set} ${[...p.tags].join(",")}`],
    ["stock", `${p.stock instanceof Map} ${p.stock.get("M")}`],
    ["views", `${typeof p.views} ${p.views + BigInt(1)}`],
    ["missing", `${"missing" in p} ${typeof p.missing}`],
    ["ratio", `${Number.isNaN(p.ratio)}`],
    ["saveAction", typeof p.saveAction],
  ];
  return (
    <section>
      <ul>
        {rows.map(([k, v]) => (
          <li key={k}>
            {k}: {v}
          </li>
        ))}
      </ul>
      {p.badge}
      <form action={p.saveAction}>
        <input name="note" aria-label="Note" />
        <button>Save</button>
      </form>
    </section>
  );
}
components/reviews.tsx
"use client";

import { use } from "react";

export function Reviews({ reviews }: { reviews: Promise<string[]> }) {
  const list = use(reviews);
  return <p>Reviews: {list.join(" · ")}</p>;
}
next build
Route (app)
┌ ◐ /
└ ○ /_not-found


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
AdvancedWhat the browser received

I fetched the payload directly with curl -s -H 'RSC: 1' 'localhost:4503/?_rsc' against next start (the ?_rsc matters, see How the RSC payload works). These are the rows about our props, in the order they arrived (the page row is cut after the props):

Output
9:I[98058,["$3","$8"],"Inspect"]
e:I[27,["$3","$8"],"Reviews"]
a:["trail","waterproof"]
b:[["M",4],["L",0]]
c:{"id":"40b911602e7a0106a4255f2a6c7dc89b3d90b78d79","bound":null}
7:[["children",{"s":"__PAGE__", … ["$","$L9",null,{"when":"$D2026-10-25T09:30:00.000Z","tags":"$Wa","stock":"$Qb","views":"$n12345678901234567890","missing":"$undefined","ratio":"$NaN","badge":["$","strong",null,{"children":"Staff pick"}],"saveAction":"$hc"}],["$","$d",null,{"fallback":["$","p",null,{"children":"Loading reviews…"}],"children":["$","$Le",null,{"reviews":"$@f"}]}] …
f:["Great grip","Runs small"]
What’s happening
  1. Rows 9 and e are the client references for Inspect and Reviews (module id, chunks, export name).
  2. Values that need more than a string are outlined into their own rows and pointed at: "tags":"$Wa" means "a Set built from row a", "stock":"$Qb" "a Map built from row b", "saveAction":"$hc" "a server reference described by row c".
  3. Row c is everything the browser knows about saveNote: an action id and bound: null. There's no code: calling it sends the id to the server. In a separate test, passing deleteItem.bind(null, 42) produced "bound":"$@c" with a row c:[42]: bound arguments travel in the payload as plain, readable data (closures captured by inline actions are encrypted instead; chapter 07 has the details).
  4. "reviews":"$@f" is a Promise placeholder for row f, which doesn't exist yet when the page row is sent. Row f is the last line of the response, written about 500 ms later when getReviews() resolved. One HTTP response carried the page and, later, the data.
  5. In Chrome, Inspect rendered when: true 2026-10-25T09:30:00.000Z, tags: true trail,waterproof, stock: true 4, views: bigint 12345678901234567891, ratio: true, saveAction: function, and then Reviews: Great grip · Runs small. The types were rebuilt, not approximated.
  6. I typed "hello action" into the form and clicked Save. Chrome sent POST / with the header next-action: 40b911602e7a0106a4255f2a6c7dc89b3d90b78d79 (row c's id), Accept: text/x-component and a multipart form body, and the server terminal printed saveNote received hello action. That's the "phone number" being called (chapter 07 covers what an action returns).
AdvancedWhat fails, and the exact errors

Each of these was a static page, so the error came from next build while prerendering (in a dynamic part of a page they fail per request instead):

Output
<SaveButton onSave={save} />  (a plain function, prop name starts with "on")
Error: Event handlers cannot be passed to Client Component props.
  {onSave: function onSave}
           ^^^^^^^^^^^^^^^

<Price cents={1999} format={(c) => `$${(c / 100).toFixed(2)}`} />
Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server". Or maybe you meant to call this function rather than return it.
  {cents: 1999, format: function format}
                        ^^^^^^^^^^^^^^^

<Price money={new Money(19.99, "CAD")} />  (class Money { constructor(public amount, public currency) {} })
Error: Only plain objects, and a few built-ins, can be passed to Client Components from Server Components. Classes or null prototypes are not supported.
  {money: {amount: 19.99, currency: "CAD"}}
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

<Show value={Symbol("local")} />
Error: Only global symbols received from Symbol.for(...) can be passed to Client Components. The symbol Symbol.for(local) cannot be found among global symbols.

<Show value={Object.create(null)} />
Error: Only plain objects, and a few built-ins, can be passed to Client Components from Server Components. Classes or null prototypes are not supported.
What’s happening
  1. Functions get two different messages depending on the prop name. React treats a prop whose name starts with on as an event handler ("Event handlers cannot be passed…"); any other function prop gets "Functions cannot be passed directly…" with the hint about "use server". Same rule, different wording: interviewers sometimes quote one of them.
  2. The class instance fails even though it holds only plain data. React checks the prototype, not the contents, because the client would get an object without the class's methods and instanceof would lie. Convert at the boundary: money={{ amount: m.amount, currency: m.currency }}.
  3. Symbols cross only if they're global (Symbol.for), because only then can the browser produce the same symbol from its name.
  4. The fix for a function is almost always to move the function to the client: pass the data (cents) and let the Client Component format it, or define the handler inside the Client Component. When the function must run on the server (save, delete), make it a Server Action.
AdvancedEverything you pass is public

Serialisation copies the whole value, not the parts the component reads. A typical leak: fetch the current user in a Server Component and pass the row to a Client Component that only shows the name.

lib/users.ts
export type User = { id: string; name: string; email: string; passwordHash: string; role: "admin" | "member" };

export async function getCurrentUser(): Promise<User> {
  return { id: "u1", name: "Rohit", email: "rohit@example.com", passwordHash: "$argon2id$v=19$m=65536$demo", role: "admin" };
}
components/avatar.tsx
"use client";

export function Avatar({ user }: { user: { name: string } }) {
  return <p>Signed in as {user.name}</p>;
}
app/page.tsx
import { Avatar } from "@/components/avatar";
import { getCurrentUser } from "@/lib/users";

export default async function Page() {
  const user = await getCurrentUser();
  return <Avatar user={user} />; // type-checks: User has a name
}

The page shows only "Signed in as Rohit", and TypeScript is happy, because a User is assignable to { name: string }. But curl -s localhost:4503/ returned this inside the HTML (unescaped for reading):

Output
7:["$","$L19",null,{"user":{"id":"u1","name":"Rohit","email":"rohit@example.com","passwordHash":"$$argon2id$v=19$m=65536$demo","role":"admin"}}]
What’s happening
  1. getCurrentUser() returns the full row, including passwordHash and role. In a real app this is a database call returning every column.
  2. <Avatar user={user} /> passes the object. React serialises every own property, because it can't know which ones Avatar will read.
  3. The page is static here (the "database" resolves immediately), so the whole row is baked into the prerendered HTML that every visitor and every CDN gets. In a dynamic page it would be in each response instead.
  4. TypeScript doesn't help: structural typing lets a wider object satisfy a narrower prop type, and there's no excess-property check for a variable.
  5. The fix: pass only what's needed (<Avatar name={user.name} />), or build a small DTO in a server-only data layer (chapter 04). React's taint APIs (experimental.taint in next.config.ts) can make this throw instead of leak, but the docs call them an extra layer, not a replacement for filtering.
AdvancedPromises across the boundary

Passing a Promise is the App Router's way to start data on the server and finish it on the client: the request starts during the server render, the Client Component reads it with use(), and the value streams into the same response, as row f showed. Rules that follow from the mechanics:

  • Create the Promise on the server and pass it down. A Client Component that creates a Promise in render and calls use on it creates a new one on every render; React warns "A component was suspended by an uncached promise" (the message in the async Client Component gotcha above).
  • Don't await it in the Server Component if you want streaming. await getReviews() in the page would hold the whole page's dynamic part for 500 ms; passing the Promise lets the rest render now.
  • Put a Suspense boundary above the reader. The fallback is what's in the HTML until the row arrives.
  • Rejections cross too, without the message. I passed a Promise that rejected with new Error("Reviews service is down (secret detail)"). The terminal logged the full error with digest: '1460651481'; the HTML contained the digest and not the words "secret detail"; and with no error.tsx, Chrome showed Next.js's "This page couldn’t load … ERROR 1460651481" screen. An error.tsx above the reader catches it instead (chapter 11).
Which props survive?

In a Next.js 16 app with Cache Components, a static page renders <Chart data={rows} onHover={(p) => console.log(p)} start={new Date()} formatter={new Intl.NumberFormat("en-CA")} title={<h2>Sales</h2>} />, where rows is an array of plain objects and Chart is a Client Component. What does next build report, and in what order as you fix each error?

Show answer

Three errors, one at a time, and not in source order: first new Date(), then formatter, then onHover. data and title are fine. I built exactly this page and fixed one prop per build.

What’s happening
  1. Build 1: Error: Route "/": Next.js encountered the unstable value `new Date()` while prerendering. It isn't a serialisation problem: a Date crosses fine. Next.js refuses to bake "now" into a prerendered page, and offers await connection() (render per request), "use cache", or computing it in a Client Component. With a fixed new Date("2026-10-01") the build moves on.
  2. Build 2: Only plain objects, and a few built-ins, can be passed to Client Components from Server Components., pointing at formatter: Intl.NumberFormat. An Intl.NumberFormat is a class instance without toJSON. React reported it before onHover even though onHover comes first in the JSX. Pass the locale string and create the formatter inside Chart.
  3. Build 3: Event handlers cannot be passed to Client Component props., pointing at onHover: function onHover. A plain function, named on…, so React calls it an event handler. Move the handler into Chart.
  4. Build 4 passed, ○ Static. data={rows} serialised as JSON (and is fully visible in the page, so keep it to the needed fields); title={<h2>Sales</h2>} is rendered JSX, the slot pattern of the next topic.
  5. The lesson for interviews: under Cache Components, "can this be prerendered?" is checked as well as "can this be serialised?", and the build stops at the first problem it meets, so fix and rebuild rather than assuming you've seen them all.

#Composition patterns

Added

The boundary rules seem to say "once you're in a Client Component, everything below is client". That's true for imports, not for rendering. A Client Component can't import a Server Component, but it can receive one as a prop: the server renders the Server Component first and passes its output in, usually as children. So the rendered tree can go server → client → server → client, while each module stays on its side. This is the main tool for keeping client code small: the Client Component owns the interactive shell (open/closed, active tab), and the content inside it stays on the server.

The Next.js boundary guide names the two roles that make this work. The owner of an element is the component whose code created the JSX; the parent is where the element ends up in the tree. A Server Component that writes <Drawer><CartSummary /></Drawer> owns both, so both are rendered by the server's rules: Drawer becomes a client reference and CartSummary runs on the server. Drawer is only the parent of CartSummary's output, like a picture frame: it decides where the picture hangs and whether it's visible, but it never paints it.

Page <h1> Drawer (client) Tabs (client) title: <span> CartSummary output Specs <p> Care <p> server output (no code ships) Client Component (code ships)
Every box was written by Page, a Server Component; the two client boxes only decide where their children appear.

This product page has a client Drawer (open/closed state) whose content is a Server Component reading server-only cart data, and client Tabs whose panels are server-rendered JSX passed as data:

app/page.tsx
import { Drawer } from "@/components/drawer";
import { CartSummary } from "@/components/cart-summary";
import { Tabs } from "@/components/tabs";
import { getCart } from "@/lib/cart";

export default function Page() {
  const count = getCart().reduce((n, l) => n + l.qty, 0);

  return (
    <main>
      <h1>Trail running shoes</h1>
      <Drawer title={<span>Cart ({count})</span>}>
        <CartSummary />
      </Drawer>
      <Tabs
        tabs={[
          { id: "specs", label: "Specs", content: <p>Weight 260 g, 6 mm drop.</p> },
          { id: "care", label: "Care", content: <p>Rinse with cold water and air dry.</p> },
        ]}
      />
    </main>
  );
}
What’s happening
  1. Page is the owner of everything here. It computes count = 1 + 3 = 4 from the cart and writes <Drawer title={<span>Cart (4)</span>}> with <CartSummary /> inside. Both title and children are just props holding elements.
  2. Because Page is a Server Component, <CartSummary /> is rendered on the server, during next build here (the build printed CartSummary rendered on the server once). CartSummary can import lib/cart.ts, which is guarded with server-only, because nothing on the client side ever imports it.
  3. Drawer receives children as already-rendered output: a <div> with the list and "Total: $197.96" (14999 + 3 × 1599 = 19796 cents). It doesn't know or care that a Server Component produced it. {open && children} only decides whether to show it.
  4. Tabs takes the same idea further: its tabs prop is an array of plain objects whose content fields are JSX. Rendered elements are data, so they can sit anywhere in a serialisable prop, not only in children. These are named slots.
  5. The route is ○ Static: the whole tree, server output and Client Components alike, was rendered at build time. At runtime the client code only toggles visibility.
AdvancedDrawer app: cart data, drawer, tabs

The rest of the drawer and tabs app

The server-only cart data, the two Client Components, the Server Component that goes in the drawer, and the route table:

lib/cart.ts
import "server-only";

export type Line = { sku: string; name: string; qty: number; priceCents: number };

export function getCart(): Line[] {
  return [
    { sku: "TR-1", name: "Trail running shoes", qty: 1, priceCents: 14999 },
    { sku: "SK-3", name: "Wool socks", qty: 3, priceCents: 1599 },
  ];
}
components/drawer.tsx
"use client";

import { useState, type ReactNode } from "react";

export function Drawer({ title, children }: { title: ReactNode; children: ReactNode }) {
  const [open, setOpen] = useState(false);

  return (
    <aside>
      <button aria-expanded={open} onClick={() => setOpen(!open)}>
        {title}
      </button>
      {open && children}
    </aside>
  );
}
components/cart-summary.tsx
import { getCart } from "@/lib/cart";

export function CartSummary() {
  const lines = getCart();
  const total = lines.reduce((sum, l) => sum + l.qty * l.priceCents, 0);
  console.log("CartSummary rendered on the server");

  return (
    <div>
      <ul>
        {lines.map((l) => (
          <li key={l.sku}>
            {l.qty} × {l.name}
          </li>
        ))}
      </ul>
      <p>Total: ${(total / 100).toFixed(2)}</p>
    </div>
  );
}
components/tabs.tsx
"use client";

import { useState, type ReactNode } from "react";

export type Tab = { id: string; label: string; content: ReactNode };

export function Tabs({ tabs }: { tabs: Tab[] }) {
  const [active, setActive] = useState(tabs[0].id);

  return (
    <section>
      <div role="tablist">
        {tabs.map((t) => (
          <button key={t.id} role="tab" aria-selected={t.id === active} onClick={() => setActive(t.id)}>
            {t.label}
          </button>
        ))}
      </div>
      {tabs.map((t) => (
        <div key={t.id} role="tabpanel" hidden={t.id !== active}>
          {t.content}
        </div>
      ))}
    </section>
  );
}
next build
Route (app)
┌ ○ /
└ ○ /_not-found


○  (Static)  prerendered as static content
AdvancedWhat happened in the browser

With next start, curl and headless Chrome I checked what the closed drawer and the hidden tab look like on the wire, then clicked:

Output
HTML <li> elements containing "Wool socks":   0
"Wool socks" in the page's payload scripts:   1
<div role="tabpanel"><p>Weight 260 g, 6 mm drop.</p>
<div role="tabpanel" hidden=""><p>Rinse with cold water and air dry.</p>

after clicking "Cart (4)":   Cart (4) / 1 × Trail running shoes / 3 × Wool socks / Total: $197.96
after clicking "Care":       hidden: Weight 260 g, 6 mm drop. / shown: Rinse with cold water and air dry.
network requests during both clicks: none
server log lines during both clicks: none

The Drawer element in the payload (unescaped):

Output
["$","$L8",null,{"title":["$","span",null,{"children":["Cart (",4,")"]}],"children":["$","div",null,{"children":[["$","ul",null,{"children":[["$","li","TR-1",{"children":[1," × ","Trail running shoes"]}],["$","li","SK-3",{"children":[3," × ","Wool socks"]}]]}],["$","p",null,{"children":["Total: $","197.96"]}]]}]}]
What’s happening
  1. The closed drawer rendered no <li> into the HTML, because open starts false during SSR too. But the cart summary's output is in the response: the Drawer row above carries it as the children prop, ready for when the drawer opens.
  2. Clicking "Cart (4)" set open to true and the list appeared with no network request: the browser already had the server's output. No CartSummary code ran in the browser; it doesn't exist there.
  3. The hidden "Care" panel was in the HTML with the hidden attribute, and switching tabs only flipped the attribute, again with no request.
  4. The flip side: slots are rendered eagerly. The server renders every slot you pass, even ones the user never opens, and sends all of it. That's ideal for small content you want to show instantly, and wasteful for heavy content most users never see; for that, render it lazily (a separate route, or a Client Component that fetches on open).
AdvancedMistake: importing a Server Component from the client

The mistake: importing a Server Component into a Client Component

Here's the same drawer written the "obvious" way, with Drawer importing CartSummary itself:

components/drawer.tsx
"use client";

import { useState, type ReactNode } from "react";
import { CartSummary } from "./cart-summary";

export function Drawer({ title }: { title: ReactNode }) {
  const [open, setOpen] = useState(false);

  return (
    <aside>
      <button aria-expanded={open} onClick={() => setOpen(!open)}>
        {title}
      </button>
      {open && <CartSummary />}
    </aside>
  );
}

next build (with page.tsx changed to <Drawer title={…} />) failed, and the import trace shows exactly how the server module got pulled across (paths shortened):

Output
./src/lib/cart.ts:1:1
Error: 'server-only' cannot be imported from a Client Component module
> 1 | import "server-only";
    | ^^^^^^^^^^^^^^^^^^^^^

It should only be used from a Server Component.

Import traces:
  Server Component:
    ./src/lib/cart.ts
    ./src/app/page.tsx

  Client Component Browser:
    ./src/lib/cart.ts [Client Component Browser]
    ./src/components/cart-summary.tsx [Client Component Browser]
    ./src/components/drawer.tsx [Client Component Browser]
    ./src/components/drawer.tsx [Server Component]
    ./src/app/page.tsx [Server Component]
What’s happening
  1. Read the trace bottom-up: page.tsx (server) imports drawer.tsx; in the server graph that's just the reference. Then drawer.tsx appears again as [Client Component Browser]: that's the client graph's copy, the real code.
  2. The client copy of drawer.tsx imports cart-summary.tsx, so CartSummary is now a Client Component in that graph, and it imports lib/cart.ts, which says server-only. The same chain repeats under Client Component SSR (cut here), the SSR copy of the client graph.
  3. The first trace, Server Component: lib/cart.ts ← page.tsx, is the legitimate use (the page counting items); it's listed so you can see that the module is fine on the server side.
  4. The guard turned a silent problem into a build error. I removed import "server-only" and rebuilt: the build passed, "Wool socks" and "CartSummary rendered on the server" were now inside a chunk in .next/static, and when I opened the drawer in Chrome, the browser console logged CartSummary rendered on the server. The "Server Component" had quietly become client code, data and all.
  5. The fix is the composition version above: let the Server Component that owns the page pass <CartSummary /> as children.
AdvancedPatterns for keeping client islands small
  • Move state down to the leaf. If a layout needs a search box, make SearchBox the Client Component, not the layout. Every component above the directive stays on the server.
  • Wrap, don't convert. When a client feature has to surround server content (a drawer, a modal, an accordion, a provider), make the Client Component accept children or named slots and let the Server Component fill them.
  • Pass rendered output, not components. <Tabs tabs={[{ content: <Specs /> }]} /> works; passing the component itself (content={Specs}) is a function and fails.
  • Keep data fetching in the owner. The server-side owner fetches and renders; the client wrapper only arranges. If the client part needs data, pass it as serialisable props or a Promise (Passing data across the boundary).
  • Render props don't cross. A Client Component that expects renderItem={(item) => …} can only get it from another Client Component. From the server, pass the rendered items instead.
  • key still resets client state. Server-rendered slots can carry keys, and a Client Component wrapper re-mounts (losing state) when its key changes, the same as in client-only React.
Does clicking re-run the Server Component?

In the composition app, a user opens the drawer, closes it and opens it again. How many times does CartSummary run, and where?

Show answer

It ran once, at build time, and never again for this visit: no server request, no browser execution.

What’s happening
  1. The route is ○ Static, so CartSummary ran during next build, and its output was stored in the prerendered HTML and payload.
  2. The visit served those files. The Drawer element arrived with the cart summary's output as its children prop.
  3. Opening, closing and reopening the drawer changes open in the browser. React re-renders Drawer and shows or removes the children elements it already holds. There's no CartSummary code in the browser to call.
  4. Server output only changes when the server renders again: a navigation, router.refresh(), or a Server Action that revalidates. Client state never triggers it.

#server-only, client-only and environment safety

Added

The boundary decides where a module runs by who imports it, and nothing in a plain .ts file says who's allowed to. A lib/db.ts written for the server works perfectly until someone imports it from a Client Component; then it's compiled into the browser bundle, with its queries, its internal URLs and its logic. Next.js calls this environment poisoning. The guards are two one-line imports: import "server-only" makes the build fail if the module ever reaches the client graph, and import "client-only" does the opposite for code that needs the browser.

The mental model: label the boxes before the move. server-only is a "do not ship" sticker on a module: the import does nothing at runtime, but Turbopack reads it while building each graph and refuses to put the module in the client one. Environment variables have their own sticker, by name: only variables prefixed NEXT_PUBLIC_ may be copied into browser code, and they're copied as text at build time. Everything else stays in the server's process.env.

You don't need to install anything: the docs say Next.js handles server-only and client-only imports itself and doesn't use the npm packages' contents. In this sandbox server-only isn't in node_modules at all, and every example here built. (Install them if your lint rules flag undeclared dependencies.)

lib/weather.ts
import "server-only";

export async function getForecast(city: string) {
  const key = process.env.WEATHER_API_KEY;
  if (!key) throw new Error("WEATHER_API_KEY is not set");
  // A real app would call the weather API here with the key in a header.
  return { city, tempC: 11, summary: "Light rain" };
}
What’s happening
  1. next build loads .env (the build log says Environments: .env). The .env files live at the project root even with a src/ folder.
  2. lib/weather.ts reads WEATHER_API_KEY. It's marked server-only, and only the page (a Server Component) imports it, so the build is happy. The page awaits it at build time (it resolves without real I/O), so / is ○ Static with "Toronto: 11°C, Light rain" in its HTML.
  3. support-link.tsx is a Client Component. It reads process.env.NEXT_PUBLIC_SUPPORT_EMAIL; because of the prefix, Turbopack replaces that expression with the string "help@example.com" in the compiled code, for the browser and for the server.
  4. lib/clipboard.ts is marked client-only because navigator.clipboard exists only in browsers. It's called from an event handler, so it never runs during SSR.
  5. /runtime renders per request (await connection()), so it reads process.env when the request arrives. That's how to use one build in several environments for server-side values. NEXT_PUBLIC_ values can't do that, as the checks below show.
AdvancedEnv app: .env, helpers, pages, route table

The rest of the environment app

The .env file, the browser-only helper, the Client Component, the two pages, and the route table:

.env
WEATHER_API_KEY=wk_test_51Hsecret
WEATHER_REGION=ca-central
NEXT_PUBLIC_SUPPORT_EMAIL=help@example.com
lib/clipboard.ts
import "client-only";

export function copy(text: string) {
  return navigator.clipboard.writeText(text);
}
components/support-link.tsx
"use client";

import { useState } from "react";
import { copy } from "@/lib/clipboard";

const email = process.env.NEXT_PUBLIC_SUPPORT_EMAIL!;

export function SupportLink() {
  const [copied, setCopied] = useState(false);

  return (
    <p>
      <a href={`mailto:${email}`}>{email}</a>{" "}
      <button onClick={() => copy(email).then(() => setCopied(true))}>{copied ? "Copied" : "Copy"}</button>
    </p>
  );
}
app/page.tsx
import { getForecast } from "@/lib/weather";
import { SupportLink } from "@/components/support-link";

export default async function Page() {
  const forecast = await getForecast("Toronto");

  return (
    <main>
      <h1>
        {forecast.city}: {forecast.tempC}°C, {forecast.summary}
      </h1>
      <SupportLink />
    </main>
  );
}
app/runtime/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";

async function Values() {
  await connection(); // render per request, so process.env is read at request time
  return (
    <ul>
      <li>WEATHER_REGION: {process.env.WEATHER_REGION}</li>
      <li>NEXT_PUBLIC_SUPPORT_EMAIL: {process.env.NEXT_PUBLIC_SUPPORT_EMAIL}</li>
    </ul>
  );
}

export default function Page() {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <Values />
    </Suspense>
  );
}
next build
Route (app)
┌ ○ /
├ ○ /_not-found
└ ◐ /runtime


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
AdvancedChecked: what's in the bundle, and when values are read

I searched the browser files and the prerendered HTML for each value, then started the same build with different environment values (WEATHER_REGION=us-east NEXT_PUBLIC_SUPPORT_EMAIL=changed@example.com next start -p 4503) and fetched both pages with curl:

Output
                     in .next/static   in index.html
wk_test_51Hsecret    0 files           0
help@example.com     1 file            1
ca-central           0 files           0
Light rain           0 files           1

GET /runtime  →  <li>WEATHER_REGION: us-east</li>
                 <li>NEXT_PUBLIC_SUPPORT_EMAIL: help@example.com</li>
GET /         →  <a href="mailto:help@example.com">help@example.com</a>
What’s happening
  1. The API key and the region appear nowhere the browser can download. The forecast text is in the HTML (it's rendered output) but not in any JavaScript.
  2. help@example.com is in one browser chunk: the inlined literal, exactly what NEXT_PUBLIC_ asks for. Treat every NEXT_PUBLIC_ value as published.
  3. At runtime, WEATHER_REGION showed us-east, the value given to next start (the real environment beats .env). A non-public variable read in a dynamic render is a runtime value.
  4. NEXT_PUBLIC_SUPPORT_EMAIL still said help@example.com on both pages, even in the server-rendered /runtime. The build replaced the expression in server code too, so the value is frozen at build time everywhere. If one Docker image must serve staging and production, public config has to come from the server at runtime (a prop, a route handler), not from NEXT_PUBLIC_ (chapter 09 goes further).
AdvancedThe guard errors, word for word

A Client Component importing a server-only module (here lib/db.ts, imported by components/greeting.tsx) fails next build with two errors for the same line (excerpts; paths shortened, the SSR trace cut):

Output
./src/lib/db.ts:1:1
Error: You're importing a module that depends on "server-only". This API is only available in Server Components in the App Router, but you are using it in the Pages Router.
    Learn more: https://nextjs.org/docs/app/building-your-application/rendering/server-components
> 1 | import "server-only";
    | ^^^^^^^^^^^^^^^^^^^^^

./src/lib/db.ts:1:1
Error: 'server-only' cannot be imported from a Client Component module
> 1 | import "server-only";
    | ^^^^^^^^^^^^^^^^^^^^^

It should only be used from a Server Component.

Import traces:
  Client Component Browser:
    ./src/lib/db.ts [Client Component Browser]
    ./src/components/greeting.tsx [Client Component Browser]
    ./src/components/greeting.tsx [Server Component]
    ./src/app/page.tsx [Server Component]

The reverse, a Server Component page importing a client-only module (excerpts):

Output
./src/lib/storage.ts:1:1
Error: You're importing a component that imports client-only. It only works in a Client Component but none of its parents are marked with "use client", so they're Server Components by default.
    Learn more: https://nextjs.org/docs/app/building-your-application/rendering
> 1 | import "client-only";
    | ^^^^^^^^^^^^^^^^^^^^^

./src/lib/storage.ts:1:1
Error: 'client-only' cannot be imported from a Server Component module
It should only be used from a Client Component.

And a Client Component importing cookies from next/headers, a server-only Next.js API:

Output
./src/components/theme.tsx:3:1
Error: You're importing a module that depends on "next/headers". This API is only available in Server Components in the App Router, but you are using it in the Pages Router.
> 3 | import { cookies } from "next/headers";
    | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
What’s happening
  1. All three are build errors, raised while Turbopack builds the module graphs, before any page renders. That's the point of the guards: the mistake can't reach production.
  2. The first message says "you are using it in the Pages Router", which is misleading in an App Router app with no pages/ folder. It's the generic wording for "a client graph imported a server-only module"; the second error and the import trace say what actually happened. Read the trace, not the first sentence.
  3. The trace's labels are the graphs from the boundary diagram: [Server Component] for the server graph, [Client Component Browser] and (listed separately) [Client Component SSR] for the client graph's two compilations. The crossing is where one file appears twice: greeting.tsx [Server Component] (the reference) directly below greeting.tsx [Client Component Browser] (the real code). That's the 'use client' file that pulled db.ts in.
  4. next/headers, server-only and your own guarded modules behave alike: available in the server graph, refused in the client graph.
AdvancedPractical rules for secrets and guards

Practical rules

  • Mark every module that touches secrets, the database or internal services with import "server-only", typically your data access layer (chapter 04). It costs nothing and turns poisoning into a build error.
  • Mark browser-dependent helpers (storage, clipboard, window measurements) with import "client-only" so a Server Component can't call them by accident.
  • Prefix only what you'd publish. Analytics ids, public API base URLs, support addresses: NEXT_PUBLIC_. Keys, tokens, database URLs: never. A rename to NEXT_PUBLIC_ is a publication.
  • NEXT_PUBLIC_ values are build-time constants, in server and client code alike (observed above). Dynamic lookups such as process.env[name] are not inlined, per the docs, so they're undefined in the browser.
  • server-only is not authorisation. It keeps code out of the bundle; it doesn't stop a Server Component from rendering data the user shouldn't see, or from passing a whole row as a prop (Passing data across the boundary).
  • 'use server' is not a guard. It turns exports into callable endpoints, the opposite of hiding them. Use server-only for "never in the browser" and 'use server' for "callable from the browser".
Leak or no leak?

For each, does the secret STRIPE_KEY (no prefix) reach the browser in any form? (a) A Server Component renders <p>{process.env.STRIPE_KEY ? "configured" : "missing"}</p>. (b) A Client Component renders {process.env.STRIPE_KEY}. (c) A Client Component imports lib/payments.ts, which has no guard and reads process.env.STRIPE_KEY inside a function the component never calls. (d) The same as (c), but lib/payments.ts starts with import "server-only".

Show answer

(a) No, only the word "configured". (b) Yes, in the server-rendered HTML. (c) The key doesn't, but the code does. (d) Nothing ships: the build fails.

What’s happening
  1. (a) The Server Component reads the key on the server and renders a word derived from it. Only the output crosses the boundary.
  2. (b) The Client Component's server render runs in Node, where process.env.STRIPE_KEY is the real key, so the HTML contains it (the gotcha above, observed with WEATHER_API_KEY). The browser bundle reads an empty env and renders undefined, causing a hydration mismatch on top of the leak.
  3. (c) The import puts lib/payments.ts in the client graph. The browser copy reads the empty env object, so the value isn't in the bundle, but the module's code, endpoints and logic are. This is the poisoning the docs describe: no secret leaked, but server code shipped and won't work.
  4. (d) With the guard, the same import is the build error "'server-only' cannot be imported from a Client Component module", with a trace pointing at the Client Component that imported it.

#Context, providers and third-party libraries

Added

React context is client-side state that flows down the tree, so it lives entirely in the client world: createContext and useContext don't exist in Server Components. That seems to clash with the classic setup, where a <ThemeProvider> or <QueryClientProvider> wraps the whole app in the root layout. It doesn't, because of the composition rule from the previous topic: the provider is a Client Component that takes children, the root layout (a Server Component) renders it, and the pages passed in as children stay Server Components. Client Components anywhere below can read the context; Server Components below it simply can't see it.

The mental model: a provider is a pipe running through the walls. It carries water (context values) to every tap (Client Component) in the house, but the rooms (Server Components) it passes through don't become part of the plumbing. The same idea covers third-party libraries: a component library written before Server Components uses hooks but has no 'use client', so a Server Component can't import it directly. You give it a door: a tiny 'use client' file that re-exports it.

app/theme-provider.tsx
"use client";

import { createContext, useContext, useState, type ReactNode } from "react";

type Theme = "light" | "dark";
const ThemeContext = createContext<{ theme: Theme; toggle: () => void } | null>(null);

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>("light");
  const toggle = () => setTheme(theme === "light" ? "dark" : "light");

  return (
    <ThemeContext value={{ theme, toggle }}>
      <div data-theme={theme}>{children}</div>
    </ThemeContext>
  );
}

export function useTheme() {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error("useTheme must be used inside <ThemeProvider>");
  return ctx;
}
What’s happening
  1. theme-provider.tsx is a Client Component that owns the state and puts { theme, toggle } into context. <ThemeContext value={…}> is React 19's shorter form of <ThemeContext.Provider value={…}> (both work). The provider renders {children} without knowing what they are.
  2. The root layout is a Server Component. It renders <ThemeProvider>{children}</ThemeProvider>, where children is the page's server-rendered output. That's the Drawer pattern again: the layout owns the page, the provider is only its parent.
  3. ThemeToggle is a Client Component, so it can call useTheme(). React finds the nearest ThemeContext above it in the rendered tree, which works across the server-rendered layers in between: the page sits between the provider and the toggle, and it's a Server Component.
  4. The page itself never touches context. Its list and heading are server output, so the route stays ○ Static and none of the page's text ends up in a JavaScript file.
  5. vendor/acme-carousel.tsx uses useState with no directive, like an older npm package. components/carousel.tsx is the door: a 'use client' file that only re-exports it. The page imports the wrapper, so the server graph gets a client reference and the carousel's code joins the client graph through the door.
  6. What this demonstrates: providers and hook-based libraries need one small client entry each, and the server tree around them stays server-rendered.
AdvancedProvider app: layout, toggle, library wrapper

The rest of the provider app

The root layout that renders the provider, the toggle, the stand-in library and its wrapper, the page, and the route table:

app/layout.tsx
import type { ReactNode } from "react";
import { ThemeProvider } from "./theme-provider";

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}
components/theme-toggle.tsx
"use client";

import { useTheme } from "@/app/theme-provider";

export function ThemeToggle() {
  const { theme, toggle } = useTheme();
  return <button onClick={toggle}>Theme: {theme}</button>;
}
vendor/acme-carousel.tsx
// Stands in for an npm package written before Server Components: hooks, no "use client".
import { useState } from "react";

export function Carousel({ slides }: { slides: string[] }) {
  const [i, setI] = useState(0);
  return <button onClick={() => setI((i + 1) % slides.length)}>{slides[i]} ›</button>;
}
components/carousel.tsx
"use client";

export { Carousel } from "@/vendor/acme-carousel";
app/page.tsx
import { ThemeToggle } from "@/components/theme-toggle";
import { Carousel } from "@/components/carousel";

const notes = ["Choosing trail shoes", "Layering for rain"];

export default function Page() {
  return (
    <main>
      <h1>Field notes</h1>
      <ThemeToggle />
      <Carousel slides={["Lake trail", "Ridge loop", "Forest path"]} />
      <ul>
        {notes.map((n) => (
          <li key={n}>{n}</li>
        ))}
      </ul>
    </main>
  );
}
next build
Route (app)
┌ ○ /
└ ○ /_not-found


○  (Static)  prerendered as static content

I checked it the same way as the earlier apps: "Field notes", "Choosing trail shoes" and "Lake trail" are in no file in .next/static (they're HTML and payload data); "useTheme must be used inside" is in one (the provider's code). In headless Chrome the page started as data-theme="light", "Theme: light", "Lake trail ›"; one click on each button changed them to dark, "Theme: dark" and "Ridge loop ›", with no network requests.

AdvancedReading context on the server: two failures

Reading context from a Server Component fails, two different ways

Calling the provider's hook from a Server Component:

Output
Error occurred prerendering page "/". Read more: https://nextjs.org/docs/messages/prerender-error
Error: Attempted to call useTheme() from the server but useTheme is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.
    at <unknown> (src/app/page.tsx:4:21)
> 4 |   const { theme } = useTheme();

Calling React's useContext directly in a Server Component (with the context object exported from the provider file):

Output
Error occurred prerendering page "/". Read more: https://nextjs.org/docs/messages/prerender-error
TypeError: (0 , c.useContext) is not a function
    at <unknown> (src/app/page.tsx:5:17)
> 5 |   const value = useContext(ThemeContext);

And importing the library without the wrapper, straight into the page:

Output
./src/vendor/acme-carousel.tsx:1:10
Error: You're importing a module that depends on `useState` into a React Server Component module. This API is only available in Client Components. To fix, mark the file (or its parent) with the `"use client"` directive.
Import trace:
  Server Component:
    ./src/vendor/acme-carousel.tsx
    ./src/app/page.tsx
What’s happening
  1. useTheme is exported from a 'use client' file, so on the server it's a client reference, and calling a reference throws. Same error as calling remaining() in The 'use client' boundary.
  2. useContext isn't on Turbopack's compile-time list (every other client hook I tried was), so it's a runtime TypeError: React's server build has no useContext export. Static page: build error. Dynamic part: a per-request error.
  3. The library error is raised in the library's file, with a trace back to your page. "Mark the file (or its parent)" means: you can't edit the package, so add the parent, a 'use client' wrapper.
  4. If a Server Component needs the value (the theme, the locale), it can't read it from context. Read the source directly on the server (a cookie, the URL, the database) and pass it down.
AdvancedServer data in a provider: pass a Promise

A provider can also carry server data to every Client Component, without each one fetching. Start the request in the layout, don't await it, and put the Promise into context; consumers unwrap it with use() inside Suspense. This is the pattern the Next.js single-page-app guide recommends:

lib/session.ts
import "server-only";
import { cookies } from "next/headers";

export type User = { name: string };

export async function getUser(): Promise<User | null> {
  const name = (await cookies()).get("user")?.value; // a real app would verify a session here
  return name ? { name } : null;
}
app/user-provider.tsx
"use client";

import { createContext, useContext, type ReactNode } from "react";
import type { User } from "@/lib/session";

const UserContext = createContext<Promise<User | null> | null>(null);

export function UserProvider({ userPromise, children }: { userPromise: Promise<User | null>; children: ReactNode }) {
  return <UserContext value={userPromise}>{children}</UserContext>;
}

export function useUserPromise() {
  const promise = useContext(UserContext);
  if (!promise) throw new Error("useUserPromise must be used inside <UserProvider>");
  return promise;
}
app/layout.tsx
import type { ReactNode } from "react";
import { UserProvider } from "./user-provider";
import { getUser } from "@/lib/session";

export default function RootLayout({ children }: { children: ReactNode }) {
  const userPromise = getUser(); // started, not awaited

  return (
    <html lang="en">
      <body>
        <UserProvider userPromise={userPromise}>{children}</UserProvider>
      </body>
    </html>
  );
}
components/greeting.tsx
"use client";

import { use } from "react";
import { useUserPromise } from "@/app/user-provider";

export function Greeting() {
  const user = use(useUserPromise());
  return <p>{user ? `Hi, ${user.name}` : "Sign in"}</p>;
}
app/page.tsx
import { Suspense } from "react";
import { Greeting } from "@/components/greeting";

export default function Page() {
  return (
    <main>
      <h1>Field notes</h1>
      <Suspense fallback={<p>…</p>}>
        <Greeting />
      </Suspense>
    </main>
  );
}
next build
Route (app)
┌ ◐ /
└ ◐ /_not-found


◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
What’s happening
  1. import type { User } in the provider is erased at compile time, so the client file doesn't really import lib/session.ts and its server-only guard isn't triggered. A value import would fail the build.
  2. The layout calls getUser() without await. It returns a Promise that will read the user cookie; the layout renders straight away and passes the pending Promise as a prop (Promises are serialisable, see Passing data across the boundary).
  3. Greeting calls use() on it and suspends; the page's <Suspense> shows "…" in the static shell. With curl and no cookie, the response streamed <p>Sign in</p> after the shell; with curl -H 'Cookie: user=Rohit', it streamed <div hidden id="S:0"><p>Hi, Rohit</p></div> plus the script that swaps it in for the fallback.
  4. Look at the route table: /_not-found became ◐ too. The root layout wraps every route, including the 404 page, and every route now carries a request-time Promise. That's why the docs advise rendering providers as deep as possible: a provider that needs request data in the root layout gives every page a dynamic part. Put it in the layout of the section that needs it.
  5. Any Client Component under the provider can call use(useUserPromise()), and they all share the one request started in the layout.
AdvancedThird-party libraries in practice
  • Check the package's entry file for 'use client'. Libraries that ship it can be imported from Server Components directly. If one doesn't, you get the useState (or createContext) build error above; add a wrapper file. Either way, the library's code is client code: the directive only moves the door.
  • Libraries that need a provider (data-fetching clients, toast systems, UI kits with a theme) get the ThemeProvider treatment: one 'use client' providers.tsx that composes them and takes children, rendered from the layout that needs it.
  • CSS-in-JS libraries that inject styles at runtime need more: the Next.js CSS-in-JS guide describes a style registry Client Component that collects rules during SSR and injects them with useServerInsertedHTML. Those libraries only style Client Components (chapter 11).
  • Server-only libraries (database drivers, SDKs with secrets) belong behind server-only modules. If a package uses Node.js-specific features that bundling breaks, serverExternalPackages in next.config.ts opts it out of Server Components bundling so it's loaded with native Node.js require (per its docs page; some packages, such as @aws-sdk/client-s3, are on Next.js's built-in list).
  • Library authors: put 'use client' at the top of entry points that use client features, and make sure your bundler keeps it (the docs warn that some bundlers strip directives).

#How the RSC payload works

Added

The RSC payload is the serialised React tree that the server produces from your Server Components. It's not HTML and not JSON: it's React's streaming "Flight" format, a series of lines id:value where values are JSON with $-prefixed references to other lines. It contains the output of Server Components, references to Client Components (which module, which export) with their props, and the router data Next.js needs. React uses it on the client to build (and later update) the component tree; HTML is only for the first paint.

The mental model: HTML is the house, the payload is the blueprint. On the first visit you get both: the built house so you can walk in at once, and the blueprint (inlined in the HTML) so React knows which parts are which components and where the interactive fittings go. On later navigations the server sends only a blueprint for the rooms that change; React rebuilds those rooms in place and leaves the rest of the house, including any Client Component state in shared layouts, untouched.

First load document Prefetch links in view Navigation dynamic route GET /about document request HTML + inline payload self.__next_f.push hydrate Client Components GET /about?_rsc=… next-router-prefetch: 1 /_tree, /about/__PAGE__ static segments client cache click: 0 requests GET /live?_rsc=… next-router-state-tree only __PAGE__ rendered layouts: "r": null page swapped in place layout state kept
What I observed for the app below: one document request, segment prefetches, then targeted payloads.

A small app to watch it happen: a root layout with links and a Client Component counter, two static pages and one dynamic page.

app/layout.tsx
import type { ReactNode } from "react";
import Link from "next/link";
import { Counter } from "@/components/counter";

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <nav>
          <Link href="/">Home</Link> · <Link href="/about">About</Link> · <Link href="/live">Live</Link>
        </nav>
        <Counter />
        {children}
      </body>
    </html>
  );
}
What’s happening
  1. next build prerenders / and /about completely (○) and /live as a static shell with a hole (◐): await connection() defers ServerTime to request time, after which reading the clock is allowed.
  2. For each route the build writes the HTML, the full payload and per-segment payloads. For /about, .next/server/app/ got about.html, about.rsc, about.meta and about.segments/ with _tree.segment.rsc (166 bytes, the route tree), _full.segment.rsc (3,796 bytes) and about/__PAGE__.segment.rsc (3,289 bytes). about.meta lists the response headers to send, including x-nextjs-stale-time: 300.
  3. The layout renders <Counter />, a Client Component, so every page's payload references it, and the layout's <Link>s are what trigger prefetching.
  4. /live's .meta also has a postponed field: the saved state React needs to resume the render at request time and fill the hole (Partial Prerendering).
AdvancedNavigation app: counter, pages, route table

The rest of the navigation app

The counter, the three pages, and the route table:

components/counter.tsx
"use client";

import { useState } from "react";

export function Counter() {
  const [n, setN] = useState(0);
  return <button onClick={() => setN(n + 1)}>Clicked {n} times</button>;
}
app/page.tsx
export default function Home() {
  return <h1>Home</h1>;
}
app/about/page.tsx
export default function About() {
  return (
    <main>
      <h1>About</h1>
      <p>Static, prerendered at build time.</p>
    </main>
  );
}
app/live/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";

async function ServerTime() {
  await connection();
  return <p>Rendered at {new Date().toISOString()}</p>;
}

export default function Live() {
  return (
    <main>
      <h1>Live</h1>
      <Suspense fallback={<p>Loading time…</p>}>
        <ServerTime />
      </Suspense>
    </main>
  );
}
next build
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /about
└ ◐ /live


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
AdvancedWhat happened, request by request

I opened / in headless Chrome against next start, recorded every request with the DevTools protocol, clicked the counter three times, then clicked "About", then "Live":

Output
page load
  GET /                        text/html           Cache-Control: s-maxage=31536000, x-nextjs-cache: HIT
  GET /?_rsc=5CB68i4pnAekjehf       rsc: 1, next-router-prefetch: 1, next-router-segment-prefetch: /_tree
  GET /about?_rsc=5CB68i4pnAekjehf  rsc: 1, next-router-prefetch: 1, next-router-segment-prefetch: /_tree
  GET /live?_rsc=5CB68i4pnAekjehf   rsc: 1, next-router-prefetch: 1, next-router-segment-prefetch: /_tree
  GET /live?_rsc=4TB-DAqkiH1L8mku   rsc: 1, next-router-prefetch: 1, next-router-segment-prefetch: /live/__PAGE__
  GET /about?_rsc=hzcUC6YqarYE9pKE  rsc: 1, next-router-prefetch: 1, next-router-segment-prefetch: /about/__PAGE__
  (all five prefetches: 200, text/x-component, x-nextjs-cache: HIT, x-nextjs-postponed: 2)

click "About"  → no requests;  page text: "Clicked 3 times / About / Static, prerendered at build time."
click "Live"   → GET /live?_rsc=tdLB5OcFnmaUobmO
                   rsc: 1, next-url: /about,
                   next-router-state-tree: ["",{"children":["live",{"children":["__PAGE__",{},null,"refetch",49314]},null,null,49250]},null,null,49234]
                 response: text/x-component, Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
                 page text: "Clicked 3 times / Live / Rendered at 2026-10-25T18:30:47.240Z"
What’s happening
  1. First load: one HTML document. The payload for it isn't fetched separately; it's inside the HTML (next block). Static pages are served from the prerender (x-nextjs-cache: HIT) with a one-year s-maxage, so a CDN may cache them.
  2. Prefetch: as soon as the links were in view, the router fetched each route's tree (/_tree), then the static __PAGE__ segment of the two routes it didn't have. These are separate small requests, the "incremental prefetching" and "layout deduplication" of Next.js 16's routing overhaul: the shared root layout isn't downloaded again per link. With partialPrefetching: true (the template's setting) only the static parts are prefetched.
  3. Click "About": zero requests. Everything the static page needed was already in the client cache, so the navigation was instant, and "Clicked 3 times" survived: the layout, and the Counter inside it, wasn't re-rendered or re-mounted.
  4. Click "Live": the prefetched shell (/live/__PAGE__) could be shown at once, but the hole needs the server, so the router made one request. rsc: 1 asks for a payload instead of HTML, and next-router-state-tree tells the server which tree the client wants, with "refetch" on the page segment. The response is per-request (no-store), and the counter still said 3.
  5. _rsc is a hash of those headers that Next.js adds as a cache key, because many CDNs ignore Vary (the CDN caching guide explains it). Ask for a payload without it and you get a redirect: curl -s -D - -H 'RSC: 1' localhost:4503/live returned HTTP/1.1 307 Temporary Redirect with location: /live?_rsc. Use curl -L or add ?_rsc when debugging.
AdvancedThe first-load payload, inlined

curl -s localhost:4503/about returned 6,212 bytes of HTML. After the markup came the payload, split across script tags (first 90 characters of each):

Output
<script>(self.__next_f=self.__next_f||[]).push([0])
<script>self.__next_f.push([1,"1:\"$Sreact.fragment\"\n2:\"/_next/static/chunks/1je66818xooq-.js\"
<script>self.__next_f.push([1,"0:{\"P\":null,\"c\":[\"\",\"about\"],\"q\":\"\",\"i\":false,\"t\":{
<script>self.__next_f.push([1,"9:C\n18:300\n11:[[\"$\",\"meta\",\"0\",{\"charSet\":\"utf-8\"}],[\"
<script>self.__next_f.push([1,"1b:I[42104,[\"$2\"],\"IconMark\"]\nd:null\n14:[[\"$\",\"link\",\"0\
<script>self.__next_f.push([1,"18:C\n15:C\n17:C\ne:C\n7:C\n"])
<script>self.__next_f.push([1,"1a:false\n19:null\n"])
What’s happening
  1. self.__next_f is a plain array the HTML fills as it streams. In Next.js's client entry (next/dist/client/app-index.js), [0] is the bootstrap entry (without it the client throws "Unexpected server data: missing bootstrap script."), [1, text] is a chunk of payload text, [2, …] carries form state and [3, base64] binary payload data. When the runtime loads, it feeds the buffered entries into a stream for React's Flight client and replaces push so later scripts go straight to the stream.
  2. Because the payload rides inside the HTML, hydration needs no second request, and with streaming, each Suspense boundary's rows arrive in the same response as its HTML.
  3. The cost: the page's content is in the response twice, once as markup and once as payload (the payload scripts here are about 4.6 KB of the 6,212 bytes). That's why passing big props to Client Components costs twice (Passing data across the boundary).
  4. The same route's payload on its own (curl -s -L -H 'RSC: 1' 'localhost:4503/about?_rsc') was 3,797 bytes, matching the prerendered _full.segment.rsc.
AdvancedThe navigation payload, only what changed

The /live navigation response, replayed with curl and the same headers (3,093 bytes; long rows cut):

Output
4:"$Sreact.fragment"
5:"$Sreact.suspense"
7:"/_next/static/chunks/1je66818xooq-.js"
8:I[33960,["$7"],"OutletBoundary"]
1:X
a:X
3:[["children",{"s":"__PAGE__","h":49314,"d":{"r":["$","$4","c",{"children":[["$","main",null,{"children":[["$","h1",null,{"children":"Live"}],["$","$5",null,{"fallback":["$","p",null,{"children":"Loading time…"}],"children":"$L6"}]]}], …
2:[["children",{"s":"live","h":49250,"d":{"r":null,"p":true,"v":null},"c":"$Q3"}]]
0:{"q":"","i":false,"S":true,"r":"$1","b":"q6FCw16s97t658HLWQV3K","t":{"t":{"s":"","h":49234,"d":{"r":null,"p":true,"v":null},"c":"$Q2"}, …
…
6:["$","p",null,{"children":["Rendered at ","2026-10-25T18:31:43.502Z"]}]
What’s happening
  1. Row 0 is the root. b is the build id (it matched .next/BUILD_ID), and t is the route tree from the root down: segment "" (root layout) → live → __PAGE__, each linked through a $Q Map of slots (children).
  2. For the root layout and the live segment, "d":{"r":null,…}: no rendered output. The client already has them, so the server skipped them. Only __PAGE__ (row 3) has "r" with elements: the <main>, the <h1>, and the Suspense boundary whose children are "$L6", a lazy reference to row 6.
  3. Row 6, the server time, is the last useful row: it was written when connection() resolved and the clock was read. One response delivered the page structure first and the dynamic part after it, the same streaming as on first load.
  4. For comparison I fetched the same URL without next-router-state-tree: 7,616 bytes, and this time the payload included the root layout (nav, the Counter reference). The state tree is what lets the server send only the changed segment. The CDN caching guide says the same: omit the header and you get a full payload.
  5. Rows like 1:X … 1:C are async iterables opened and closed (X starts one, C closes it), and the response also contained a byte stream (R, then chunks such as 15:o463,). That stream is Partial Prefetching's embedded runtime prerender (the extra render from Server Components by default): it repeats the page's shell ("Loading time…" appears twice in the response) but not the time, because connection() never resolves in a prerender. Rebuilt with partialPrefetching: false, the same navigation response was 1,544 bytes, with no R row. The X/C iterables remained; I read their row types from React's Flight client source but didn't trace what the router does with them.
AdvancedReading the format

What the tags mean, from React's Flight client (react-server-dom-turbopack-client bundled in Next.js 16.4.3) and the payloads above:

In the payloadMeaning
id:I[moduleId,[chunks],"Export"]A client reference: load these chunks, take this export
:HL["/…css","style"]A hint: preload this stylesheet, font or script
id:X … id:CAn async iterable opened, its values, then closed
id:R, id:o<hex length>,A stream started, then binary chunks of it
["$", type, key, props]A React element; type is a tag name or a reference such as "$L9"
"$L9"A lazy reference to row 9 (often a client component or a part still streaming)
"$@f"A Promise settled by row f
"$Sreact.suspense"A symbol (Symbol.for("react.suspense"))
"$D…", "$n…", "$Q…", "$W…", "$K…", "$i…"Date, BigInt, Map, Set, FormData, iterator
"$h…"A server reference (a Server Action)
"$undefined", "$NaN", "$Infinity", "$-0"Values JSON can't hold
"$$…"A string that really starts with $
"$Z"An error (in production, without its message)
AdvancedDebugging the payload
  • DevTools → Network, filter by _rsc or by type text/x-component: each prefetch and navigation is listed with its rsc, next-router-prefetch, next-router-segment-prefetch and next-router-state-tree headers. The response tab shows the rows.
  • curl -L -H 'RSC: 1' 'http://localhost:3000/path?_rsc' prints a route's payload. Without ?_rsc you get the 307 above.
  • Search the payload for data you didn't mean to send (grep for a column name or a secret), which is how the passwordHash leak in Passing data across the boundary shows up.
  • Look at the build output: .next/server/app/<route>.rsc, .meta and .segments/ show what each route prerendered, and .meta's postponed field tells you a route has a dynamic hole.
  • next build --debug-prerender gives readable stack traces for prerender errors (the build itself suggests it). The streaming guide also points to an external RSC Explorer tool for stepping through payloads.
  • Symptoms that point at the payload: navigations turning into full page loads behind a CDN or proxy that strips the rsc header (the CDN guide's warning), hydration mismatches (HTML and payload disagree, like the process.env gotcha), and slow navigations from very large payloads.
Predict the requests

In the payload app, a user lands on /about (not /), waits two seconds, clicks the counter twice, clicks "Home", then "Live". For each click, is there a network request, and what does the counter show at the end?

Show answer

"Home": no request. "Live": one RSC request for the dynamic part. The counter shows "Clicked 2 times" at the end.

What’s happening
  1. Landing on /about is a document request; the HTML carries /about's payload inline. While the user waits, the router prefetches what the links need. I ran exactly this: the trees of /, /about and /live, then the __PAGE__ segments of /live and / (not /about's, which it already had).
  2. Two counter clicks change Counter's state in the browser: "Clicked 2 times". No server involvement.
  3. "Home" is ○ Static, and its page segment was prefetched, so the router rendered it from the client cache: no request, and the page read "Clicked 2 times / Home". The root layout stayed mounted, so the counter kept its state.
  4. "Live" has a dynamic hole. The first text I could read after the click was already "Live / Loading time…" (the prefetched shell), then one rsc: 1 request with the state tree filled in "Rendered at …".
  5. Through both navigations the root layout was never replaced (its "r" was null in the dynamic response), so the counter still says "Clicked 2 times".

Written fresh for interviews (there was no Google Doc for this one). Examples target Next.js 16.4.3 and React 19.3 and were built with next build by scripts/check-next.mjs.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.