Next.js NotesRohit’s interview study guide
Chapter 04

Data Fetching

Where data is read in the App Router, how to keep requests parallel, how streaming decides what the user sees first, what memoization deduplicates, when the browser should fetch, and how to stop private fields leaking into the page.

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

#Fetching data in Server Components

Added

In the App Router, a page is a tree of React components, and by default every one of them is a Server Component: it runs on the server, it can be an async function, and it can await whatever data it needs right where it renders. There's no useEffect, no loading-state boilerplate, and no API endpoint between the component and the data. The component calls fetch, an ORM or a plain function, gets the result, and returns JSX. Only the rendered result travels to the browser, never the code that produced it.

A good mental model: each Server Component is a small request handler for its own slice of the page. In an Express app you'd write one handler that loads everything and then renders a template. Here every component loads what it needs, and React assembles the slices. That's why you'll see fetch calls in leaf components rather than one big loader at the top: the data lives next to the markup that uses it.

Next.js 16 with Cache Components (cacheComponents: true, on by default in new apps) adds one rule on top: at build time Next.js prerenders every route into a static shell, and any read that can't finish during that prerender (an uncached fetch, a database query, a timer, cookies()) has to be explicitly handled. Either wrap it in <Suspense> so it streams in at request time, or cache it with 'use cache' (see the caching model). Leave it bare and the build fails. This chapter uses the Suspense route throughout, because it's about fresh, per-request data.

The examples in this chapter talk to a small fake API (shown at the end of this topic) that waits a fixed time per route, like a real backend would: /products takes 300 ms. Here's a products page that reads it:

.env
API_URL=http://localhost:4704
DB_PASSWORD=hunter2-not-for-browsers
app/products/product-list.tsx
type Product = { id: string; name: string; price: number };

export async function ProductList() {
  const res = await fetch(`${process.env.API_URL}/products`);
  if (!res.ok) throw new Error(`GET /products failed with ${res.status}`);
  const products: Product[] = await res.json();
  console.log(`[ProductList] rendering ${products.length} products`);

  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>
          {p.name}: ${p.price}
        </li>
      ))}
    </ul>
  );
}
app/products/page.tsx
import { Suspense } from "react";
import { ProductList } from "./product-list";

export default function ProductsPage() {
  return (
    <main>
      <h1>Products</h1>
      <Suspense fallback={<p>Loading products…</p>}>
        <ProductList />
      </Suspense>
    </main>
  );
}
What’s happening
  1. ProductList is an async function component. Nothing marks it as a Server Component: in the app/ directory that's the default, so it never ships to the browser and is free to read process.env.API_URL and call the API directly.
  2. await fetch(...) pauses the component, not the server. React keeps rendering the rest of the tree; only this component is waiting for the 300 ms response.
  3. The res.ok check matters: fetch only rejects on network failure, so a 500 would otherwise reach res.json() and fail with a confusing parse error. Throwing hands the failure to the nearest error boundary (error.js).
  4. ProductsPage itself isn't async and awaits nothing. Its <h1> and the <Suspense> fallback can be rendered at build time, so they become the route's static shell.
  5. The <Suspense> boundary is what makes the build pass: it tells Next.js "the part inside may wait for request-time data; show the fallback until it's ready". Without it the build stops with the error shown further down.
AdvancedRoute table, HTML and stream timings

The build output says exactly that: the route is Partial Prerender (◐), a static shell with a dynamic hole.

next build
Route (app)
┌ ○ /_not-found
├ ◐ /products
└ ◐ /stock


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

With the app running (next build && next start -p 4504), curl -s http://localhost:4504/products shows both halves in one HTML response. First the shell, with a placeholder where the list will go:

Output
<main><h1>Products</h1><!--$?--><template id="B:0"></template><p>Loading products…</p><!--/$--></main>

Then, later in the same response, the finished list in a hidden <div> and the script call that swaps it in:

Output
<div hidden id="S:0"><ul><li>Desk lamp<!-- -->: $<!-- -->39</li><li>Notebook<!-- -->: $<!-- -->6</li></ul></div>
…$RC("B:0","S:0")…

A small Node script that timestamps each chunk of the response (observe.mjs, also at the end of this topic) shows when each half arrived:

Output
$ node observe.mjs http://localhost:4504/products "Loading products" "Desk lamp"
status 200 after 43ms
chunk 0 +  45ms   1544 bytes Loading products
…chunks 1–7 (by +46ms): the rest of the shell and the bootstrap scripts…
chunk 8 + 350ms    209 bytes Desk lamp
chunk 10 + 351ms    974 bytes Desk lamp
chunk 11 + 364ms   4628 bytes Loading products
done after 365ms
What’s happening
  1. At 45 ms the client already has the <h1> and "Loading products…": the static shell was rendered by next build, so the server sends it without waiting for any data.
  2. At the same moment the server starts rendering the dynamic hole: ProductList runs and calls the API. The fake API logged GET /products at that point; during next build it logged nothing at all, because Next.js doesn't send uncached fetches while prerendering.
  3. About 300 ms later the response comes back, ProductList finishes, and React streams the rendered <ul> (chunk 8) plus its RSC payload row (chunk 10) into the same, still-open response.
  4. Chunk 11 also mentions "Loading products": it's the RSC payload for hydration, which describes the boundary and its fallback too. <template id="B:0"> marks the boundary, <div hidden id="S:0"> carries the finished content, and $RC("B:0","S:0") is React's inline script that replaces the fallback with it. It runs as soon as it arrives, before the page's JavaScript bundle has even loaded.
  5. The console.log printed [ProductList] rendering 2 products in the terminal running next start, never in the browser console: this code only exists on the server.
AdvancedCalling a database or ORM directly

fetch isn't special. Any async I/O works in a Server Component, including a database client. Because the module only runs on the server, credentials and query code never reach the browser. Here a stand-in ORM client reads stock levels:

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

// Stand-in for an ORM client (Prisma, Drizzle…): same shape, rows in memory, a real wait.
const stock = [
  { sku: "p1", warehouse: "YUL-EAST-7", qty: 12 },
  { sku: "p2", warehouse: "YYZ-NORTH-2", qty: 0 },
];

export const db = {
  stock: {
    async findMany() {
      // A real client would connect with process.env.DB_PASSWORD here.
      console.log("[db] SELECT sku, warehouse, qty FROM stock");
      await new Promise((resolve) => setTimeout(resolve, 200));
      return stock;
    },
  },
};
app/stock/page.tsx
import { Suspense } from "react";
import { db } from "@/lib/db";

async function StockTable() {
  const rows = await db.stock.findMany();
  return (
    <table>
      <tbody>
        {rows.map((r) => (
          <tr key={r.sku}>
            <td>{r.sku}</td>
            <td>{r.qty > 0 ? `${r.qty} in stock` : "Sold out"}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

export default function StockPage() {
  return (
    <main>
      <h1>Stock</h1>
      <Suspense fallback={<p>Checking stock…</p>}>
        <StockTable />
      </Suspense>
    </main>
  );
}
What’s happening
  1. import "server-only" is a tripwire: if a Client Component ever imports this module, the build fails (the real error is in the Data Access Layer topic). Next.js handles the package itself, so it works without installing it.
  2. db.stock.findMany() waits 200 ms on a timer, which Cache Components treats like any uncached I/O: it can't finish during the prerender, so StockTable needs the <Suspense> around it, exactly like the fetch version.
  3. Each row has a warehouse field, but StockTable only renders sku and a stock label. Server Components send their output, not their inputs: curl -s http://localhost:4504/stock | grep -c YUL-EAST-7 printed 0.
  4. The code didn't leak either: searching the browser bundles with grep -rl 'YUL-EAST-7\|hunter2\|SELECT sku' .next/static found nothing. The module is only in .next/server, and process.env.DB_PASSWORD stays a runtime lookup there (the secret itself is in no build file at all).
  5. In the observer, "Checking stock" arrived at +45 ms and "Sold out" at +245 ms: the same shell-then-hole pattern, with the query's 200 ms in between.
AdvancedWhat happens without <Suspense>

Delete the boundary and render <ProductList /> directly in the page. With Cache Components on, next build refuses (this is the real output from that change, built in a copy of the app):

app/products/page.tsx (broken variant, not part of the app)
import { ProductList } from "./product-list";

export default function ProductsPage() {
  return (
    <main>
      <h1>Products</h1>
      <ProductList />
    </main>
  );
}
Output
Error: Route "/products": Next.js encountered uncached or runtime data during prerendering.

`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience.

Ways to fix this:
  - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
  - [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)
  - [block] Set `export const instant = false` to allow a blocking route

Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic
    at main (<anonymous>)
    at body (<anonymous>)
    at html (<anonymous>)
…
Export encountered an error on /products/page: /products, exiting the build.
What’s happening
  1. While prerendering /products, Next.js reached the fetch in ProductList and found no <Suspense> boundary above it before the root (the stack shows main, body, html).
  2. Without a boundary there is no fallback to put in the shell, so the whole page would have to wait for the API on every request. Cache Components treats that as a mistake by default rather than silently making the route slow.
  3. The three fixes are the three choices you always have: stream it (<Suspense>), cache it ('use cache', so the result can be part of the shell), or explicitly accept a blocking route (instant = false).
  4. The same error appeared for a component that only awaited a setTimeout: the check isn't about fetch specifically, it's about any async work that can't complete during the prerender.
AdvancedWhat a Server Component can and can't do
  • Can: be async; read process.env secrets; use Node APIs (fs, crypto); call databases, ORMs and internal services; import heavy libraries that then cost the browser nothing.
  • Can't: use state or effects (useState, useEffect), event handlers (onClick), or browser APIs (window, localStorage). Those need a Client Component (see the 'use client' boundary).
  • Client Components can't be async. React's documentation states that async/await isn't supported in Client Components; read a promise with use() instead (shown in Fetching on the client).
AdvancedHow it used to work
  • Pages Router (Next 12 and earlier, still common): components couldn't fetch on the server. A page exported getServerSideProps (every request) or getStaticProps (build time), the whole page waited for it, and the result arrived as props. See getServerSideProps.
  • App Router, Next 13–14: the same await fetch() code as above, but fetch was cached by default (force-cache), so many pages silently became static and served stale data until you added { cache: 'no-store' } or revalidate. This surprised so many teams that 15 reversed it.
  • Next 15: fetch is not cached by default (the 15 upgrade guide: "fetch requests are no longer cached by default"); opt in with cache: 'force-cache'.
  • Next 16 with Cache Components: still uncached by default, but now the build makes you decide: Suspense or 'use cache'. force-cache, revalidate and route segment config are the previous model, covered in the previous model.
AdvancedThe fake API and the stream observer

Every measurement in this chapter used these two scripts. backend.mjs is the fake API: each route waits a fixed time, and every hit is logged with its offset from the first request of a burst, so you can see which requests start together and which wait for each other.

backend.mjs
// A fake API for this chapter's examples: every route waits on purpose and logs each hit.
import { createServer } from "node:http";

const routes = {
  "/products": [300, [{ id: "p1", name: "Desk lamp", price: 39 }, { id: "p2", name: "Notebook", price: 6 }]],
  "/artists/nina": [300, { id: "a1", name: "Nina Simone" }],
  "/artists/nina/albums": [500, [{ id: "al1", title: "Pastel Blues" }, { id: "al2", title: "Wild Is the Wind" }]],
  "/artists/nina/shows": [400, [{ id: "s1", city: "Montreux" }]],
  "/me": [300, { id: "u1", name: "Rohit", plan: "pro" }],
};
const hits = {};
let epoch = 0;
let last = 0;

createServer(async (req, res) => {
  const { pathname } = new URL(req.url, "http://localhost");
  if (pathname === "/hits") return res.end(JSON.stringify(hits));
  const now = Date.now();
  if (now - last > 1000) epoch = now; // a quiet second starts a new "page load"
  last = now;
  hits[pathname] = (hits[pathname] ?? 0) + 1;
  console.log(`+${String(now - epoch).padStart(4)}ms ${req.method} ${pathname} (hit ${hits[pathname]})`);
  const [ms, body] = routes[pathname] ?? [0, { error: "not found" }];
  await new Promise((r) => setTimeout(r, ms));
  res.writeHead(routes[pathname] ? 200 : 404, { "content-type": "application/json", connection: "close" });
  res.end(JSON.stringify(body));
}).listen(4704, () => console.log("fake API on http://localhost:4704"));
What’s happening
  1. routes maps each path to a delay and a JSON body: the artist takes 300 ms, the albums 500 ms, the shows 400 ms, so overlaps are easy to read off the log.
  2. The epoch resets after a quiet second, so each page load's log starts at +0ms and later lines show how long after the first request they arrived.
  3. hits counts requests per path; it's how the memoization topic proves how many times a request really reached the backend.
  4. connection: "close" is there because of something the first measurements showed: with keep-alive, some requests from next start reached this server about 200 ms after the component called fetch (the albums request after /me arrived at +500 ms instead of +300 ms). With a fresh connection per request the delay disappeared (+302 ms) and the timings became repeatable. It's a localhost connection-reuse effect, not something Next.js does to your code.

observe.mjs reads a response as a stream and prints when each chunk arrived and which of the given strings it contained. It asks for no compression, because gzip buffers chunks and would hide the streaming (the bundled Streaming guide uses the same trick).

observe.mjs
// node observe.mjs <url> [text…]: prints when each chunk of the response arrived and which texts it contained.
const [url, ...marks] = process.argv.slice(2);
const start = performance.now();
const res = await fetch(url, { headers: { "accept-encoding": "identity" } });
console.log(`status ${res.status} after ${Math.round(performance.now() - start)}ms`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let i = 0;
for (;;) {
  const { done, value } = await reader.read();
  if (done) break;
  const text = decoder.decode(value, { stream: true });
  const found = marks.filter((m) => text.includes(m));
  console.log(`chunk ${i++} +${String(Math.round(performance.now() - start)).padStart(4)}ms ${String(value.length).padStart(6)} bytes ${found.join(", ")}`);
}
console.log(`done after ${Math.round(performance.now() - start)}ms`);
What’s happening
  1. fetch resolves as soon as the status line and headers arrive, so "status 200 after 43ms" is the time to first byte, not the time to the full page.
  2. res.body.getReader() hands over the body chunk by chunk as the server flushes it; each read() resolves when the next chunk arrives.
  3. Each line prints the chunk's arrival time, its size and which marker strings it contains, which is how you can tell "fallback sent" from "content arrived".
  4. In the quoted outputs, chunks that contain none of the markers (scripts, the bootstrap) are summarised to keep them short; the numbers on the lines shown are exactly what the script printed.

#Parallel and sequential fetching

Added

A waterfall is a chain of requests where each one can't start until the previous one has finished. Sometimes that's necessary: you need the artist's id before you can ask for their playlists. Much more often it's an accident of how the code is written, because await on its own line pauses everything after it. Three independent 300–500 ms requests written as three awaits take the sum of their times; started together they take the longest one.

The mental model is a kitchen with one cook versus three. await a; await b; await c is one cook who won't put the pasta on until the sauce is done. Starting all three requests first and then awaiting them is three burners going at once: dinner is ready when the slowest dish is. In React Server Components there's a second kind of waterfall that's easy to miss: a child component can't start fetching until its parent has finished rendering, because the child doesn't exist until the parent returns it.

Here is where this topic ends up, measured on next start with the five pages below: the same requests, arranged differently.

0 ms 500 1000 /sequential artist albums shows 1232 /parallel artist albums shows 540 /nested child waits artist albums 829 /gated check first me albums 831 /preload started early albums me 525
Measured on next start: when each request reached the API, and when the content arrived (bold, ms after the request started).

The page below uses three requests against the fake API: the artist (300 ms), their albums (500 ms) and their upcoming shows (400 ms).

.env
API_URL=http://localhost:4704
lib/api.ts
export type Artist = { id: string; name: string };
export type Album = { id: string; title: string };
export type Show = { id: string; city: string };

async function get<T>(path: string): Promise<T> {
  const res = await fetch(`${process.env.API_URL}${path}`);
  if (!res.ok) throw new Error(`GET ${path} failed with ${res.status}`);
  return res.json();
}

export const getArtist = (name: string) => get<Artist>(`/artists/${name}`);
export const getAlbums = (name: string) => get<Album[]>(`/artists/${name}/albums`);
export const getShows = (name: string) => get<Show[]>(`/artists/${name}/shows`);
export const getMe = () => get<{ name: string; plan: "free" | "pro" }>("/me");
app/sequential/page.tsx
import { Suspense } from "react";
import { getAlbums, getArtist, getShows } from "@/lib/api";

async function ArtistDetails() {
  const artist = await getArtist("nina"); // 300 ms
  const albums = await getAlbums("nina"); // 500 ms, starts after the artist arrives
  const shows = await getShows("nina"); // 400 ms, starts after the albums arrive

  return (
    <section>
      <h2>{artist.name}</h2>
      <p>{albums.length} albums, {shows.length} upcoming shows</p>
    </section>
  );
}

export default function Page() {
  return (
    <Suspense fallback={<p>Loading artist…</p>}>
      <ArtistDetails />
    </Suspense>
  );
}
app/parallel/page.tsx
import { Suspense } from "react";
import { getAlbums, getArtist, getShows } from "@/lib/api";

async function ArtistDetails() {
  // All three requests start on these lines, before anything is awaited.
  const artistPromise = getArtist("nina");
  const albumsPromise = getAlbums("nina");
  const showsPromise = getShows("nina");

  const [artist, albums, shows] = await Promise.all([artistPromise, albumsPromise, showsPromise]);

  return (
    <section>
      <h2>{artist.name}</h2>
      <p>{albums.length} albums, {shows.length} upcoming shows</p>
    </section>
  );
}

export default function Page() {
  return (
    <Suspense fallback={<p>Loading artist…</p>}>
      <ArtistDetails />
    </Suspense>
  );
}

The API's log and the stream observer, for one request to each page:

Output
$ node observe.mjs http://localhost:4504/sequential …
chunk 0 +  25ms   1512 bytes Loading
chunk 8 +1232ms    213 bytes Nina Simone, upcoming shows
done after 1236ms
-- API log
+   0ms GET /artists/nina (hit 8)
+ 302ms GET /artists/nina/albums (hit 13)
+ 805ms GET /artists/nina/shows (hit 4)

$ node observe.mjs http://localhost:4504/parallel …
chunk 0 +  38ms   1512 bytes Loading
chunk 8 + 540ms    213 bytes Nina Simone, upcoming shows
done after 545ms
-- API log
+   0ms GET /artists/nina (hit 9)
+   0ms GET /artists/nina/albums (hit 14)
+   0ms GET /artists/nina/shows (hit 5)
What’s happening
  1. In /sequential, await getArtist("nina") suspends ArtistDetails for 300 ms. The getAlbums line hasn't executed yet, so its request doesn't exist: the API saw it at +302 ms.
  2. The same happens again: shows start at +805 ms, after the 500 ms album request. The content arrived at 1232 ms, roughly 300 + 500 + 400 plus rendering.
  3. In /parallel, calling getArtist, getAlbums and getShows without await starts all three fetches immediately; each call returns a promise straight away. The API logged all three at +0 ms.
  4. Promise.all then waits for the slowest (albums, 500 ms). The content arrived at 540 ms: less than half the sequential time, from moving three awaits.
  5. Both pages sent the static shell ("Loading artist…") within 40 ms. The waterfall only delays the part inside the boundary, which is the point of having one.
AdvancedThe component waterfall

The second kind of waterfall has no sequential awaits in it at all. ArtistHeader fetches the artist and renders <Albums>, which fetches the albums. Each component looks fine on its own:

app/nested/page.tsx
import { Suspense } from "react";
import { getAlbums, getArtist } from "@/lib/api";

async function Albums() {
  const albums = await getAlbums("nina");
  return <ul>{albums.map((a) => <li key={a.id}>{a.title}</li>)}</ul>;
}

async function ArtistHeader() {
  const artist = await getArtist("nina");
  // <Albums> only exists once this component has returned, so its fetch starts late.
  return (
    <section>
      <h2>{artist.name}</h2>
      <Albums />
    </section>
  );
}

export default function Page() {
  return (
    <Suspense fallback={<p>Loading artist…</p>}>
      <ArtistHeader />
    </Suspense>
  );
}
app/siblings/page.tsx
import { Suspense } from "react";
import { getAlbums, getArtist, getShows } from "@/lib/api";

async function Name() {
  const artist = await getArtist("nina");
  return <h2>{artist.name}</h2>;
}

async function Albums() {
  const albums = await getAlbums("nina");
  return <ul>{albums.map((a) => <li key={a.id}>{a.title}</li>)}</ul>;
}

async function Shows() {
  const shows = await getShows("nina");
  return <p>Next show: {shows[0]?.city}</p>;
}

export default function Page() {
  return (
    <main>
      <Suspense fallback={<h2>…</h2>}>
        <Name />
      </Suspense>
      <Suspense fallback={<p>Loading albums…</p>}>
        <Albums />
      </Suspense>
      <Suspense fallback={<p>Loading shows…</p>}>
        <Shows />
      </Suspense>
    </main>
  );
}
Output
$ node observe.mjs http://localhost:4504/nested …
chunk 8 + 326ms    148 bytes Nina Simone
chunk 11 + 829ms   1170 bytes Pastel Blues
done after 833ms
-- API log
+   0ms GET /artists/nina (hit 10)
+ 301ms GET /artists/nina/albums (hit 15)

$ node observe.mjs http://localhost:4504/siblings …
chunk 8 + 328ms     97 bytes Nina Simone
chunk 11 + 430ms     94 bytes Montreux
chunk 13 + 528ms    115 bytes Pastel Blues
done after 532ms
-- API log
+   0ms GET /artists/nina (hit 11)
+   1ms GET /artists/nina/albums (hit 16)
+   1ms GET /artists/nina/shows (hit 6)
What’s happening
  1. In /nested, React renders ArtistHeader, which suspends on the artist request. <Albums /> is part of what ArtistHeader returns, so React doesn't know it exists yet: the albums request reached the API at +301 ms, right after the artist came back.
  2. Total: 829 ms, the same shape as two sequential awaits, without a single sequential await in the code. This is the waterfall that code review misses.
  3. In /siblings, the page returns all three components at once, so React renders them all in the same pass and all three requests start within 1 ms.
  4. Each sibling has its own <Suspense>, so each streams on its own schedule: the name at 328 ms, the show at 430 ms, the albums at 528 ms. The page is complete at the slowest one, like Promise.all, but the user sees the fast parts early.
  5. The fix for a component waterfall is structural: hoist the requests so they start together (start promises in the parent and pass them down, or render siblings instead of nesting), or start the child's request early with a preload, below.
What does the user see on /nested at 400 ms?

The observer shows a chunk containing "Nina Simone" arriving at 326 ms on /nested. Does the user see the artist's name at 400 ms?

Show answer

No. The page still shows "Loading artist…" until about 830 ms, even though the name's HTML has already arrived.

What’s happening
  1. ArtistHeader finished at about 326 ms, and React streamed what it had: the chunk was <div hidden id="S:0"><section><h2>Nina Simone</h2><template id="P:1"></template></section></div>. Note the hidden attribute.
  2. <template id="P:1"> is a hole for <Albums>, which is still waiting. Both components are inside the same <Suspense> boundary, and React only reveals a boundary when everything inside it is ready.
  3. At 829 ms the albums arrived as <div hidden id="S:1">…</div> with $RS("S:1","P:1") (fill the hole), followed by $RC("B:0","S:0") (reveal the boundary). Only then is the name visible.
  4. Streaming granularity is per boundary, not per component. To show the name at 326 ms, give <Albums> its own <Suspense> inside ArtistHeader: then the outer boundary can reveal with an inner "Loading albums…" fallback.
AdvancedStarting a request early: the preload pattern

Sometimes a request genuinely has to wait for something before it can be used, but not before it can start. Here only "pro" members see albums, so the page checks the user's plan first. Written naturally, the album request waits for the check; with a preload it starts at the same time and the check decides whether to use it.

components/albums.tsx
import { getAlbums } from "@/lib/api";

// Starts the request without waiting for it. The promise is dropped on purpose:
// the identical fetch inside <Albums> picks up the same in-flight request.
export const preloadAlbums = (name: string) => {
  void getAlbums(name);
};

export async function Albums({ name }: { name: string }) {
  const albums = await getAlbums(name);
  return <ul>{albums.map((a) => <li key={a.id}>{a.title}</li>)}</ul>;
}
app/gated/page.tsx
import { Suspense } from "react";
import { getMe } from "@/lib/api";
import { Albums } from "@/components/albums";

async function MemberAlbums() {
  const me = await getMe(); // 300 ms: only "pro" members see albums
  if (me.plan !== "pro") return <p>Upgrade to see albums.</p>;
  return <Albums name="nina" />; // its 500 ms fetch starts only now
}

export default function Page() {
  return (
    <Suspense fallback={<p>Loading albums…</p>}>
      <MemberAlbums />
    </Suspense>
  );
}
app/preload/page.tsx
import { Suspense } from "react";
import { getMe } from "@/lib/api";
import { Albums, preloadAlbums } from "@/components/albums";

async function MemberAlbums() {
  preloadAlbums("nina"); // start the 500 ms fetch now…
  const me = await getMe(); // …while this 300 ms check runs
  if (me.plan !== "pro") return <p>Upgrade to see albums.</p>;
  return <Albums name="nina" />; // same URL and options: reuses the request above
}

export default function Page() {
  return (
    <Suspense fallback={<p>Loading albums…</p>}>
      <MemberAlbums />
    </Suspense>
  );
}
Output
$ node observe.mjs http://localhost:4504/gated …
chunk 8 + 831ms    197 bytes Pastel Blues
done after 835ms
-- API log
+   0ms GET /me (hit 6)
+ 303ms GET /artists/nina/albums (hit 17)

$ node observe.mjs http://localhost:4504/preload …
chunk 8 + 525ms    197 bytes Pastel Blues
done after 530ms
-- API log
+   0ms GET /artists/nina/albums (hit 18)
+   0ms GET /me (hit 7)
What’s happening
  1. In /gated, await getMe() holds MemberAlbums for 300 ms; <Albums> is only returned afterwards, so its request reached the API at +303 ms and the albums appeared at 831 ms.
  2. In /preload, preloadAlbums("nina") runs first. It calls getAlbums, which calls fetch, and throws the promise away (void), so the request is in flight before getMe() even starts: the API logged both at +0 ms.
  3. When <Albums> later calls getAlbums("nina") again, it doesn't send a second request. Next.js memoizes GET fetches with the same URL and options within a render, so the second call gets the in-flight response: the album hit counter went from 17 to 18, one request per page load.
  4. The albums appeared at 525 ms instead of 831 ms: the 300 ms check now overlaps the 500 ms fetch.
  5. The preload only works because the second call is deduplicated. For a database call that isn't fetch, wrap the function in React's cache() (next topic) or 'use cache', or the "preload" would just run the query twice.
AdvancedPractical rules

The build output for this app: every route is a static shell (the fallback) with one streamed hole, however its fetches are arranged inside.

next build
Route (app)
┌ ○ /_not-found
├ ◐ /gated
├ ◐ /nested
├ ◐ /parallel
├ ◐ /preload
├ ◐ /sequential
└ ◐ /siblings


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
  • Start independent requests before awaiting any of them: call the functions, keep the promises, then await Promise.all([...]). Or render the consumers as siblings, each in its own <Suspense>.
  • Promise.all fails fast: one rejected request rejects the whole thing and you lose the other results. Use Promise.allSettled when a partial page is better than an error page.
  • Watch for component waterfalls: a component that awaits and then renders a child that awaits is a chain. Nested <Suspense> boundaries make it visible as a staircase of fallbacks.
  • Layouts and pages render in parallel: the docs note that each route segment starts fetching as soon as possible, so a layout's request doesn't hold up the page's. The waterfalls happen inside components.
  • Keep genuine dependencies: if request B needs data from A (the artist's id), it is sequential by nature. Then make A fast or cache it, and put B in its own boundary so the rest of the page isn't held up.

#Streaming with Suspense

Added

Streaming means the server sends the page in pieces over one HTTP response: first everything that's ready (the layout, headings, fallbacks), then each slow section as soon as its data arrives. The browser paints the first piece immediately and swaps in the rest as it comes. Without streaming, a page is as slow as its slowest query; with it, the time to first byte is the time to render the shell, and each section pays only for its own data.

<Suspense> boundaries are the seams. Each boundary is an independent unit: it shows its fallback until everything inside it is ready, then React streams the finished HTML plus a tiny inline script that replaces the fallback. Think of a restaurant that brings each dish when it's cooked instead of holding the whole table's order until the slowest plate is done. Where you put the boundaries decides what arrives together.

With Cache Components, the part before any boundary isn't even rendered per request: it's the static shell prerendered at build time, which is why the first bytes on these pages arrived in 25–45 ms. The Routing chapter covers loading.js as a route convention; this topic is about what streaming does to the data and the timeline.

The dashboard below, measured: what the browser showed at four moments, and when each piece left the server.

163 ms Dashboard Loading revenue… Loading orders… Loading recs… 401 ms Dashboard Revenue: $1840 Loading orders… Loading recs… 1219 ms Dashboard Revenue: $1840 o-1007, o-1006 Loading recs… 2322 ms Dashboard Revenue: $1840 o-1007, o-1006 Desk lamp, … One HTTP response shell 46 252 1050 2050
What headless Chrome showed at four moments (top), and when each chunk left the server (bottom, ms).
lib/db.ts
// Stand-in for real queries: each one takes a fixed time so the streaming order is predictable.
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export async function getRevenue() {
  await wait(200);
  return { today: 1840 };
}

export async function getOrders() {
  await wait(1000);
  return [{ id: "o-1007", total: 64 }, { id: "o-1006", total: 18 }];
}

export async function getRecommendations() {
  await wait(2000);
  return ["Desk lamp", "Notebook", "Fountain pen"];
}
app/dashboard/page.tsx
import { Suspense } from "react";
import { getOrders, getRecommendations, getRevenue } from "@/lib/db";

async function Revenue() {
  const { today } = await getRevenue(); // 200 ms
  return <p>Revenue today: ${today}</p>;
}

async function RecentOrders() {
  const orders = await getOrders(); // 1000 ms
  return <ul>{orders.map((o) => <li key={o.id}>{o.id}: ${o.total}</li>)}</ul>;
}

async function Recommendations() {
  const items = await getRecommendations(); // 2000 ms
  return <p>Recommended: {items.join(", ")}</p>;
}

export default function Dashboard() {
  return (
    <main>
      <h1>Dashboard</h1>
      <Suspense fallback={<p>Loading revenue…</p>}>
        <Revenue />
      </Suspense>
      <Suspense fallback={<p>Loading orders…</p>}>
        <RecentOrders />
      </Suspense>
      <Suspense fallback={<p>Loading recommendations…</p>}>
        <Recommendations />
      </Suspense>
    </main>
  );
}
Output
$ node observe.mjs http://localhost:4504/dashboard "Loading revenue" "Revenue today" "o-1007" "Recommended:"
status 200 after 45ms
chunk 0 +  46ms   1697 bytes Loading revenue
…shell and bootstrap scripts, chunks 1–8 by +65ms…
chunk 10 + 252ms    108 bytes Revenue today
chunk 13 +1050ms    141 bytes o-1007
chunk 15 +2050ms    121 bytes Recommended:
done after 2050ms

What a real browser showed (headless Chrome, reading the page's text at set times after navigation started):

Output
163ms: Dashboard | Loading revenue… | Loading orders… | Loading recommendations…
401ms: Dashboard | Revenue today: $1840 | Loading orders… | Loading recommendations…
1219ms: Dashboard | Revenue today: $1840 | o-1007: $64 | o-1006: $18 | Loading recommendations…
2322ms: Dashboard | Revenue today: $1840 | o-1007: $64 | o-1006: $18 | Recommended: Desk lamp, Notebook, Fountain pen
What’s happening
  1. Dashboard awaits nothing, so its <h1> and the three fallbacks are the static shell. The server sent it at 46 ms, and the browser showed it at 163 ms (navigation started from a blank tab, so that includes connection and parse time).
  2. At request time the three components start rendering together; their queries run in parallel (200, 1000, 2000 ms).
  3. Each boundary streamed the moment its own data was ready: revenue at 252 ms, orders at 1050 ms, recommendations at 2050 ms. The browser readings at 401, 1219 and 2322 ms show each fallback replaced in turn, without waiting for the others.
  4. The response stayed open for the whole 2 s: it's one HTTP response with Transfer-Encoding: chunked, not four requests. Each piece arrived as HTML plus an inline swap script, which (as the bundled Streaming guide explains) doesn't need the page's JavaScript bundle to run.
  5. Compare the total: 2050 ms either way. Streaming didn't make the slow query faster; it stopped the slow query from delaying the fast ones.
AdvancedOne boundary for everything: loading.js

The same three reads, awaited together at the top of the page, with a loading.tsx next to it. loading.tsx wraps the whole page in a single <Suspense> boundary, so the page appears all at once:

app/report/page.tsx
import { getOrders, getRecommendations, getRevenue } from "@/lib/db";

// The same three reads, awaited together at the top of the page.
export default async function Report() {
  const [revenue, orders, items] = await Promise.all([getRevenue(), getOrders(), getRecommendations()]);
  return (
    <main>
      <h1>Report</h1>
      <p>Revenue today: ${revenue.today}</p>
      <p>{orders.length} recent orders</p>
      <p>Recommended: {items.join(", ")}</p>
    </main>
  );
}
app/report/loading.tsx
export default function Loading() {
  return <p>Loading report…</p>;
}
Output
$ node observe.mjs http://localhost:4504/report "Loading report" "Revenue today" "Recommended:"
status 200 after 47ms
chunk 0 +  48ms   1569 bytes Loading report
chunk 9 +2049ms    342 bytes Revenue today, Recommended:
done after 2050ms
What’s happening
  1. Report is async and awaits all three reads, so it can't be part of the static shell; without loading.tsx the build would fail exactly like the products page did.
  2. loading.tsx is a boundary Next.js puts around the page for you, so the shell is the layout plus "Loading report…" (sent at 48 ms).
  3. Because there's one boundary, everything inside it reveals together: revenue and recommendations arrived in the same chunk at 2049 ms, held back by the slowest query.
  4. That's the trade-off: loading.js is one file and no thought, but the page is only as fast as its slowest read. Granular <Suspense> boundaries, as on /dashboard, cost a few lines and let the fast parts show 1.8 s earlier.
  5. The bundled Streaming guide also notes a navigation difference: a loading.js fallback is prefetched for instant navigation, while a <Suspense> fallback inside a page isn't prefetched by default. That table describes the classic prefetch; with partialPrefetching: true (this template) the prefetched static shell includes in-page fallbacks too: in chapter 03's payload app the router prefetched /live's page segment, and the first text readable after the click was its "Loading time…" fallback.
AdvancedStatus codes, errors and SEO

Streaming has one hard constraint: the status code and headers go out with the first chunk. Once the shell has been sent with 200 OK, nothing that happens inside a boundary can change it. This page looks up an order that doesn't exist, inside a boundary:

app/order/page.tsx
import { Suspense } from "react";
import { notFound } from "next/navigation";
import { getOrders } from "@/lib/db";

async function OrderDetails() {
  const orders = await getOrders();
  const order = orders.find((o) => o.id === "o-9999");
  if (!order) notFound(); // runs after streaming has started
  return <p>Order {order.id}</p>;
}

export default function OrderPage() {
  return (
    <main>
      <h1>Order</h1>
      <Suspense fallback={<p>Loading order…</p>}>
        <OrderDetails />
      </Suspense>
    </main>
  );
}
Output
$ node observe.mjs http://localhost:4504/order "Loading order" "noindex"
status 200 after 42ms
chunk 0 +  42ms   1538 bytes Loading order
chunk 10 +1044ms    112 bytes noindex
done after 1045ms

headless Chrome after load:
{ "text": "404\nThis page could not be found.",
  "title": "404: This page could not be found.",
  "robots": "noindex" }
What’s happening
  1. The shell ("Order", "Loading order…") went out at 42 ms with status 200, before OrderDetails had its data.
  2. One second later notFound() ran. The status line was long gone, so Next.js did the only thing left: it streamed the not-found UI and injected <meta name="robots" content="noindex"/> so search engines won't index the page.
  3. The browser ended up showing the standard 404 page with the 404 title, but any client checking the status code (a crawler, a monitoring probe, fetch in a test) saw 200.
  4. To get a real 404, decide before streaming starts: check existence before any <Suspense> boundary or await that streams, or reject the request earlier in proxy.ts or next.config redirects (both run before rendering). The Streaming guide says the same of redirect(): inside a streamed boundary it becomes a client-side redirect, not an HTTP redirect.

Errors follow the same rule (from the same guide; not measured here): if a component throws after streaming has started, the nearest error boundary replaces just that section and the rest of the page stays; the status stays 200.

Bots and SEO. Streamed content is in the same HTML response, so a crawler that reads the whole response sees all of it; the question is metadata. For "HTML-limited" bots (Twitterbot, Slackbot and others in Next.js's list), Next.js waits for generateMetadata so the <head> is complete. With Cache Components, the docs add that these bots skip the prerendered shell and get a dynamic render. That matched what I measured: requesting /dashboard with User-Agent: Twitterbot/1.0, the response had no x-nextjs-prerender or x-nextjs-postponed headers (the same request with curl's default user agent had both), yet it still streamed: revenue at 225 ms, orders at 1025 ms, recommendations at 2026 ms. This page has static metadata, so there was nothing to wait for. (The docs' older example of a bot getting the whole document "in a single burst" describes apps without Cache Components; I didn't measure that setup.)

AdvancedPlacing boundaries well

All three pages of this app built as static shells with streamed holes; /report got its shell from loading.tsx, the other two from their own boundaries:

next build
Route (app)
┌ ○ /_not-found
├ ◐ /dashboard
├ ◐ /order
└ ◐ /report


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
  • Put the boundary around the slow data, not around the page. Everything outside a boundary is in the shell. The more of the page that's outside, the more the user sees at once.
  • Keep the LCP element out of boundaries. A hero heading or image inside a boundary can't paint until that boundary resolves, which hurts Largest Contentful Paint.
  • Make fallbacks the same size as the content. A skeleton with the final height avoids layout shift (CLS) when the content swaps in.
  • Group what should appear together. Sibling boundaries reveal in data order, which can make a layout jump around. Wrap related sections in one boundary if they should appear as a unit, or nest boundaries for a deliberate top-to-bottom reveal.
  • Push runtime access down. Awaiting cookies(), params or a fetch at the top of a layout makes everything below it dynamic. Pass the promise down and await it in the small component that needs it, inside its own boundary.
AdvancedHow it used to work
  • Pages Router: no streaming of data. getServerSideProps had to finish before any HTML was sent, so the slowest query set the time to first byte for the whole page. React 18 made streaming SSR possible, and the App Router (Next 13) built on it with loading.js and <Suspense>.
  • App Router before Cache Components (13–15): streaming worked the same way, but a route was either static or dynamic as a whole. One cookies() call made the entire page render per request, so even the "shell" was rendered on every request instead of being prerendered.
  • Next 16 with Cache Components: the shell is prerendered at build time and served immediately; only the holes render per request. This is Partial Prerendering, covered in Partial Prerendering.

#Request memoization and React cache()

Added

Fetching data where it's used means several components often need the same data in the same request: the layout shows the user's name, the page greets them, a badge shows their plan. Passing it down as props would undo the "fetch where you render" design, so React and Next.js deduplicate instead. Request memoization means: during one render, identical calls run once and every caller gets the same result.

There are two mechanisms. For fetch, Next.js does it automatically: GET requests with the same URL and options are memoized during a render pass. For everything else (an ORM, a database driver, an SDK), you wrap the function in React's cache(), which gives it a per-render memo keyed by its arguments. Both are like a whiteboard in a meeting room: the first person to look something up writes the answer on the board, everyone else in the meeting reads it from there, and the board is wiped when the meeting ends. Nothing survives to the next request; that's what the persistent caches ('use cache') in the next chapter are for.

The account page below asks for the signed-in user three times: once in the layout and twice in the page. The fake API counts every request it really receives.

.env
API_URL=http://localhost:4704
lib/api.ts
export type Me = { id: string; name: string; plan: "free" | "pro" };

export async function getMe(): Promise<Me> {
  const res = await fetch(`${process.env.API_URL}/me`);
  return res.json();
}

// Same request, but with its own AbortSignal: Next.js can't treat two calls as identical any more.
export async function getMeUnmemoized(): Promise<Me> {
  const res = await fetch(`${process.env.API_URL}/me`, { signal: new AbortController().signal });
  return res.json();
}
app/account/layout.tsx
import { Suspense } from "react";
import { getMe } from "@/lib/api";

async function UserName() {
  const me = await getMe(); // call 1
  return <span>Signed in as {me.name}</span>;
}

export default function AccountLayout({ children }: LayoutProps<"/account">) {
  return (
    <>
      <header>
        <Suspense fallback={<span>…</span>}>
          <UserName />
        </Suspense>
      </header>
      {children}
    </>
  );
}
app/account/page.tsx
import { Suspense } from "react";
import { getMe } from "@/lib/api";

async function Greeting() {
  const me = await getMe(); // call 2
  return <h1>Hello, {me.name}</h1>;
}

async function PlanBadge() {
  const me = await getMe(); // call 3
  return <p>Plan: {me.plan}</p>;
}

export default function AccountPage() {
  return (
    <Suspense fallback={<p>Loading account…</p>}>
      <Greeting />
      <PlanBadge />
    </Suspense>
  );
}
app/opt-out/page.tsx
import { Suspense } from "react";
import { getMeUnmemoized } from "@/lib/api";

async function Greeting() {
  const me = await getMeUnmemoized();
  return <h1>Hello, {me.name}</h1>;
}

async function PlanBadge() {
  const me = await getMeUnmemoized();
  return <p>Plan: {me.plan}</p>;
}

export default function OptOutPage() {
  return (
    <Suspense fallback={<p>Loading account…</p>}>
      <Greeting />
      <PlanBadge />
    </Suspense>
  );
}

The API's log for two loads of /account and one of /opt-out (each curl -s http://localhost:4504/<path>):

Output
== /account
+   0ms GET /me (hit 8)
== /account
+   0ms GET /me (hit 9)
== /opt-out
+   0ms GET /me (hit 10)
+   0ms GET /me (hit 11)
What’s happening
  1. On /account, UserName (layout), Greeting and PlanBadge (page) all render in the same request and all call getMe(), which calls fetch with the same URL and no options.
  2. The first call starts the request; Next.js records it under a key built from the URL and options. The second and third calls find the same key and get the same in-flight promise. The API saw one GET /me per page load (hits 8 and 9), not three.
  3. The second page load got its own request (hit 9): memoization lives only as long as one request's render. It's deduplication, not caching.
  4. On /opt-out, each call creates a new AbortController().signal. A signal makes the options different every time, so the calls no longer match, and the API saw two requests (hits 10 and 11). That's the documented way to opt a fetch out of memoization.
  5. All three components could call getMe() instead of receiving me as a prop because of this. The layout couldn't pass it to the page anyway (next topic).
How many requests does /account make?

The layout calls getMe() once, the page calls it twice. How many requests reach the API for one load of /account, and how many for /opt-out, where the page calls getMeUnmemoized() twice and its layout isn't involved?

Show answer

One for /account, two for /opt-out (measured: hits 8 and 9 for two loads of /account; hits 10 and 11 for one load of /opt-out).

What’s happening
  1. Memoization applies across the whole React tree of one request: layouts, pages and every nested Server Component share it. So the layout's call and the page's two calls are the same request.
  2. The key is the URL plus the options. getMe() passes identical options every time, so all three calls match.
  3. getMeUnmemoized() passes a fresh AbortSignal per call; the options never match, so both calls go out.
  4. /opt-out has no layout of its own; the root layout doesn't call the API. So two calls, two requests.
AdvancedReact cache() for everything that isn't fetch

A database query isn't a fetch, so Next.js can't deduplicate it. React's cache(fn) returns a wrapped function with a per-render memo: the first call with given arguments runs fn, later calls with the same arguments (compared with Object.is) return the stored result. The page below calls a query twice per component in three ways:

lib/db.ts
let queries = 0;

// Stand-in for an ORM query: it logs every time it really runs.
export async function getOrders(userId: string) {
  console.log(`[db] query #${++queries}: SELECT * FROM orders WHERE user_id = '${userId}'`);
  await new Promise((resolve) => setTimeout(resolve, 200));
  return [{ id: "o-1007", total: 64 }, { id: "o-1006", total: 18 }];
}

export async function getOrdersFor(user: { id: string }) {
  return getOrders(user.id);
}
lib/orders.ts
import { cache } from "react";
import { getOrders, getOrdersFor } from "./db";

// One shared, module-level wrapper: every component that imports it shares its per-request memo.
export const getOrdersCached = cache(getOrders);

// Object argument: cache() compares arguments with Object.is, so a fresh object never matches.
export const getOrdersForCached = cache(getOrdersFor);
app/orders/page.tsx
import { Suspense } from "react";
import { getOrders } from "@/lib/db";
import { getOrdersCached, getOrdersForCached } from "@/lib/orders";

async function Plain() {
  const a = await getOrders("u1");
  const b = await getOrders("u1");
  return <p>Plain: {a.length + b.length} rows</p>;
}

async function Cached() {
  const a = await getOrdersCached("u1");
  const b = await getOrdersCached("u1");
  return <p>cache(): {a.length + b.length} rows</p>;
}

async function CachedObjectArg() {
  const a = await getOrdersForCached({ id: "u1" });
  const b = await getOrdersForCached({ id: "u1" });
  return <p>cache() with objects: {a.length + b.length} rows</p>;
}

export default function OrdersPage({ searchParams }: PageProps<"/orders">) {
  return (
    <Suspense fallback={<p>Loading orders…</p>}>
      {searchParams.then(({ mode }) =>
        mode === "plain" ? <Plain /> : mode === "object" ? <CachedObjectArg /> : <Cached />,
      )}
    </Suspense>
  );
}
Output
next start terminal, one request each:

== /orders?mode=plain
[db] query #1: SELECT * FROM orders WHERE user_id = 'u1'
[db] query #2: SELECT * FROM orders WHERE user_id = 'u1'
[db] query #3: SELECT * FROM orders WHERE user_id = 'u1'
[db] query #4: SELECT * FROM orders WHERE user_id = 'u1'
== /orders
[db] query #5: SELECT * FROM orders WHERE user_id = 'u1'
[db] query #6: SELECT * FROM orders WHERE user_id = 'u1'
== /orders?mode=object
[db] query #7: SELECT * FROM orders WHERE user_id = 'u1'
[db] query #8: SELECT * FROM orders WHERE user_id = 'u1'
[db] query #9: SELECT * FROM orders WHERE user_id = 'u1'
[db] query #10: SELECT * FROM orders WHERE user_id = 'u1'
What’s happening
  1. The page reads searchParams inside the boundary (searchParams.then(...)), so the static shell is just the fallback, and ?mode= picks which component renders.
  2. Plain calls getOrders("u1") twice, and the query ran 4 times for one request, not 2. The reason is the next section: on this setup Next.js renders the dynamic part of the page twice per request, and each render ran both calls.
  3. Cached calls getOrdersCached("u1") twice and the query ran 2 times: once per render. Within each render, the second call with the same string argument returned the first call's result. Half the queries of Plain.
  4. CachedObjectArg passes { id: "u1" } twice. Two object literals are two different objects, Object.is says they differ, so cache() treated them as different arguments: 4 queries, no better than no cache at all. Pass primitives (ids, slugs) to cached functions.
  5. getOrdersCached is created once at module level and imported. If you called cache(getOrders) inside a component, every render would create a new wrapper with an empty memo, and nothing would be shared.
AdvancedTwo render passes per request with partial prefetching

Why did the query run twice even with cache()? Next.js 16.3 added Partial Prefetching (partialPrefetching: true; create-next-app sets it next to cacheComponents, and its docs page says leaving it unset behaves like false). With it, a request-time render does extra work: a comment in Next.js 16.4.3's own source (next/dist/server/app-render/app-render.js) says Next.js will "piggyback on the dynamic render to fill caches and then spawn a final runtime prerender whose result stream is embedded in the RSC payload", and, for HTML requests, that the result is embedded "so the client can cache runtime-prefetchable content during hydration". Producing it re-runs your components, for document requests and for client navigations alike (an RSC: 1 request for a dynamic page also logged both passes). The bundled documentation describes runtime prerenders only for prefetches, not this extra render on every dynamic request; the counts above are what led to it.

Timestamped logs from a copy of the app (each query also logs when it resolves) show the two passes side by side for one request to ?mode=plain:

Output
[db] t=2275 query #1: SELECT * FROM orders WHERE user_id = 'u1'
[db] t=2290 query #2: SELECT * FROM orders WHERE user_id = 'u1'
[db] t=2477 query #1 resolved
[db] t=2477 query #3: SELECT * FROM orders WHERE user_id = 'u1'
[db] t=2492 query #2 resolved
[db] t=2492 query #4: SELECT * FROM orders WHERE user_id = 'u1'
[db] t=2678 query #3 resolved
[db] t=2693 query #4 resolved

The same app rebuilt with partialPrefetching: false (one line changed in next.config.ts, everything else identical) gave the textbook numbers:

Output
== /account      +   0ms GET /me (hit 14)
== /opt-out      +   0ms GET /me (hit 15)
                 +   0ms GET /me (hit 16)
== /orders?mode=plain   [db] query #1 … [db] query #2
== /orders              [db] query #3
== /orders?mode=object  [db] query #4 … [db] query #5
What’s happening
  1. In the timestamped log, query #1 starts at 2275 and #2 at 2290: two renders of Plain, 15 ms apart, each making its first call. 200 ms later each one's first call resolves and it makes its second call (#3 at 2477, #4 at 2492).
  2. So per render, Plain did exactly what the code says: two sequential queries. There were just two renders.
  3. With partialPrefetching: false there's one render per request: Plain ran 2 queries, Cached 1, the object version 2. That's the behaviour every tutorial describes.
  4. fetch was not doubled in either setup: /account made one API request and /opt-out two, with the option on or off. In a review copy I logged React's work-unit type on a line before and a line after an await fetch in a component: the request render printed both lines (request), the second pass only the line before the fetch (prerender-runtime). The component did run again, but in a prerender an uncached fetch is never sent and never resolves (the API saw one hit), so nothing after it ran. My database stub only waits on a timer, which a prerender doesn't hold back, so it ran in full in both passes.
  5. await connection() stops the second pass the same way: in the review copy a component logged request before and after connection(), and prerender-runtime only before it. That's why the /promise route in Fetching on the client logged one read per request. In a sandbox route a 'use cache' function (also behind connection()) ran once on the first request and not at all on the second, as you'd expect from a persistent cache.
AdvancedWhere memoization does not apply
app/api/me/route.ts
import { getMe } from "@/lib/api";

// Route Handlers are not rendered by React, so nothing memoizes these two calls.
export async function GET() {
  const first = await getMe();
  const second = await getMe();
  return Response.json({ same: first.id === second.id });
}
Output
$ curl -s http://localhost:4504/api/me
{"same":true}
-- API log
+   0ms GET /me (hit 12)
+ 302ms GET /me (hit 13)
What’s happening
  1. A Route Handler is a plain request handler, not a React render, so there's no render pass for memoization to attach to.
  2. The two identical getMe() calls made two real requests, 302 ms apart because they're awaited one after the other.
  3. The authentication guide says the same of cache(): "Server Actions and Route Handlers are not part of a render pass, so the wrapper does nothing and every call runs the function."
  4. The build marked it ƒ /api/me (dynamic) by itself, because the handler awaits uncached data:
next build
Route (app)
┌ ○ /_not-found
├ ◐ /account
├ ƒ /api/me
├ ◐ /opt-out
└ ◐ /orders


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand

The complete list of things that won't be deduplicated:

  • Route Handlers and Server Actions: not a render pass.
  • Non-GET fetches: memoization covers GET only; a POST that happens to be a read (GraphQL!) runs every time. Wrap it in cache().
  • Different URL or options: a different header, query string order, cache option or a signal makes it a different request.
  • cache() with object arguments built per call, or a cache() wrapper created inside a component.
  • Client Components: React's cache is for Server Components; in the browser use a client data library's cache.
  • Across requests: the memo is gone when the request ends. For that, you need a cache with a lifetime ('use cache', or force-cache in the previous model).
AdvancedHow it used to work
  • Next 13–14: the same fetch memoization existed, but it was easy to confuse with the data cache, because fetch was also cached across requests by default. Many explanations from that time blur "deduplicated" and "cached".
  • unstable_cache vs cache: unstable_cache (Next 13–15, still exported) is a persistent cache for non-fetch functions, with revalidate and tags. React's cache is per request. Mixing them up is a classic interview trap; 'use cache' replaces unstable_cache in the Cache Components model.
  • Pages Router: no memoization at all. getServerSideProps ran once per request and you passed the result down as props, or used a context.

#Fetching on the client

Added

Server Components cover most reads, but some data belongs in the browser: things that change after the page has loaded (a notification count, a live price), things that depend on browser-only APIs (geolocation, localStorage, the file the user just picked), and things fetched in response to typing or clicking (autocomplete). For those, a Client Component fetches from an endpoint, usually a Route Handler in the same app.

The cost is a later start. A client fetch can't begin until the HTML has arrived, the JavaScript has downloaded and React has hydrated the component; only then does the effect run and the request leave. Think of it as ordering at the table instead of having the meal waiting: fine for the dessert you decide on later, bad for the main course everyone came for. The usual compromise is to have the server provide the initial value in the HTML and let the client take over from there (polling, refetching, mutations).

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

export type Notification = { id: number; text: string };

let reads = 0;
const inbox: Notification[] = [
  { id: 1, text: "Your order o-1007 shipped" },
  { id: 2, text: "Nina Simone added a show in Montreux" },
];

export async function getNotifications(source: string): Promise<Notification[]> {
  console.log(`[db] read #${++reads} from ${source}`);
  await new Promise((resolve) => setTimeout(resolve, 300));
  return inbox;
}
app/api/notifications/route.ts
import { getNotifications } from "@/lib/notifications";

// The browser's API: a GET Route Handler that returns JSON.
export async function GET() {
  return Response.json(await getNotifications("route handler"));
}
components/bell.tsx
"use client";

import { useEffect, useState } from "react";

type Notification = { id: number; text: string };

export function Bell({ initial }: { initial?: Notification[] }) {
  const [items, setItems] = useState<Notification[] | null>(initial ?? null);

  useEffect(() => {
    let cancelled = false;
    const load = async () => {
      const res = await fetch("/api/notifications");
      const next: Notification[] = await res.json();
      if (!cancelled) setItems(next);
    };
    if (!initial) load(); // nothing from the server: fetch straight away
    const timer = setInterval(load, 10_000); // then poll
    return () => {
      cancelled = true;
      clearInterval(timer);
    };
  }, [initial]);

  if (!items) return <p>Loading notifications…</p>;
  return (
    <ul>
      {items.map((n) => (
        <li key={n.id}>{n.text}</li>
      ))}
    </ul>
  );
}
app/client-only/page.tsx
import { Bell } from "@/components/bell";

export default function Page() {
  return (
    <main>
      <h1>Inbox</h1>
      <Bell />
    </main>
  );
}
app/server-first/page.tsx
import { Suspense } from "react";
import { Bell } from "@/components/bell";
import { getNotifications } from "@/lib/notifications";

async function BellWithData() {
  const initial = await getNotifications("server component");
  return <Bell initial={initial} />;
}

export default function Page() {
  return (
    <main>
      <h1>Inbox</h1>
      <Suspense fallback={<p>Loading notifications…</p>}>
        <BellWithData />
      </Suspense>
    </main>
  );
}
next build
Route (app)
┌ ○ /_not-found
├ ƒ /api/notifications
├ ○ /client-only
├ ◐ /promise
└ ◐ /server-first


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand

What each page sends and what the browser does with it (curl for the HTML and headers; headless Chrome for the timeline, which read the page text and the Resource Timing entries):

Output
== curl /client-only: in the HTML?
   1 Loading notifications…
Cache-Control: s-maxage=31536000
== curl /server-first: in the HTML?
   3 Loading notifications…
   2 Your order o-1007 shipped
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate

== browser /client-only
 "259ms: Inbox | Loading notifications…",
 "602ms: Inbox | Your order o-1007 shipped | Nina Simone added a show in Montreux",
 "fetch /api/notifications started 56ms, done 369ms"
== browser /server-first, after 11.5 s
 "fetches": [ "/api/notifications @10335ms" ]
What’s happening
  1. /client-only renders <Bell /> with no data, and nothing on the server awaits anything, so the whole route is static (○) and served with a one-year s-maxage. Its HTML contains only "Loading notifications…".
  2. In the browser, the request to /api/notifications started at 56 ms, after the HTML and the JavaScript had loaded and Bell's effect had run. It finished at 369 ms (300 ms in the fake query), and the list was on screen by the 602 ms reading.
  3. /server-first reads the same data in a Server Component and passes it as initial. The HTML already contains "Your order o-1007 shipped" (streamed into the boundary), and the browser made no request at load: the only /api/notifications call came from the 10-second poll, at 10335 ms.
  4. The trade-off is visible in the headers: /client-only is a static file a CDN can serve to everyone; /server-first is rendered per request (private, no-store) because it contains fresh data.
  5. On localhost the client-side delay was only 56 ms. It grows with every kilobyte of JavaScript and every round trip on a real phone network, which is why data the first screen needs should come from the server.
AdvancedModule state isn't shared between a page and a Route Handler
AdvancedPassing a promise to a Client Component with use()

When a Client Component needs server data once (no polling, no refetching), you don't need an endpoint or a library. Start the read in a Server Component, don't await it, and pass the promise as a prop. The Client Component unwraps it with React's use(), which suspends until the promise resolves.

components/inbox-list.tsx
"use client";

import { use, useState } from "react";

type Notification = { id: number; text: string };

export function InboxList({ itemsPromise }: { itemsPromise: Promise<Notification[]> }) {
  const items = use(itemsPromise); // suspends until the server's promise resolves
  const [read, setRead] = useState<number[]>([]);
  return (
    <ul>
      {items.map((n) => (
        <li key={n.id} onClick={() => setRead([...read, n.id])}>
          {read.includes(n.id) ? "✓ " : ""}
          {n.text}
        </li>
      ))}
    </ul>
  );
}
app/promise/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { InboxList } from "@/components/inbox-list";
import { getNotifications } from "@/lib/notifications";

async function loadInbox() {
  await connection(); // request-time data, not part of the prerendered shell
  return getNotifications("promise prop");
}

export default function Page() {
  const itemsPromise = loadInbox(); // started here, not awaited
  return (
    <main>
      <h1>Inbox</h1>
      <Suspense fallback={<p>Loading notifications…</p>}>
        <InboxList itemsPromise={itemsPromise} />
      </Suspense>
    </main>
  );
}
Output
headless Chrome, 11.5 s after loading /promise, then clicking the first item:
{ "fetches": [], "clicked": "✓ Your order o-1007 shipped" }
next start terminal:
[db] read #11 from promise prop
What’s happening
  1. Page calls loadInbox() and gets a promise straight away; the page itself never awaits, so its <h1> and the fallback are in the static shell.
  2. await connection() inside loadInbox says "this needs a real request", so the read isn't attempted during next build. (connection() is also why this route got one read per request rather than two: the second render pass described in the memoization topic is a prerender, where connection() never resolves, so it never reaches the read.)
  3. The promise crosses the server–client boundary as a prop: React serialises it, and when it resolves on the server, the value is streamed into the same response (curl showed the notification text in the HTML).
  4. In the browser, use(itemsPromise) suspends InboxList until the value is there, showing the boundary's fallback; afterwards it's an ordinary interactive component. Clicking marked the first item read, and no request was made to any API.
  5. Choose this over an endpoint when the data is needed once. Choose an endpoint plus a client library when the browser must refetch it later.
AdvancedSWR and TanStack Query with server-provided data

When the client needs a real cache (refetch on focus, polling, deduplication between components, optimistic updates), use a data library, and seed it from the server so the first paint has data. The bundled guides show both libraries. These excerpts follow them but weren't built here: neither package is installed in this site's Next.js sandbox, so treat the code as the documented pattern, not as tested output.

app/products/[id]/page.tsx (SWR, from the bundled guide; not built)
import { Suspense } from "react";
import { SWRConfig } from "swr";
import { getProduct } from "./data"; // server-side function
import { ProductView } from "./product-view";

export default function Page({ params }: PageProps<"/products/[id]">) {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      {params.then(({ id }) => (
        // Not awaited: only components that read this key suspend
        <SWRConfig value={{ fallback: { [`/api/products/${id}`]: getProduct(id) } }}>
          <ProductView id={id} />
        </SWRConfig>
      ))}
    </Suspense>
  );
}
app/products/[id]/product-view.tsx (SWR; not built)
"use client";

import useSWR from "swr";

const fetcher = (url: string) => fetch(url).then((r) => r.json());

export function ProductView({ id }: { id: string }) {
  // Same key as the server fallback: first render uses the server's data, then SWR keeps it fresh.
  const { data } = useSWR(`/api/products/${id}`, fetcher, { suspense: true, refreshInterval: 10_000 });
  return <h1>{data.name}</h1>;
}
What’s happening
  1. The server puts an unawaited promise under the SWR key in fallback; React streams its value through the RSC payload, the same mechanism as use() above.
  2. The Client Component asks SWR for the same key. On first render SWR finds the server's value, so there's no browser request at load; afterwards it refetches from /api/products/:id every 10 s.
  3. The bundled guide says this needs SWR 2.3.0 and React 19. With TanStack Query (5.40.0 or later, per the other guide) the equivalent is queryClient.prefetchQuery(...) without awaiting, dehydrate(queryClient) and a <HydrationBoundary>, with a new QueryClient per server render and one shared in the browser.
  4. The key, or query key, is the contract between the two sides. If they differ, the client ignores the server data and fetches again on load.
AdvancedWhen client fetching is right
SituationWhere to fetchWhy
First screen content (product, article, dashboard)Server ComponentIn the HTML, no extra round trip, no JS needed to see it
Changes while the page is open (notifications, prices)Server initial value, then client polling or a socketFast first paint, stays fresh
Depends on browser APIs (location, storage, files)ClientThe server can't know it
Triggered by input (search-as-you-type, infinite scroll)Client, via a Route HandlerThe request doesn't exist until the user acts
MutationsServer Actions (Server Actions)POST with progressive enhancement, revalidation built in
AdvancedHow it used to work
  • Pages Router: the common pattern was getServerSideProps returning initial data and SWR's fallback (or React Query's dehydrate) taking over on the client; the App Router pattern above is the same idea with Server Components as the source.
  • Create React App / SPA era: everything was client-side: useEffect + fetch + a loading state, which is exactly the slow /client-only path above, for every page.

#Sharing data between layouts and pages

Added

A layout wraps a page, so it's natural to expect it to load the user once and hand it down. It can't. A layout receives its page as children, an already-created React element; there's no way to add props to it, and in the App Router the layout and page are separate route segments that Next.js renders independently (and in parallel). A layout also persists across navigations between its child pages, so data passed from it would go stale.

The App Router's answer is to stop passing data down and ask for it again where it's needed, relying on memoization to make the second ask free. Picture a shared notice board in a building: the reception desk (layout) and each office (page) read the same notice instead of the receptionist photocopying it and running it to every office. fetch calls are memoized automatically; for a database read you wrap the function in cache(), as here.

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

let queries = 0;

async function findUser(id: string) {
  console.log(`[db] query #${++queries}: SELECT name, plan FROM users WHERE id = '${id}'`);
  await new Promise((resolve) => setTimeout(resolve, 200));
  return { id, name: id === "u2" ? "Grace" : "Rohit", plan: "pro" as const };
}

// The current user, read at most once per render pass however many components ask.
export const getCurrentUser = cache(async () => {
  const id = (await cookies()).get("uid")?.value ?? "u1";
  return findUser(id);
});
app/shop/layout.tsx
import { Suspense } from "react";
import { getCurrentUser } from "@/lib/user";

async function AccountMenu() {
  const user = await getCurrentUser();
  return <span>{user.name} ({user.plan})</span>;
}

export default function ShopLayout({ children }: LayoutProps<"/shop">) {
  return (
    <>
      <header>
        <Suspense fallback={<span>…</span>}>
          <AccountMenu />
        </Suspense>
      </header>
      {children}
    </>
  );
}
app/shop/page.tsx
import { Suspense } from "react";
import { getCurrentUser } from "@/lib/user";

async function Welcome() {
  const user = await getCurrentUser();
  return <h1>Welcome back, {user.name}</h1>;
}

export default function ShopPage() {
  return (
    <Suspense fallback={<h1>Welcome back</h1>}>
      <Welcome />
    </Suspense>
  );
}
Output
$ curl -s http://localhost:4504/shop            (template config: partialPrefetching: true)
   <span>Rohit (pro)</span> … Welcome back, Rohit
[db] query #1: SELECT name, plan FROM users WHERE id = 'u1'
[db] query #2: SELECT name, plan FROM users WHERE id = 'u1'
$ curl -s -b uid=u2 http://localhost:4504/shop
   <span>Grace (pro)</span> … Welcome back, Grace
[db] query #3: SELECT name, plan FROM users WHERE id = 'u2'
[db] query #4: SELECT name, plan FROM users WHERE id = 'u2'

same app, partialPrefetching: false:
/shop                            [db] query #1
/shop, getCurrentUser without cache()   [db] query #1, [db] query #2
What’s happening
  1. AccountMenu (in the layout) and Welcome (in the page) both call getCurrentUser(). Neither knows about the other; there's no prop between them.
  2. getCurrentUser is one module-level cache() wrapper, so within a render pass the first call runs the query and the second gets the same promise. With partialPrefetching: false, one request made one query; the same app without cache() made two.
  3. With the template's default partialPrefetching: true, each request made two queries: one per render pass (the second pass is explained in the memoization topic), still one per pass for two callers.
  4. cookies() is read inside the cached function, so each request reads its own cookie: uid=u2 gave "Grace" in both places. cache() memoizes per request, so one user's result can never be served to another.
  5. Each reader has its own <Suspense>, so the layout's menu and the page's heading stream independently, and the layout's <header> stays in the static shell.
Can a layout pass props to its page?

A teammate writes this, expecting the page to receive the user:

app/shop/layout.tsx (quiz app, built in a sandbox copy)
import React from "react";

export default function ShopLayout({ children }: LayoutProps<"/shop">) {
  const el = children as React.ReactElement<{ user?: string }>;
  return <>{React.cloneElement(el, { user: "Rohit" })}</>;
}
app/shop/page.tsx (quiz app, built in a sandbox copy)
export default function ShopPage(props: { user?: string }) {
  return <h1>user prop: {String(props.user)} | keys: {Object.keys(props).join(",")}</h1>;
}

What does the page render?

Show answer

user prop: undefined | keys: params,searchParams. The page never sees the prop.

What’s happening
  1. children isn't the page element. Logging its props in the layout printed parallelRouterKey, error, template, notFound, forbidden, unauthorized and similar keys: it's Next.js's router element for the rest of the route, which renders error boundaries, templates and loading boundaries before it gets to the page.
  2. cloneElement added user to that router element, which ignores it. Next.js renders the page itself, with exactly two props, params and searchParams, as the prerendered HTML showed.
  3. Even if a prop could get through, the docs say layouts and pages render in parallel, and a layout persists across navigations between its pages, so a value from it would be the value from the first page load.
  4. The idiomatic version is the one above: both call the memoized function. (Awaiting the user at the top of the layout would also block the whole layout and, with Cache Components, fail the build without a Suspense boundary.)
AdvancedSharing a promise through context with use()

Client Components can't call getCurrentUser() (it's server-only). If many Client Components across the layout and page need the user, start the read once in the layout, put the promise in a context provider, and let each consumer use() it.

components/user-context.tsx
"use client";

import { createContext, use } from "react";

type User = { id: string; name: string; plan: "pro" };

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

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

export function useUser() {
  const promise = use(UserContext);
  if (!promise) throw new Error("useUser must be used inside <UserProvider>");
  return use(promise);
}
components/plan-badge.tsx
"use client";

import { useUser } from "./user-context";

export function PlanBadge() {
  const user = useUser();
  return <strong>{user.plan.toUpperCase()}</strong>;
}
app/studio/layout.tsx
import { Suspense } from "react";
import { UserProvider } from "@/components/user-context";
import { PlanBadge } from "@/components/plan-badge";
import { getCurrentUser } from "@/lib/user";

export default function StudioLayout({ children }: LayoutProps<"/studio">) {
  const userPromise = getCurrentUser(); // started once, not awaited
  return (
    <UserProvider userPromise={userPromise}>
      <header>
        <Suspense fallback={<strong>…</strong>}>
          <PlanBadge />
        </Suspense>
      </header>
      {children}
    </UserProvider>
  );
}
app/studio/page.tsx
import { Suspense } from "react";
import { PlanBadge } from "@/components/plan-badge";

export default function StudioPage() {
  return (
    <main>
      <h1>Studio</h1>
      <Suspense fallback={<p>…</p>}>
        <p>
          Exports on your plan: <PlanBadge />
        </p>
      </Suspense>
    </main>
  );
}
next build
Route (app)
┌ ○ /_not-found
├ ◐ /shop
└ ◐ /studio


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
What’s happening
  1. StudioLayout isn't async: it calls getCurrentUser() and passes the pending promise to UserProvider, a Client Component. Nothing in the layout waits, so the route is still ◐ with the header in the shell.
  2. <UserContext value={...}> is React 19's shorthand for <UserContext.Provider value={...}>. The value is the promise itself, not the user.
  3. PlanBadge appears twice, once in the layout's header and once in the page. Each calls useUser(), which reads the context with use(UserContext) and then unwraps the promise with use(promise), suspending until it resolves.
  4. curl showed <strong>PRO</strong> twice in the HTML, from one promise: with partialPrefetching: false the request made one query ([db] query #2 after /shop's #1), and two with the default, one per render pass as before.
  5. Each PlanBadge sits inside a <Suspense>: use() on a pending promise suspends, and without a boundary the layout would block on the user.
AdvancedChoosing a way to share
  • Server Components that need the same data: call the memoized function (fetch, or a cache()-wrapped query) in each one. This is the default answer.
  • Many Client Components need it: start it once in a layout, pass the promise through context, use() it where needed.
  • One Client Component needs it: pass it (or a promise of it) as a prop from the nearest Server Component.
  • Don't fetch in the root layout "because everything needs it": every page then depends on it, and a slow user query delays the shell of every route unless it's behind a boundary.
AdvancedHow it used to work
  • Pages Router: pages got data from getServerSideProps, and shared data was usually fetched per page and put into a context in _app, or loaded in _app.getInitialProps, which disabled automatic static optimisation for every page. Layouts were just components inside pages, so passing props was easy, and the whole tree re-rendered on navigation.
  • App Router 13–14: same rule as now (no props from layout to page); with fetch cached by default, "fetch it twice" often meant "fetch it once at build time", which hid the question.

#Data security: the Data Access Layer

Added

Server Components make it easy to read a database row and render it. They also make it easy to send that whole row to the browser by accident: any object passed as a prop to a Client Component is serialised into the RSC payload inside the HTML, every field of it, whether or not the component renders the field. The page looks fine; the data is in "view source".

The pattern the Next.js docs recommend for new projects is a Data Access Layer (DAL): one server-only module that is the only code allowed to talk to the database. It checks who is asking (authentication and authorisation) and returns Data Transfer Objects (DTOs), plain objects with only the fields this viewer may see. Components call the DAL, never the database. The mental model is a bank teller: you don't walk into the vault and take what you want, you ask at the counter, the teller checks your ID, and hands you exactly what you're entitled to, nothing else.

server only sent to the browser Server Component DAL (server-only) 1 who is asking? 2 map rows → DTO Database { id, name, email | null } Client Component props serialised into the RSC payload in the HTML /leaky page reads rows itself whole row: passwordHash, stripeCustomerId… every field in view-source
The DAL is the only way to the database; only DTOs cross into Client Components.

The app below has a database with more fields than any page should show, a Client Component that renders a name and (on click) an email, and two pages: one that reads rows directly and one that goes through a DAL.

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

// What the database really holds: far more than any page should show.
export type UserRow = {
  id: string;
  name: string;
  email: string;
  role: "admin" | "member";
  passwordHash: string;
  stripeCustomerId: string;
};

const users: UserRow[] = [
  { id: "u1", name: "Rohit", email: "rohit@example.com", role: "admin", passwordHash: "$argon2id$v=19$m=65536$c2FsdA$aGFzaA", stripeCustomerId: "cus_R0h1tSecret" },
  { id: "u2", name: "Grace", email: "grace@example.com", role: "member", passwordHash: "$argon2id$v=19$m=65536$c2FsdB$aGFzaB", stripeCustomerId: "cus_Gr4ceSecret" },
];

export async function findUser(id: string) {
  await new Promise((resolve) => setTimeout(resolve, 50));
  return users.find((u) => u.id === id) ?? null;
}

export async function listUsers() {
  await new Promise((resolve) => setTimeout(resolve, 50));
  return users;
}
components/member-card.tsx
"use client";

import { useState } from "react";

export function MemberCard({ member }: { member: { name: string; email: string | null } }) {
  const [open, setOpen] = useState(false);
  return (
    <li>
      <button onClick={() => setOpen(!open)}>{member.name}</button>
      {open && <span> {member.email ?? "email hidden"}</span>}
    </li>
  );
}
app/leaky/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { MemberCard } from "@/components/member-card";
import { listUsers } from "@/lib/db";

// ❌ Component-level data access: whole rows go straight to a Client Component.
async function Team() {
  await connection();
  const rows = await listUsers();
  return <ul>{rows.map((row) => <MemberCard key={row.id} member={row} />)}</ul>;
}

export default function Page() {
  return (
    <Suspense fallback={<p>Loading team…</p>}>
      <Team />
    </Suspense>
  );
}

curl -s http://localhost:4504/leaky: the visible HTML is just two buttons, <ul><li><button>Rohit</button></li><li><button>Grace</button></li></ul>. The RSC payload further down the same response (split into lines here; it's one self.__next_f.push call):

Output
1d:I[61059,["$2","$1c"],"MemberCard"]
a:["$","ul",null,{"children":[
  ["$","$L1d","u1",{"member":{"id":"u1","name":"Rohit","email":"rohit@example.com","role":"admin",
     "passwordHash":"$$argon2id$v=19$m=65536$c2FsdA$aGFzaA","stripeCustomerId":"cus_R0h1tSecret"}}],
  ["$","$L1d","u2",{"member":{"id":"u2","name":"Grace","email":"grace@example.com","role":"member",
     "passwordHash":"$$argon2id$v=19$m=65536$c2FsdB$aGFzaB","stripeCustomerId":"cus_Gr4ceSecret"}}]]}]
What’s happening
  1. Team reads the rows and passes each one as member. TypeScript is happy: a UserRow has name and email, so it fits the prop type { name: string; email: string | null }. Extra fields aren't an error when you pass a variable.
  2. MemberCard is a Client Component, so React has to send its props to the browser to hydrate it. It serialises the object it was given, not the type it declared: every field went into the payload.
  3. 1d:I[61059,…,"MemberCard"] is the reference to the client module, and $L1d points each list item at it. The props follow, including passwordHash (the $ is escaped as $$) and stripeCustomerId, for every user, to every visitor, signed in or not.
  4. Nothing on screen gives it away. Only the payload does: open view-source, or run curl … | grep passwordHash.
AdvancedThe same page through a DAL

Now the same page through a Data Access Layer:

lib/dal.ts
import "server-only";
import { cache } from "react";
import { cookies } from "next/headers";
import { findUser, listUsers, type UserRow } from "./db";

// Who is asking? Read once per render pass, from the session cookie.
export const getViewer = cache(async () => {
  const session = (await cookies()).get("session")?.value;
  // A real app verifies a signed session here; this demo maps the cookie to a user id.
  return session ? findUser(session) : null;
});

export type MemberDTO = { id: string; name: string; email: string | null };

function toMemberDTO(row: UserRow, viewer: UserRow): MemberDTO {
  const canSeeEmail = viewer.role === "admin" || viewer.id === row.id;
  return { id: row.id, name: row.name, email: canSeeEmail ? row.email : null };
}

export async function getTeam(): Promise<MemberDTO[]> {
  const viewer = await getViewer();
  if (!viewer) throw new Error("Not signed in");
  const rows = await listUsers();
  return rows.map((row) => toMemberDTO(row, viewer));
}
app/team/page.tsx
import { Suspense } from "react";
import { MemberCard } from "@/components/member-card";
import { getTeam } from "@/lib/dal";

// ✅ Through the Data Access Layer: checked, and only the DTO's fields leave the server.
async function Team() {
  const team = await getTeam();
  return <ul>{team.map((m) => <MemberCard key={m.id} member={m} />)}</ul>;
}

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


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
Output
$ curl -s -b session=u2 http://localhost:4504/team      (Grace, a member)
a:["$","ul",null,{"children":[
  ["$","$L1d","u1",{"member":{"id":"u1","name":"Rohit","email":null}}],
  ["$","$L1d","u2",{"member":{"id":"u2","name":"Grace","email":"grace@example.com"}}]]}]

$ curl -s -b session=u1 http://localhost:4504/team      (Rohit, an admin)
   both emails present, no passwordHash, no stripeCustomerId

$ curl -s http://localhost:4504/team                     (no session)
server log:  ⨯ Error: Not signed in … { digest: '2752064546' }
HTML:        only the error digest, no team data
What’s happening
  1. getViewer reads the session cookie and finds the user. It's wrapped in cache(), so every DAL function in the same render can call it without another lookup; that's why the docs pair the DAL with cache().
  2. getTeam refuses to run without a viewer, then maps each row through toMemberDTO, which builds a new object with exactly three fields. The email is included only if the viewer is an admin or it's their own row.
  3. Grace (member) got her own email and null for Rohit's; Rohit (admin) got both. Neither response contained passwordHash or stripeCustomerId, because those fields were never copied into the DTO.
  4. Without a session, getTeam threw. The error stayed on the server (the log shows the message); the browser got only a digest, which is how Next.js hides server error messages in production. A real app would redirect("/login") in the DAL instead.
  5. The page component didn't change shape: same MemberCard, same JSX. The security lives in one module, which is the point: you audit lib/dal.ts, not every page.
Advancedserver-only: making the boundary a build error

The DAL starts with import "server-only". If a Client Component imports it, even indirectly, the build fails. I added a Client Component that imports getTeam to a copy of this app, and next build stopped with five errors; the clearest of them:

Output
./src/lib/dal.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/dal.ts [Client Component Browser]
    ./src/components/team-count.tsx [Client Component Browser]
    ./src/components/team-count.tsx [Server Component]
    ./src/app/count/page.tsx [Server Component]
What’s happening
  1. team-count.tsx had "use client" and import { getTeam } from "@/lib/dal". Everything a Client Component imports becomes client code, so dal.ts, and through it db.ts, would have been bundled for the browser.
  2. Turbopack saw server-only in the client graph and failed, with an import trace from the page down to the offending module. Without the import, the same mistake would have compiled and shipped the query code (and any secrets in it) to the browser, failing only at runtime.
  3. Two of the five errors were worded confusingly: "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." There's no Pages Router in this app; read it as "you're using it in client code".
  4. server-only is a guard, not a fix. The fix is to call the DAL from a Server Component and pass the DTO down, or to expose it through a Server Action or a Route Handler that does its own checks.
AdvancedTaint APIs: a last line of defence

React's experimental taint APIs mark an object or a value as "must not reach a Client Component"; if it does, rendering throws. Next.js enables them with experimental.taint, which also switches the app to React's experimental channel:

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
  experimental: {
    taint: true, // enables React's taint APIs (and React's experimental channel for app/)
  },
};

export default nextConfig;
lib/db.ts
/// <reference types="react/experimental" />
import "server-only";
import { experimental_taintObjectReference, experimental_taintUniqueValue } from "react";

type UserRow = { id: string; name: string; email: string; passwordHash: string };

const users: UserRow[] = [
  { id: "u1", name: "Rohit", email: "rohit@example.com", passwordHash: "$argon2id$v=19$m=65536$c2FsdA$aGFzaA" },
];

export async function listUsers() {
  await new Promise((resolve) => setTimeout(resolve, 50));
  for (const row of users) {
    experimental_taintObjectReference("Don't pass a whole user row to the client; map it to a DTO.", row);
    experimental_taintUniqueValue("Password hashes never leave the server.", row, row.passwordHash);
  }
  return users;
}
components/member-card.tsx
"use client";

export function MemberCard({ member }: { member: { name: string } }) {
  return <li>{member.name}</li>;
}
components/hash-badge.tsx
"use client";

export function HashBadge({ value }: { value: string }) {
  return <code>{value.slice(0, 8)}</code>;
}
app/whole-row/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { MemberCard } from "@/components/member-card";
import { listUsers } from "@/lib/db";

async function Team() {
  await connection();
  const rows = await listUsers();
  return <ul>{rows.map((row) => <MemberCard key={row.id} member={row} />)}</ul>;
}

export default function Page() {
  return (
    <Suspense fallback={<p>Loading team…</p>}>
      <Team />
    </Suspense>
  );
}
app/hash/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { HashBadge } from "@/components/hash-badge";
import { listUsers } from "@/lib/db";

async function Hash() {
  await connection();
  const [row] = await listUsers();
  const copied = row.passwordHash; // a new variable, the same string
  return <HashBadge value={copied} />;
}

export default function Page() {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <Hash />
    </Suspense>
  );
}
app/dto/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { MemberCard } from "@/components/member-card";
import { listUsers } from "@/lib/db";

async function Team() {
  await connection();
  const rows = await listUsers();
  return <ul>{rows.map((row) => <MemberCard key={row.id} member={{ name: row.name }} />)}</ul>;
}

export default function Page() {
  return (
    <Suspense fallback={<p>Loading team…</p>}>
      <Team />
    </Suspense>
  );
}
Output
== curl /whole-row   status 200, HTML has only an error digest
server log:  ⨯ Error: Don't pass a whole user row to the client; map it to a DTO.
                 at ignore-listed frames { digest: '2246276726' }
browser:     "This page couldn’t load … A server error occurred. Reload to try again. … ERROR 2246276726"
== curl /hash        status 200, HTML has only an error digest
server log:  ⨯ Error: Password hashes never leave the server.
                 at ignore-listed frames { digest: '3350637428' }
== curl /dto         status 200
HTML:        <li>Rohit</li>
What’s happening
  1. experimental_taintObjectReference(message, row) registers that exact object. On /whole-row, React found it among MemberCard's props while serialising and threw with the message given; the page failed instead of leaking.
  2. experimental_taintUniqueValue(message, row, row.passwordHash) registers the string value itself (kept for as long as row is alive). On /hash it was copied into a new variable first, and React still caught it: the value is tracked, not the variable.
  3. On /dto, { name: row.name } is a new, untainted object and name isn't a tainted value, so it rendered normally.
  4. The limits are documented and real: tainting tracks references, so a copy like { ...row } isn't tainted, and derived values (hash.slice(0, 8) computed on the server, or `v:${hash}`) aren't caught. It's an alarm for mistakes, not a substitute for DTOs.
  5. The error surfaced as a 500-style error page with status 200 here, because the boundary had already streamed. Either way the secret didn't reach the browser.
AdvancedThe DAL checklist
  • One module owns data access (lib/dal.ts or a data/ folder), starts with import "server-only", and is the only place that imports the database client and reads secret process.env values.
  • Check authorisation inside every DAL function, close to the data, not only in the page or in proxy.ts: pages are one entry point, but Server Actions and Route Handlers are others (authorisation close to the data).
  • Return DTOs built field by field, never rows or ORM entities. Pick columns in the query too (select: { id: true, name: true }) so private fields never even enter memory.
  • Use cache() for the viewer lookup so every DAL call in a render shares one session check.
  • Type Client Component props narrowly and treat broad ones (user: User) as review red flags.
  • Use taint as a backstop, not as the design.
AdvancedHow it used to work
  • Pages Router: the same leak existed in a different form: whatever getServerSideProps returned in props was serialised into __NEXT_DATA__ in the HTML. "Return only what the page needs from getServerSideProps" was the same lesson.
  • Before Server Components: the browser called an API, and the API layer was the natural place for authorisation and DTOs. The DAL brings that discipline back now that components can reach the database directly.

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.