Next.js NotesRohit’s interview study guide
Chapter 05

Caching with Cache Components

How Next.js 16 decides what is computed ahead of time, what is cached and for how long, and what waits for the request; plus the older model you'll still meet in existing apps.

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

#The caching model in Next.js 16

Added

Caching in the App Router used to be something that happened to you. In Next.js 13 and 14, fetch results were cached by default and a route was static unless it touched something request-specific, so a page could quietly serve week-old data and nobody knew which line of code made it static. Next.js 15 flipped the defaults the other way (fetch uncached, GET Route Handlers uncached, page segments no longer reused from the client router cache). Next.js 16 adds Cache Components, a new model you switch on with cacheComponents: true in next.config.ts. New apps made with create-next-app@16.4.3 have it on (together with partialPrefetching: true), and it is the model this chapter teaches first.

The rule of Cache Components fits on one line: nothing is cached unless you say 'use cache', and everything that can be computed ahead of time is. Next.js renders each route at build time and keeps everything that finishes without waiting for a request: plain JSX, imports, synchronous work, and the output of functions and components marked 'use cache'. That prerendered HTML is the route's static shell. Anything that has to wait for a request (cookies, headers, search params, an uncached database call) must sit inside a <Suspense> boundary. The shell then contains the boundary's fallback, and the real content streams into that hole when a request arrives. If runtime data is read outside any <Suspense>, the build fails and tells you to choose.

A good mental model is a printed form with blanks. The printer (the build) prints everything that is the same for every reader: the headings, the logo, the "this week's deals" box that you said may be reused for an hour. It leaves boxes blank where each reader's own details go, with "loading…" printed in them. When a reader arrives, the form is handed over instantly and a clerk fills in the blanks one by one as the answers come back. 'use cache' is how you tell the printer "you may print this, and reprint it every so often"; <Suspense> is how you mark a blank.

What is and isn't cached under Cache Components:

Kind of workExampleWhat Next.js does
Pure rendering, imports, synchronous computation<h1>Store</h1>, JSON.parse(readFileSync(...))Runs at build time, goes into the static shell
Work inside 'use cache'a cached getDeals() or <Deals />Runs once, output stored and reused; in the shell if its lifetime isn't too short
Uncached async I/Ofetch(...), db.query(...) outside 'use cache'Runs at request time; must be inside <Suspense>
Runtime APIscookies(), headers(), searchParams, unknown paramsRuns at request time; must be inside <Suspense>
Non-deterministic valuesnew Date(), Math.random(), crypto.randomUUID()You must choose: cache it ('use cache') or defer it (await connection() or await io() first)
Request memoizationthe same fetch GET twice in one render, React.cacheStill deduplicated within one render, as before
Client router cacherevisiting a page you navigated toKept in the browser for the stale time of the cached content (at least 30 seconds)

Here is a page with all three kinds of content: static, cached and runtime.

app/page.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";
import { connection } from "next/server";
import { cacheLife } from "next/cache";

export default function Home() {
  return (
    <main>
      <h1>Store</h1>
      <Deals />
      <Suspense fallback={<p>Loading your basket…</p>}>
        <Basket />
      </Suspense>
    </main>
  );
}

// Cached: runs once (at build time here), then every visitor gets the stored result.
async function Deals() {
  "use cache";
  cacheLife("hours");
  return <p>Deals computed at {new Date().toISOString()}</p>;
}

// Runtime: reads a cookie, so it can only run when a real request arrives.
async function Basket() {
  const basket = (await cookies()).get("basket")?.value ?? "empty";
  await new Promise((resolve) => setTimeout(resolve, 1000)); // a slow cart service
  await connection(); // the timestamp below must be this request's
  return <p>Basket: {basket} (rendered at {new Date().toISOString()})</p>;
}
What’s happening
  1. At next build, Next.js renders Home. The <h1> is plain JSX, so it is rendered straight into the shell. Nothing about it can differ between requests.
  2. <Deals /> is marked 'use cache' with the hours profile, so Next.js runs it during the build, stores its output (Deals computed at 2026-10-25T18:07:53.465Z, the build time) and puts that HTML in the shell too. Without 'use cache' the new Date() would fail the build (see below), because Next.js can't know whether you wanted one timestamp for everyone or one per request.
  3. <Basket /> awaits cookies(). Cookies only exist on a real request, so the build can't finish this component. Because it sits inside <Suspense>, the build puts the fallback <p>Loading your basket…</p> into the shell in its place and records that this hole is still to be filled ("postponed").
  4. At request time Next.js sends the shell immediately, then runs Basket with the request's cookies, waits out the one-second "cart service", and streams the result into the hole. await connection() is there because the timestamp is meant to be per request: it tells Next.js that what follows must only ever run for a real request.
  5. Two requests a second apart get the same Deals timestamp and different Basket timestamps, and a request with basket=chair gets its own basket. Every visitor shares the cached part; each visitor gets their own hole.
AdvancedThe about page and the route table

This is the route table next build printed for the app (the page above plus an about page that is just <h1>About us</h1>):

app/about/page.tsx
export default function About() {
  return <h1>About us</h1>;
}
AdvancedRoute table: one ◐ and one ○
next build
Route (app)      Revalidate  Expire
┌ ◐ /                    1h      1d
├ ○ /_not-found
└ ○ /about


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
AdvancedWhat the running app returned

/about is ○ Static: nothing in it waits for a request. / is ◐ Partial Prerender: a static shell plus a hole that streams. The Revalidate 1h and Expire 1d columns come from the hours profile of the cached <Deals /> inside the shell: the shell is regenerated in the background when that content is an hour old (see cacheLife).

What the running app sent (next start, then curl; HTML comments that React inserts between text parts removed for reading):

curl http://localhost:4505/ (twice, one second apart), then with a cookie
<p>Deals computed at 2026-10-25T18:07:53.465Z</p>
<p>Basket: empty (rendered at 2026-10-25T18:08:05.174Z)</p>

<p>Deals computed at 2026-10-25T18:07:53.465Z</p>
<p>Basket: empty (rendered at 2026-10-25T18:08:07.204Z)</p>

curl -b basket=chair http://localhost:4505/
<p>Basket: chair (rendered at 2026-10-25T18:08:09.231Z)</p>
AdvancedWhen each part of the response arrived

And when each part arrived, measured with a small Node script that reads the response body as it streams and notes the first chunk containing each piece of text:

node chunks.mjs http://localhost:4505/
"<h1>Store</h1>"             +30 ms (chunk 1)
"Deals computed at"          +30 ms (chunk 1)
"Loading your basket"        +30 ms (chunk 1)
"Basket: "                   +1032 ms (chunk 11)
end of response              +1035 ms (14 chunks)
AdvancedDiagram: the static shell and its hole

The heading, the cached deals and the basket fallback all arrive in the first chunk, 30 ms after the request. The basket arrives a second later in the same response. That is the static shell with a dynamic hole, observed.

Static shell (made at build time) Store static JSX Deals computed at 18:07:53 'use cache' + cacheLife('hours') Loading your basket… Suspense fallback: a hole cookies() can't run yet request shell sent at +30 ms hole streamed later What the visitor ends up with Store same for everyone Deals computed at 18:07:53 same for everyone, until revalidated Basket: chair rendered for this request arrives at +1032 ms blue: static · green: cached · dashed: hole in the shell · violet: streamed at request time
One response: the prerendered shell first, then each hole streamed in as its data arrives.
AdvancedBuild errors: when you don't choose

What Next.js does when you don't choose

Cache Components refuses to guess. Two things fail the build: reading runtime data outside <Suspense>, and producing a non-deterministic value during prerendering. These were built in a scratch copy of the template (not one of the chapter's apps, since they don't build). A page that reads a cookie at the top level:

app/page.tsx (fails to build)
import { cookies } from "next/headers";

export default async function Page() {
  const theme = (await cookies()).get("theme")?.value ?? "light";
  return <h1>Theme: {theme}</h1>;
}
next build (trimmed)
Error: Route "/": 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
What’s happening
  1. The build renders Page and reaches await cookies(). There is no request, so the promise can't resolve during prerendering.
  2. Next.js looks for the nearest <Suspense> above that point to use as a hole. There isn't one: the only thing above Page is the root layout, so the whole page would have to wait for the request, and the shell would be empty.
  3. Under the previous model this silently made the whole route dynamic. Cache Components stops the build instead and lists the three choices: stream it (wrap in <Suspense>), cache it ('use cache', for data that isn't per request), or accept a blocking route with export const instant = false.
  4. The fix you almost always want is the first: move the cookie read into a small component and wrap that in <Suspense>, exactly like Basket above. See Runtime data.

A page that renders the current time with no decision about it:

app/page.tsx (fails to build)
export default function Page() {
  return <h1>Now: {new Date().toISOString()}</h1>;
}
next build (trimmed)
Error: Route "/": Next.js encountered the unstable value `new Date()` while prerendering.

This value can change between renders, so it must be either prerendered or computed later.

Ways to fix this:
  - [dynamic] Render at request time by adding a dynamic data access (e.g. `await connection()`) before this call
  - [cache] Prerender and cache the value with `"use cache"`
  - [client] Render the value on the client with `"use client"`
  - [measure] If the value is for telemetry, use a timing API such as `performance.now()`

Learn more: https://nextjs.org/docs/messages/blocking-prerender-current-time
What’s happening
  1. new Date() returns something different every time it runs. If the build baked it into the shell, every visitor would see the build time, which is right for "last published" and wrong for "now".
  2. Next.js can't tell which you meant, so it stops and asks. 'use cache' says "one value, shared, reused until revalidated" (what Deals does). await connection() (or await io(), added in 16.3) inside a <Suspense> says "a fresh value per request" (what Basket does).
  3. Math.random() and crypto.randomUUID() get the same treatment. performance.now() doesn't, because it's meant for measuring, not rendering.
AdvancedHistory: why 15 and 16 changed it

Why 15 and 16 changed the model

Interviewers often ask why Next.js keeps changing caching. The short history, as the upgrade guides describe it:

VersionDefault behaviour
13–14fetch cached by default (force-cache); GET Route Handlers cached by default; client router reused dynamic pages for 30 seconds. Static unless something forced dynamic.
15fetch not cached by default; GET Route Handlers not cached by default; client router no longer reuses page segments (staleTimes.dynamic 30 s → 0 s). Still route-level static vs dynamic.
16Cache Components (cacheComponents: true): caching is explicit with 'use cache', cacheLife, cacheTag; Partial Prerendering is the default rendering for every route; revalidate, dynamic and fetchCache segment configs are replaced. Without the flag, 16 keeps the 15 model.

The problem with 13–14 was implicit caching: data was cached because you didn't say otherwise, and a route went static or dynamic because of something three components away. 15 fixed the surprise by caching less. 16 fixes the granularity: the unit of caching is no longer the whole route but a function or component, so one page can be part shared-and-cached and part per-request without the cookie read in a header making the whole page dynamic.

AdvancedSuspense doesn't make anything dynamic
Which parts of this page end up in the static shell?
React + TypeScript
export default function Page() {
  return (
    <>
      <Header />
      <Suspense fallback={<p>Loading prices…</p>}>
        <Prices />
      </Suspense>
      <Footer />
    </>
  );
}
// Header: plain JSX. Footer: 'use cache' + cacheLife('days').
// Prices: awaits fetch() with no 'use cache'.
Show answer

<Header />, the text Loading prices… and <Footer /> are in the shell; the prices stream in at request time.

What’s happening
  1. Header is plain JSX: rendered at build time, in the shell.
  2. Prices awaits an uncached fetch, which under Cache Components is request-time work. The build can't finish it, so it uses the nearest <Suspense> fallback, Loading prices…, as the hole.
  3. Footer is cached with the days profile. Its lifetime is long enough to be prerendered, so its output is in the shell, and the route's Revalidate column would show 1d.
  4. The route prints as ◐ in the build output. If Prices had no <Suspense> around it, the build would fail with the "uncached or runtime data" error instead of making the page dynamic.
AdvancedWhat the build stored in .next

What the build stored for this route is worth seeing once. In .next/server/app/ the /about route has about.html and about.rsc (the HTML and the RSC payload). The / route has index.html (the shell) and index.meta, whose JSON includes the headers to send and a "postponed" field: React's serialised state of where it stopped, so a request can resume rendering the holes without re-rendering the shell. index.meta also lists the route's automatic cache tags, which revalidatePath uses:

.next/server/app/index.meta (excerpt)
    "x-nextjs-stale-time": "300",
    "x-nextjs-prerender": "1",
    "x-next-cache-tags": "_N_T_/layout,_N_T_/page,_N_T_/,_N_T_/index"
  },
  "postponed": "1969:[1,{\"nextSegmentId\":1,\"rootFormatContext\":{...
AdvancedResponse headers of static and ◐ routes

The response headers say the same thing from the outside. /about came back with x-nextjs-cache: HIT and Cache-Control: s-maxage=31536000, a fully static file. / came back with x-nextjs-postponed: 1 and Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate, because the HTML in this response includes one visitor's basket. The shell itself is still served from the prerender; it's the finished response that mustn't be cached by a shared cache.

#'use cache'

Added

'use cache' is a directive, like 'use client' and 'use server': a string at the top of a function body or a file. It tells the compiler to wrap that async function so that its return value is stored and reused. You can put it on a data function (getQuote(symbol)), on a component (<ProductCard id="p1" />, where the "return value" is the rendered output), or at the top of a file, which makes every export of the file a cached function.

The mental model is memoisation across requests. A memoised function remembers the result for each set of arguments; a 'use cache' function does the same, except the memory outlives the request and is shared by every visitor. So the question to ask is always the memoisation question: what is the key? Next.js builds the key from the build ID, the function's identity (a hash of where it is in your code), its serialised arguments, and any variables it captures from the enclosing scope (closures are turned into hidden arguments). Same key, same stored result; any difference, a separate entry.

Because entries are stored, the inputs and the result have to be serialisable. Arguments follow the rules for Server Function arguments (primitives, plain objects, arrays, Date, Map, Set, typed arrays); the return value can additionally be JSX. Class instances, functions and symbols can't be part of the key. A value you only pass through without reading it, such as children or a Server Action, is allowed, and it doesn't become part of the key.

Here a market-data helper is cached at file level and called with a value from the URL:

lib/quotes.ts
"use cache";

import { cacheLife } from "next/cache";

export async function getQuote(symbol: string) {
  cacheLife("hours");
  console.log(`computing quote for ${symbol}`);
  await new Promise((resolve) => setTimeout(resolve, 300)); // a slow market-data API
  return {
    symbol,
    price: (symbol.charCodeAt(0) * 2.5).toFixed(2), // stand-in for a real price
    computedAt: new Date().toISOString(),
  };
}
What’s happening
  1. "use cache" as the first statement of lib/quotes.ts makes every export of the file a cached function. The import after it is fine: the directive only has to come before other code. cacheLife("hours") inside the function sets the entry's lifetime.
  2. The page below calls getQuote(symbol) with the ?symbol= from the URL. The first request for AAPL misses: the body runs, logs computing quote for AAPL, waits 300 ms and returns { symbol: "AAPL", price: "162.50", computedAt: … }. Next.js stores that object under a key made of the build ID, getQuote's identity and the argument "AAPL".
  3. The second AAPL request hits: the body doesn't run (no second log line), and the stored computedAt comes back while the page's own served time moves on. Timed on another symbol below: 0.31 s for the miss, 0.008 s for the hit.
  4. ?symbol=MSFT is a different argument, so a different key, a miss and a second log line. Going back to AAPL still hits the first entry: each argument value has its own entry.
  5. The entry lives in memory in the next start process. Restarting the server empties it, so the next AAPL request computes again.
AdvancedThe page that passes the symbol in

The page that calls it, with the symbol from the URL:

app/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { getQuote } from "@/lib/quotes";

export default function Page({ searchParams }: PageProps<"/">) {
  return (
    <Suspense fallback={<p>Loading quote…</p>}>
      <Quote searchParams={searchParams} />
    </Suspense>
  );
}

async function Quote({ searchParams }: Pick<PageProps<"/">, "searchParams">) {
  const { symbol = "AAPL" } = await searchParams;
  const quote = await getQuote(String(symbol));
  await connection(); // "served" below is this request's time
  return (
    <p>
      {quote.symbol}: {quote.price} (computed {quote.computedAt}, served{" "}
      {new Date().toISOString()})
    </p>
  );
}
What’s happening
  1. searchParams only exists per request, so Quote reads it inside <Suspense>; the page itself never awaits it. The route is ◐, and nothing about the quote is in the shell.
  2. String(symbol) turns string | string[] into a plain string before it becomes the cache key. The cached function only ever sees that value, never the request.
  3. await connection() comes before the served timestamp because that timestamp must be per request (see Request-time timestamps for what happens without it).
AdvancedRoute table for the quotes app
next build
Route (app)
┌ ◐ /
├ ○ /_not-found
└ ◐ /sales


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
AdvancedObserved: hits, misses and a restart

There is no Revalidate column this time: the cached function only runs behind a request-time read, so nothing cached is in either shell. What next start returned, one second apart (only the streamed paragraph shown), and the server's log:

curl "http://localhost:4505/?symbol=…"
?symbol=AAPL  AAPL: 162.50 (computed 2026-10-25T18:08:25.171Z, served 2026-10-25T18:08:25.172Z)
?symbol=AAPL  AAPL: 162.50 (computed 2026-10-25T18:08:25.171Z, served 2026-10-25T18:08:26.202Z)
?symbol=MSFT  MSFT: 192.50 (computed 2026-10-25T18:08:27.525Z, served 2026-10-25T18:08:27.526Z)
?symbol=AAPL  AAPL: 162.50 (computed 2026-10-25T18:08:25.171Z, served 2026-10-25T18:08:28.552Z)

server log:
computing quote for AAPL
computing quote for MSFT

timing (curl -w %{time_total}): ?symbol=NVDA first 0.311368s, second 0.007764s
after restarting next start: ?symbol=AAPL  computed 2026-10-25T18:08:48.099Z  (log: computing quote for AAPL)
AdvancedClosures become part of the key

A cached function written inside a component can use variables from the component. Next.js doesn't silently share one entry between different values of those variables: it captures them and binds them as extra arguments, so they are in the key.

app/sales/page.tsx
import { Suspense } from "react";
import { cacheLife } from "next/cache";

export default function Page({ searchParams }: PageProps<"/sales">) {
  return (
    <Suspense fallback={<p>Loading sales…</p>}>
      <Sales searchParams={searchParams} />
    </Suspense>
  );
}

async function Sales({ searchParams }: Pick<PageProps<"/sales">, "searchParams">) {
  const { region = "eu" } = await searchParams;

  // An inline cached function. It reads `region` from the enclosing scope.
  const totalFor = async (year: number) => {
    "use cache";
    cacheLife("hours");
    console.log(`computing sales for ${region} ${year}`);
    return { region, year, computedAt: new Date().toISOString() };
  };

  const total = await totalFor(2026);
  return (
    <p>
      {total.region} {total.year}: computed {total.computedAt}
    </p>
  );
}
What’s happening
  1. totalFor is created fresh on every render of Sales, but its identity for caching is its position in the source code, so every render's totalFor is "the same function" as far as the cache is concerned.
  2. Its explicit argument is year (2026). It also reads region, which comes from the enclosing scope. The compiler sees that capture and passes region in as a hidden bound argument, so the key is effectively (region, year).
  3. /sales?region=eu misses and logs computing sales for eu 2026. A second eu request hits and returns the same computedAt. /sales?region=us is a different captured value, so a separate entry and a second log line.
  4. If captured variables were not part of the key, the us request would get the eu total: the classic cache-key bug. Next.js prevents it by construction; the cost is that every captured value must be serialisable, the same as an argument.
curl "http://localhost:4505/sales?region=…" (one second apart)
?region=eu  eu 2026: computed 2026-10-25T18:08:29.595Z
?region=eu  eu 2026: computed 2026-10-25T18:08:29.595Z
?region=us  us 2026: computed 2026-10-25T18:08:31.661Z

server log:
computing sales for eu 2026
computing sales for us 2026
AdvancedA cached component with a live hole inside

Caching a component, with live content inside it

On a component, 'use cache' caches the rendered output for those props. The useful trick is interleaving: a cached component can render children (or any JSX prop) that it doesn't look at, and those children are not part of its cache entry. That lets a cached shell of UI wrap a live hole.

app/page.tsx
import { Suspense, type ReactNode } from "react";
import { connection } from "next/server";
import { cacheLife } from "next/cache";

export default function Page() {
  return (
    <ProductCard id="p1">
      <Suspense fallback={<p>Checking stock…</p>}>
        <Stock id="p1" />
      </Suspense>
    </ProductCard>
  );
}

// A cached component. `children` passes through: it is not part of the cache entry.
async function ProductCard({ id, children }: { id: string; children: ReactNode }) {
  "use cache";
  cacheLife("days");
  return (
    <article>
      <h2>Product {id}</h2>
      <p>Card rendered at {new Date().toISOString()}</p>
      {children}
    </article>
  );
}

async function Stock({ id }: { id: string }) {
  await connection(); // wait for a real request: stock must be live
  return (
    <p>
      {id} stock checked at {new Date().toISOString()}
    </p>
  );
}
What’s happening
  1. ProductCard is cached with the days profile. Its key is built from id ("p1"). children is a React element that the card only places in its output, never reads, so it is passed through as a reference and not serialised into the key.
  2. At build time the card renders once: Card rendered at 2026-10-25T18:04:55.457Z. Where {children} goes, the entry holds a placeholder, not the stock.
  3. The child is a <Suspense> around Stock, which awaits connection(), so it can't run at build time. The shell gets the card plus Checking stock….
  4. On each request the card comes from the cache (same timestamp) and only Stock runs. The route is ◐ with the card's 1d lifetime in the table.
  5. The pass-through only works while the cached component treats children as opaque. The docs' rule is "don't read or modify children": a cached scope that looks inside a value it can't serialise fails, as the class-instance example below shows.
next build
Route (app)      Revalidate  Expire
┌ ◐ /                    1d      1w
└ ○ /_not-found


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
curl http://localhost:4505/ (twice, one second apart; paragraphs only)
<p>Card rendered at 2026-10-25T18:04:55.457Z</p>
<p>Checking stock…</p>
<p>p1 stock checked at 2026-10-25T18:09:01.182Z</p>

<p>Card rendered at 2026-10-25T18:04:55.457Z</p>
<p>Checking stock…</p>
<p>p1 stock checked at 2026-10-25T18:09:02.217Z</p>

The fallback is still in the HTML because the shell is sent first and the stock is streamed after it (React swaps it in); a browser shows only the final paragraph.

AdvancedSerialisation rules and the error you get

Serialisation rules, and what breaking them looks like

The rules come from React's serialisation, and they differ slightly between the two directions:

AllowedNot allowed
Arguments and captured variables (part of the key)primitives, plain objects, arrays, Date, Map, Set, typed arrays, ArrayBufferclass instances, functions, symbols, WeakMap/WeakSet, URL instances
Pass-through props you don't readchildren, other JSX, Server Actionsreading or calling them inside the cached scope
Return valueeverything allowed for arguments, plus JSXclass instances, functions

Passing a class instance and reading a field from it, built in a scratch copy:

app/page.tsx (fails to build)
class Money {
  constructor(public cents: number) {}
}

async function Price({ amount }: { amount: Money }) {
  "use cache";
  return <p>{amount.cents / 100}</p>;
}

export default function Page() {
  return <Price amount={new Money(1999)} />;
}
next build (trimmed)
⨯ Error: Cannot access cents on the server. You cannot dot into a temporary client reference from a server component. You can only pass the value through to the client.
    at g (src/app/page.tsx:7:21)
What’s happening
  1. new Money(1999) is a class instance, which React's Server Component serialisation can't encode as a plain value.
  2. Instead of failing on the argument, Next.js treats it the way it treats pass-through values: as an opaque "temporary reference" that the cached function may hand on but not look inside.
  3. amount.cents does look inside, so the prerender throws with the message above (the line number in the real output points at the amount.cents expression). The fix is to pass what you need as plain data: <Price cents={1999} />.

A non-async export under a file-level directive is caught earlier, by the compiler: Error: "use cache" functions must be async functions. Every cached function must return a promise because, on a miss, it may have to wait for the cache handler or the work itself.

AdvancedWhere 'use cache' entries are stored

Where the entries live

By default 'use cache' stores entries in memory, in an LRU cache inside each Next.js server process (its size is set with cacheMaxMemorySize). That has consequences you should be able to state:

  • Self-hosted, one long-running server: entries persist across requests until evicted or the process restarts (the restart above recomputed AAPL).
  • Serverless: each instance has its own short-lived memory, so runtime entries are often not reused. What is reliable is the build-time part: anything cached into the static shell is stored with the prerender, not in that memory.
  • Several instances: each has its own copy. Use a custom cacheHandlers entry, or 'use cache: remote', for a shared store (see use cache: private and remote).
  • New deployment: the build ID is part of every key, so entries never carry over, even in a durable store. (The older fetch cache and unstable_cache do persist across deployments; see the previous model.)

Three more rules from the use cache reference that interviewers like:

  • React cache() is isolated inside 'use cache'. A value stored with React.cache outside a cached function isn't visible inside it; pass data in as arguments instead.
  • Draft Mode bypasses it. With Draft Mode on, cached functions re-run on every request and their results aren't saved.
  • Not on a Route Handler export. You can't put 'use cache' on export async function GET; call a cached helper from the handler instead.
AdvancedGotcha: cached code can't read the request
How many times does the body run?
React + TypeScript
async function Report({ team }: { team: string }) {
  const load = async (month: string) => {
    "use cache";
    return expensiveQuery(team, month);
  };
  return <>{await load("2026-10")}{await load("2026-10")}</>;
}
// Rendered for team "web" on request 1, "web" on request 2, "core" on request 3.
Show answer

Twice: once for ("web", "2026-10") and once for ("core", "2026-10").

What’s happening
  1. load captures team from Report, so its key is (team, month) plus the build and function identity.
  2. Request 1 (web): the first load("2026-10") misses and runs the query. The second call in the same render has the same key and is served from the entry just written, so the body doesn't run again.
  3. Request 2 (web): same key, served from the stored entry. No run.
  4. Request 3 (core): team differs, so a new key and a second run. Total: two executions, assuming the entries weren't evicted and the server didn't restart in between.

#cacheLife: how long cached data lives

Added

A cache entry needs an answer to "how old may this be?", and Next.js asks that question in three places, so cacheLife takes three numbers (in seconds):

  • stale: how long the browser (the client router cache) may reuse the content without asking the server at all. Next.js enforces at least 30 seconds, and sends the value to the browser in the x-nextjs-stale-time header.
  • revalidate: after this age, the next request on the server still gets the cached content immediately, and a fresh version is generated in the background (stale-while-revalidate, the same idea as ISR).
  • expire: the hard limit. If nobody requested the content for this long, the next request waits for a fresh version instead of getting the old one. It must be longer than revalidate.

Think of a bakery shelf. stale is how long a customer keeps a loaf at home before checking the shelf again. revalidate is when the baker puts a new batch in the oven: until it's ready, customers still get yesterday's loaf from the shelf, so nobody waits. expire is the "too old to sell" date: if the shelf loaf is past it, the next customer waits for the oven.

On the server: age of the entry fresh served from cache (HIT) stale old copy served (STALE), regenerated behind expired next request waits 0 revalidate expire In the browser reused without a request stale (at least 30 s; sent as x-nextjs-stale-time)
revalidate and expire act on the server copy; stale acts on the browser's copy.

The built-in profiles, from the cacheLife reference:

ProfilestalerevalidateexpireTypical use
default (no cacheLife call)5 min15 minneveranything you didn't think about
seconds30 s1 s1 minlive scores, prices
minutes5 min1 min1 hfeeds, news
hours5 min1 h1 dayinventory, weather
days5 min1 day1 weekblog posts
weeks5 min1 week30 dayspodcasts, newsletters
max5 min30 days1 yearlegal pages, archives; content you invalidate by tag

You can call cacheLife('name') with a built-in or custom profile, or pass an object; any field you leave out comes from default. Custom profiles go in next.config.ts, and the type of cacheLife() is regenerated from it, so the names autocomplete. This page uses a custom ten-second profile, so the revalidation can be watched; the profile is defined in next.config.ts, shown after the walkthrough:

app/news/page.tsx
import { cacheLife } from "next/cache";

export default async function News() {
  "use cache";
  cacheLife("tenSeconds");
  return <p>News generated at {new Date().toISOString()}</p>;
}
What’s happening
  1. cacheLife("tenSeconds") refers to a profile registered in next.config.ts (next block): browsers may reuse it for 30 s, the server regenerates it in the background once it is 10 s old, and after an hour with no traffic a request would wait for a fresh render.
  2. The page itself is cached ('use cache' at the top of the component), so the whole page is the cached output. Its lifetime is long enough to prerender, so the build makes it ○ Static with Revalidate 10s, Expire 1h (the table below).
  3. Requests in the first 10 seconds after the build get the build's HTML with x-nextjs-cache: HIT.
  4. The first request after 10 s still gets the old HTML at once (STALE), and that request triggers the regeneration. The request after it gets the new version (HIT again, with a new timestamp). Nobody waited for a render.
  5. The Cache-Control header turns the profile into instructions for a CDN: s-maxage=10 (fresh for 10 s) and stale-while-revalidate=3590 (serve stale for the rest of the hour while revalidating).
AdvancedThe custom profile in next.config.ts

The profile, registered in next.config.ts:

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

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
  cacheLife: {
    // A custom profile, short enough to watch it revalidate.
    tenSeconds: { stale: 30, revalidate: 10, expire: 3600 },
  },
};

export default nextConfig;
AdvancedObserved: stale-while-revalidate every 10 s

The build finished at 18:09:14 (a build of this app from before the /expiring page below was added; /news and the config were the same). Then a request every two seconds, with the time on the left and x-nextjs-cache from the response headers; Cache-Control was the same on every response:

curl -sD - http://localhost:4505/news, every 2 s
18:09:16  News generated at 2026-10-25T18:09:14.726Z  HIT    s-maxage=10, stale-while-revalidate=3590
18:09:18  News generated at 2026-10-25T18:09:14.726Z  HIT
18:09:20  News generated at 2026-10-25T18:09:14.726Z  HIT
18:09:22  News generated at 2026-10-25T18:09:14.726Z  HIT
18:09:24  News generated at 2026-10-25T18:09:14.726Z  HIT
18:09:26  News generated at 2026-10-25T18:09:14.726Z  STALE
18:09:28  News generated at 2026-10-25T18:09:26.372Z  HIT
18:09:30  News generated at 2026-10-25T18:09:26.372Z  HIT
18:09:32  News generated at 2026-10-25T18:09:26.372Z  HIT
18:09:34  News generated at 2026-10-25T18:09:26.372Z  HIT
18:09:36  News generated at 2026-10-25T18:09:26.372Z  STALE
18:09:38  News generated at 2026-10-25T18:09:36.483Z  HIT
AdvancedReading the revalidation cycle

Read it as a cycle: 12 seconds after the build the first request finds the page older than 10 s, gets the old copy, and the regeneration it triggers finishes at 18:09:26.372. Ten seconds after that, the same thing happens again. With no traffic nothing regenerates: revalidation is triggered by requests, not by a timer.

AdvancedExpire: when the next request waits

Expire: when the next request has to wait

expire only matters when nobody asked for the content for a long time. To see it without waiting an hour, the same app has a page with an inline profile whose expire is 300 seconds (the shortest that is still prerendered, see below):

app/expiring/page.tsx
import { cacheLife } from "next/cache";

export default async function Expiring() {
  "use cache";
  cacheLife({ stale: 30, revalidate: 10, expire: 300 }); // inline profile
  return <p>Expiring page generated at {new Date().toISOString()}</p>;
}
build finished 18:19:39, server started 18:19:41, then no requests to /expiring for 330 s
expiring.html at build time: generated at 2026-10-25T18:19:38.923Z

curl -sD - -w %{time_total} http://localhost:4505/expiring (three requests, one second apart)
18:25:11 generated at 2026-10-25T18:25:11.092Z  0.065332s  x-nextjs-cache: REVALIDATED
18:25:12 generated at 2026-10-25T18:25:11.092Z  0.002458s  x-nextjs-cache: HIT
18:25:13 generated at 2026-10-25T18:25:11.092Z  0.001693s  x-nextjs-cache: HIT
What’s happening
  1. The build prerendered /expiring at 18:19:38 with a 10-second revalidate and a 300-second expire; the route table below shows 10s 5m.
  2. Nobody requested it for five and a half minutes, so by 18:25:11 the stored copy was older than expire. Unlike the STALE case on /news, the old copy may not be served any more.
  3. The first request therefore waited for a fresh render: its HTML was generated during the request (18:25:11.092), it took 65 ms instead of about 2 ms, and the header says REVALIDATED rather than STALE.
  4. The next requests are ordinary HITs of the new copy. expire bounds the oldest content anyone can be shown; revalidate bounds how often the work is done while there is traffic.
AdvancedShort lifetimes become dynamic holes

Short lifetimes become holes

How long something may be cached also decides where it can be delivered from. The rules from the cacheLife reference:

  • revalidate: 0, or expire under 5 minutes: not prerendered at all. The cached function becomes a dynamic hole that runs (or hits the runtime cache) at request time, and needs a <Suspense> around it.
  • stale under 30 seconds: not prerendered either, because a prefetch would be out of date before the user could click.
  • stale between 30 seconds and 5 minutes: prerendered, but left out of the route's App Shell (the URL-independent version used for prefetching and for unknown dynamic params).

Of the presets, only seconds crosses a threshold (its expire is one minute). So a seconds component is cached, but at runtime, not in the shell:

app/ticker/page.tsx
import { Suspense } from "react";
import { cacheLife } from "next/cache";

export default function Page() {
  return (
    <main>
      <h1>Ticker</h1>
      <Suspense fallback={<p>Loading price…</p>}>
        <Price />
      </Suspense>
    </main>
  );
}

async function Price() {
  "use cache";
  cacheLife("seconds");
  return <p>Price generated at {new Date().toISOString()}</p>;
}
What’s happening
  1. Price is cached with seconds (revalidate: 1, expire: 60). An expire under five minutes means Next.js won't put it in a prerender, so at build time it behaves like request-time work.
  2. That's why it's inside <Suspense>: the shell gets <h1>Ticker</h1> and Loading price… (the stored ticker.html contains the fallback and no price), and the route is ◐, not ○.
  3. At request time Price runs through the in-memory cache: requests within about a second share one entry, and once it is older than its one-second revalidate, the next request renders a new one.
  4. So "cached" and "prerendered" are different questions. seconds content is cached (it protects your backend from a burst of requests) but never prerendered (it would be wrong by the time anyone saw it).
next build
Route (app)      Revalidate  Expire
┌ ○ /_not-found
├ ○ /expiring           10s      5m
├ ○ /news               10s      1h
└ ◐ /ticker


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
curl http://localhost:4505/ticker, about every 0.4 s, then after a pause
18:10:04.544  Price generated at 2026-10-25T18:10:04.565Z
18:10:04.988  Price generated at 2026-10-25T18:10:04.565Z
18:10:05.422  Price generated at 2026-10-25T18:10:04.565Z
18:10:05.855  Price generated at 2026-10-25T18:10:05.867Z
18:10:06.284  Price generated at 2026-10-25T18:10:05.867Z
18:10:08      Price generated at 2026-10-25T18:10:08.741Z
18:10:09      Price generated at 2026-10-25T18:10:08.741Z

response headers: x-nextjs-postponed: 1
                  Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate

The first three requests share the entry made at 04.565. The request at 05.855, about 1.3 s later, got a price generated 12 ms after it started: for this request-time hole, the stale entry was replaced during the request rather than served stale. After a two-second pause the same thing happens at 08.741.

AdvancedNested caches and the explicit-lifetime rule

Nested caches and lifetimes

A cached component that renders another cached component has two lifetimes in play. The rule: an explicit outer cacheLife wins, whatever the inner one says, because when the outer entry hits, its stored output already contains the inner output. Without an outer cacheLife, the outer uses default, and a shorter inner lifetime shortens it (a longer one can't lengthen it).

That last case can silently turn a page dynamic, so Next.js refuses it when the inner lifetime is short-lived. Built in a scratch copy:

app/page.tsx (fails to build)
import { cacheLife } from "next/cache";

async function Ticker() {
  "use cache";
  cacheLife("seconds");
  return <p>{new Date().toISOString()}</p>;
}

export default async function Page() {
  "use cache"; // no cacheLife here
  return (
    <main>
      <Ticker />
    </main>
  );
}
next build (trimmed)
Error: Route "/": A nested `"use cache"` with a short `expire` (under 5 minutes) is inside an outer `"use cache"` that has no `cacheLife()`. Add `cacheLife()` to the outer one to choose whether to prerender it with a longer `expire` or keep it dynamic with a short `expire`.
Learn more: https://nextjs.org/docs/messages/nested-use-cache-no-explicit-cachelife
What’s happening
  1. Ticker has seconds, a short-lived lifetime. Page is cached but has no cacheLife of its own, so it starts from default.
  2. With no explicit outer lifetime, the inner expire of 60 s would propagate and make the whole page short-lived, which means the page couldn't be prerendered. Almost nobody intends that.
  3. So the build asks you to say which you meant. cacheLife("default") (or longer) on Page keeps the page prerendered, with Ticker's output frozen inside it until the page revalidates. If you want it all short-lived, the docs' version moves the content into a child component with an explicit cacheLife("seconds") and wraps that in <Suspense>: a deliberate short-lived hole.
AdvancedMore cacheLife rules worth knowing

Other things to know

  • One cacheLife per call. You can call it in different branches (cacheLife('minutes') for "not found yet", cacheLife('days') for a published post), but only one should run per invocation. You can also compute an inline profile from data: cacheLife({ revalidate: post.revalidateSeconds ?? 3600 }).
  • Always set one. The docs recommend an explicit cacheLife in every 'use cache' scope so the lifetime is visible where the cache is, and so nested lifetimes behave predictably.
  • stale is for the browser, not for Cache-Control. The Cache-Control above came from revalidate and expire; stale travels in x-nextjs-stale-time: 30 on /news and /expiring, whose profiles set stale: 30, and 300 (the presets' five minutes) on /ticker and the other pages checked in this chapter. Server Actions that call revalidateTag, revalidatePath, updateTag or refresh clear the browser's cache immediately, whatever stale says.
  • Redefining a preset. You can override default or any preset in next.config.ts. Overriding default changes every 'use cache' that doesn't call cacheLife; overriding a time-named preset such as days surprises readers, so prefer a new name.
AdvancedGotcha: revalidation only happens on requests
What does each request get?

A page uses cacheLife({ stale: 30, revalidate: 10, expire: 3600 }) and was built at 12:00:00. Requests arrive at 12:00:05, 12:00:12, 12:00:13 and 14:00:00 (nothing in between). Which version does each get?

Show answer

Build version, build version, a new 12:00:12 version, and a fresh one rendered during the 14:00:00 request.

What’s happening
  1. 12:00:05: the entry is 5 s old, younger than revalidate. Served from cache (HIT), build version.
  2. 12:00:12: 12 s old, past revalidate but well within expire. The old build version is served at once (STALE) and this request starts a background regeneration, which finishes at about 12:00:12.
  3. 12:00:13: the regenerated entry is now in place and fresh, so this request gets the 12:00:12 version (HIT).
  4. 14:00:00: the newest entry is about two hours old, past the one-hour expire, so it may not be served. This request waits while the page renders, and gets brand-new content (the expire test above shows this as REVALIDATED).

#cacheTag and revalidating on demand

Added

Time-based revalidation guesses how often data changes. Most data changes at a known moment: someone saves a post, a price is updated, a CMS publishes. For that, you label cache entries with cacheTag('posts') inside the cached scope, and when the data changes you tell Next.js to invalidate everything with that label. One tag can be on many entries (the post list, the sidebar, the RSS route), and one cacheTag() call accepts up to 128 tags of up to 256 characters each (longer tags are skipped and tags past the 128th dropped, each with a console warning).

There are four functions for "the data changed", and the differences between them are a favourite interview topic:

updateTag(tag)revalidateTag(tag, profile)revalidatePath(path, type?)refresh()
Where it can runServer Actions onlyServer Actions and Route HandlersServer Actions and Route HandlersServer Actions only
What it targetsentries with the tagentries with the tageverything rendered for a route pathnothing in the server cache
Next readwaits for fresh datagets the stale copy while fresh data is made (with 'max')fresh on the next visitunchanged
Made forread-your-own-writes: the user who saved sees itwebhooks, background updates where a short delay is finewhen you don't know the tagsre-rendering the current page's uncached parts

So the mental model is a newspaper office. cacheTag is the label on each printed page ("sports", "weather"). revalidateTag('sports', 'max') puts a sticky note on every sports page saying "reprint when someone next picks this up, but hand out the old one meanwhile". updateTag('sports') pulls the sports pages off the shelf now, so the next reader waits for the new print; you use it when the person asking is the one who just phoned in the correction. revalidatePath('/sports') reprints one particular page whatever its labels. refresh() just asks the reader's own copy to be re-delivered; it doesn't reprint anything.

The example is a small blog: a tagged cached read, three Server Actions that each save a post and then invalidate differently, and two Route Handlers. The actions first:

app/actions.ts
"use server";

import { refresh, revalidateTag, updateTag } from "next/cache";
import { addPost } from "@/lib/db";

export async function saveWithUpdateTag(formData: FormData) {
  addPost(String(formData.get("title")));
  updateTag("posts");
}

export async function saveWithRevalidateTag(formData: FormData) {
  addPost(String(formData.get("title")));
  revalidateTag("posts", "max");
}

export async function saveWithRefresh(formData: FormData) {
  addPost(String(formData.get("title")));
  refresh();
}
What’s happening
  1. Each action is a Server Action (the file starts with "use server") that writes a post to the database, then tells Next.js about it in a different way. The page uses them as formActions on three buttons of one form, so each button posts the same title field to a different action.
  2. The list on the page comes from getPosts(), a 'use cache' function tagged posts with the max profile (shown below). Until something invalidates that tag, the cached list is served for up to 30 days.
  3. saveWithUpdateTag expires the tag at once. In the headless-browser run further down, the action's own response already contained the new post.
  4. saveWithRevalidateTag marks the tag stale with the recommended 'max' profile: the next reader gets the old list while a fresh one is made. In the run, the page didn't change after the click, the next load was still stale, and only the load after that showed the post.
  5. saveWithRefresh invalidates nothing; it asks the client router to re-render the current page. The page's uncached part (the request time) changed; the cached list didn't.
AdvancedThe tagged cached read

The cached, tagged read and the pretend database (a JSON file, so every part of the server sees the same data):

lib/posts.ts
import { cacheLife, cacheTag } from "next/cache";
import { readPosts } from "./db";

export async function getPosts() {
  "use cache";
  cacheLife("max");
  cacheTag("posts");
  return { posts: readPosts(), readAt: new Date().toISOString() };
}
AdvancedThe pretend database
lib/db.ts
import { readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

// A pretend database: a JSON file, so every part of the server sees the same data.
const file = join(tmpdir(), "nj05-posts.json");

export function readPosts(): string[] {
  try {
    return JSON.parse(readFileSync(file, "utf8"));
  } catch {
    return ["Hello world"];
  }
}

export function addPost(title: string) {
  writeFileSync(file, JSON.stringify([...readPosts(), title]));
}
AdvancedThe page with three save buttons

The page:

app/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { getPosts } from "@/lib/posts";
import { saveWithRefresh, saveWithRevalidateTag, saveWithUpdateTag } from "./actions";

export default async function Page() {
  const { posts, readAt } = await getPosts();
  return (
    <main>
      <ul id="posts">
        {posts.map((title, i) => (
          <li key={i}>{title}</li>
        ))}
      </ul>
      <p id="read-at">List read at {readAt}</p>
      <Suspense fallback={<p>…</p>}>
        <RequestTime />
      </Suspense>
      <form>
        <input name="title" required />
        <button formAction={saveWithUpdateTag}>Save + updateTag</button>
        <button formAction={saveWithRevalidateTag}>Save + revalidateTag</button>
        <button formAction={saveWithRefresh}>Save + refresh</button>
      </form>
    </main>
  );
}

async function RequestTime() {
  await connection();
  return <p id="request-time">Request rendered at {new Date().toISOString()}</p>;
}
What’s happening
  1. The page awaits getPosts() at the top level. That's allowed because it's cached data, not runtime data: the list and its readAt time go into the static shell, with the max lifetime (30d / 1y in the route table).
  2. RequestTime awaits connection() inside <Suspense>: a hole that shows when the page was last actually rendered for a request. It makes it visible whether an action caused a re-render.
  3. The ids (posts, read-at, request-time) are for the headless-browser script that reads the page.
AdvancedRoute Handler playing the CMS

And two Route Handlers that play the CMS and its webhook:

app/api/posts/route.ts
import { addPost } from "@/lib/db";

// Another system (say, a CMS) writes to the database. Nothing tells Next.js.
export async function POST(request: Request) {
  const { title } = await request.json();
  addPost(title);
  return Response.json({ added: title });
}
AdvancedThe revalidation webhook
app/api/revalidate/route.ts
import { revalidatePath, revalidateTag, updateTag } from "next/cache";
import type { NextRequest } from "next/server";

// The webhook the CMS calls after it changes content.
export async function POST(request: NextRequest) {
  const mode = request.nextUrl.searchParams.get("mode") ?? "max";
  if (mode === "max") revalidateTag("posts", "max");
  if (mode === "expire") revalidateTag("posts", { expire: 0 });
  if (mode === "path") revalidatePath("/");
  if (mode === "update") updateTag("posts");
  return Response.json({ mode });
}
What’s happening
  1. POST /api/posts writes to the database without telling Next.js, the way an external CMS would. The cached list is now wrong.
  2. POST /api/revalidate is the webhook the CMS would call. ?mode=max marks the tag stale with 'max'; ?mode=expire uses { expire: 0 }, which expires it immediately (the Route Handler substitute for updateTag); ?mode=path calls revalidatePath('/'); ?mode=update tries updateTag, which is only allowed in Server Actions.
AdvancedRoute table for the blog app
next build
Route (app)          Revalidate  Expire
┌ ◐ /                       30d      1y
├ ○ /_not-found
├ ƒ /api/posts
└ ƒ /api/revalidate


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand
AdvancedReading the blog app's route table

/ is ◐ with 30d / 1y, the max profile of the tagged list inside its shell. The two Route Handlers are ƒ: POST handlers always run per request.

AdvancedObserved: invalidating from a webhook

From a Route Handler: the webhook

With the database file deleted and the app freshly built (build finished 18:11:28), a CMS write followed by each kind of invalidation, one request at a time:

curl (GET / shows the posts and "List read at"; POST bodies are JSON)
18:11:30 GET /  posts: Hello world | List read at 2026-10-25T18:11:28.614Z
18:11:30 POST /api/posts -> {"added":"From the CMS"}
18:11:31 GET /  posts: Hello world | List read at 2026-10-25T18:11:28.614Z
18:11:31 POST /api/revalidate?mode=max -> {"mode":"max"}
18:11:31 GET /  posts: Hello world | List read at 2026-10-25T18:11:28.614Z
18:11:32 GET /  posts: Hello world, From the CMS | List read at 2026-10-25T18:11:31.313Z
18:11:32 POST /api/posts -> {"added":"Second"}
18:11:32 POST /api/revalidate?mode=expire -> {"mode":"expire"}
18:11:32 GET /  posts: Hello world, From the CMS, Second | List read at 2026-10-25T18:11:32.445Z
18:11:33 POST /api/posts -> {"added":"Third"}
18:11:33 POST /api/revalidate?mode=path -> {"mode":"path"}
18:11:33 GET /  posts: Hello world, From the CMS, Second, Third | List read at 2026-10-25T18:11:33.530Z
18:11:34 GET /  posts: Hello world, From the CMS, Second, Third | List read at 2026-10-25T18:11:33.530Z
POST /api/revalidate?mode=update -> HTTP 500

server log:
⨯ Error: updateTag can only be called from within a Server Action. To invalidate cache tags in Route Handlers or other contexts, use revalidateTag instead. See more info here: https://nextjs.org/docs/app/api-reference/functions/updateTag
What’s happening
  1. After the CMS write, GET / still shows only Hello world with the build's readAt: the cache has no idea the file changed. This is the whole reason for on-demand invalidation.
  2. revalidateTag("posts", "max") returns at once and changes nothing visible yet. The next GET / still gets the old page; that request is what notices the tag is stale and starts the re-render in the background (it read the file at 18:11:31.313). The request after it gets the new list. Stale-while-revalidate, triggered by a request, not by the revalidateTag call.
  3. revalidateTag("posts", { expire: 0 }) doesn't allow a stale copy, so the very next GET / waits for a fresh render and already includes Second.
  4. revalidatePath("/") invalidates the route through its automatic tags (the _N_T_/… soft tags you saw in index.meta) and here the next request was fresh too. It doesn't need to know about posts; it also doesn't touch other routes that show the same posts.
  5. updateTag in a Route Handler throws and the handler returns a 500. The error message tells you the alternative.
AdvancedObserved: updateTag, revalidateTag, refresh

From a Server Action: read-your-own-writes

The three buttons were driven in headless Chrome: load the page, type a title, click a button, wait 1.5 s, read the list, readAt and the request time from the DOM. Each sequence below started from a fresh build and an empty database.

headless Chrome: updateTag (build finished 18:12:40)
18:12:44 load:                     [Hello world]    | List read at 18:12:40.057Z | Request rendered at 18:12:42.060Z
18:12:45 after "Save + updateTag": [Hello world, A] | List read at 18:12:44.055Z | Request rendered at 18:12:44.056Z
18:12:47 load:                     [Hello world, A] | List read at 18:12:44.055Z | Request rendered at 18:12:45.574Z
18:12:49 load:                     [Hello world, A] | List read at 18:12:44.055Z | Request rendered at 18:12:47.582Z
headless Chrome: revalidateTag(…, "max") (build finished 18:13:08)
18:13:12 load:                         [Hello world]    | List read at 18:13:08.620Z | Request rendered at 18:13:10.575Z
18:13:14 after "Save + revalidateTag": [Hello world]    | List read at 18:13:08.620Z | Request rendered at 18:13:10.575Z
18:13:16 load:                         [Hello world]    | List read at 18:13:08.620Z | Request rendered at 18:13:14.069Z
18:13:18 load:                         [Hello world, B] | List read at 18:13:14.075Z | Request rendered at 18:13:16.079Z
18:13:20 load:                         [Hello world, B] | List read at 18:13:14.075Z | Request rendered at 18:13:18.090Z

(Dates shortened to the time of day.)

What’s happening
  1. updateTag: the action writes A, expires the posts tag and Next.js re-renders the page as part of the action's response. The list in the browser includes A straight away, read at 18:12:44.055, during the action. Later loads keep showing A from that same entry. The user who saved sees their change, which is what "read-your-own-writes" means.
  2. revalidateTag(…, "max"): the action writes B and marks the tag stale. The page in the browser didn't change at all; even the request time stayed at 18:13:10.575, so no new render arrived. The next full load still shows the stale list (and starts the background refresh); only the load after that shows B.
  3. That is the behaviour you want for a webhook and the wrong behaviour for a form: the person who just pressed Save is exactly the person who must not see stale data. In a Server Action that mutates data the user is looking at, use updateTag.
  4. revalidateTag without a second argument is the deprecated Next.js 15 form. It still works if you suppress the TypeScript error and behaves like { expire: 0 }; in 16 always pass a profile, normally 'max'.

And refresh(), from another fresh build and empty database:

headless Chrome: refresh() (build finished 18:26:16)
18:26:20 load:                   [Hello world] | List read at 18:26:16.613Z | Request rendered at 18:26:18.865Z
18:26:22 after "Save + refresh": [Hello world] | List read at 18:26:16.613Z | Request rendered at 18:26:20.859Z
18:26:24 load:                   [Hello world] | List read at 18:26:16.613Z | Request rendered at 18:26:22.361Z
18:26:26 load:                   [Hello world] | List read at 18:26:16.613Z | Request rendered at 18:26:24.368Z
What’s happening
  1. The action wrote C to the database and called refresh(). The request time changed (18:26:20.859): the router re-rendered the page, so its uncached hole ran again.
  2. The list did not include C, neither in the action's result nor on later loads: refresh() doesn't touch the server cache, and the posts entry is still the one read at build time (18:26:16.613).
  3. Use refresh() when an action changes something that is read uncached (a notification count rendered behind connection(), a cookie-dependent widget) and you want the current page to show it. If the data is behind 'use cache', you need a tag function.
AdvancedHow tags and soft tags work underneath

How tags and paths work underneath

Every cache entry stores the tags it was created with: your explicit ones from cacheTag (or from fetch(url, { next: { tags } }) in the previous model), and soft tags that Next.js derives from the route path, prefixed _N_T_. They are built from the route's files plus the URL: for /blog/hello, rendered by app/blog/[slug]/page.tsx, a scratch build's hello.meta listed _N_T_/layout, _N_T_/blog/layout, _N_T_/blog/[slug]/layout, _N_T_/blog/[slug]/page and _N_T_/blog/hello. revalidatePath works by invalidating soft tags, which is why revalidatePath('/blog', 'layout') reaches every page under that layout, and revalidatePath('/', 'layout') invalidates everything and clears the client router cache.

A tag function only records that the tag changed, with a timestamp. When an entry is read, the cache handler compares the entry's creation time with the latest invalidation of any of its tags. That design has two practical consequences:

  • Revalidation is lazy. After revalidateTag('posts', 'max'), the hundred pages that show posts are not re-rendered at once; each one revalidates when it's next visited.
  • It's per instance by default. On several servers behind a load balancer, revalidateTag on instance A only marks the tag on A. A custom cache handler shares invalidations through its updateTags() and refreshTags() hooks (for example via Redis). Self-hosting is in Caching when self-hosting.
AdvancedObserved: mixing updateTag and revalidateTag

Mixing the two in one session

What happens if the same user triggers both? The first headless run clicked updateTag, then revalidateTag, then reloaded (request-time column left out, dates shortened):

headless Chrome (build finished 18:11:56)
18:12:00 load:                         [Hello world]                    | List read at 18:11:55.894Z
18:12:02 after "Save + updateTag":     [Hello world, A (updateTag)]     | List read at 18:12:00.850Z
18:12:03 after "Save + revalidateTag": [Hello world, A (updateTag)]     | List read at 18:12:00.850Z
18:12:05 reload:                       [Hello world]                    | List read at 18:11:55.894Z
18:12:09 reload (after a refresh click): [Hello world, A (updateTag), B (revalidateTag)] | List read at 18:12:03.859Z

After the reload, the page went back to the build's list, without A, which the user had already seen. My reading of it: the updateTag action re-rendered the list for that response but never replaced the stored page; the later revalidateTag(…, 'max') then marked the tag as merely stale, so the next request was allowed to serve the oldest stored page (the build's) while regenerating. To a user that's a lost write: they saw their change, and the next load took it away. It reproduced on a second, independent run, so treat it as observed 16.4.3 behaviour that looks like a Next.js bug rather than documented semantics. Whatever the exact mechanism, the lesson is safe: for a given tag, pick one semantics for user-triggered writes (updateTag) and keep revalidateTag for out-of-band updates.

AdvancedGotcha: the form that shows the old value
What does the user see right after this action?
TypeScript
"use server";
export async function rename(formData: FormData) {
  await db.project.update({ name: String(formData.get("name")) });
  revalidateTag("projects", "max");
}
// The page shows a list from a 'use cache' function tagged "projects".
Show answer

The old name. The action response doesn't bring a new list, and the next page load still gets the stale copy; only the load after that shows the new name.

What’s happening
  1. The database is updated, then the projects tag is marked stale with the 'max' profile, which allows a stale copy to be served for up to a year while a fresh one is generated.
  2. In this chapter's run, the page in the browser didn't change after such an action, not even its uncached parts, so no new render arrived with the response.
  3. The next load is served the stale list and triggers the background re-render; the load after that gets the new list (18:13:16 vs 18:13:18 above).
  4. Replace the call with updateTag("projects") and the action's response contains the new name, and every later load does too.

#Runtime data: cookies, headers and searchParams

Added

cookies(), headers() and the searchParams prop are runtime data: values that don't exist until a particular request arrives. params belongs to the same family when its value isn't known at build time (a dynamic segment not listed by generateStaticParams). There's no way to prerender a component that reads them, so under Cache Components the component that reads them becomes a hole, and the nearest <Suspense> above it decides how big the hole is.

In the previous model, one cookies() call anywhere in the tree made the entire route dynamic: no static HTML at all. Under Cache Components it only affects the subtree under the nearest <Suspense>; everything outside still goes into the shell. So the skill is pushing the read down: read the cookie in the smallest component that needs it, wrap that component, and keep the layout, header and page frame out of it.

Two rules make runtime data work with caching:

  1. A cached scope can't read runtime data. 'use cache' output is shared between users, so letting it read a cookie would leak one user's data to another. Next.js forbids it, at build time or at request time.
  2. Pass the value, not the source. Read the cookie outside, then call the cached function with the value (getGreeting(lang)). The value becomes part of the cache key, so each distinct value gets its own entry, and requests with the same value share it.
app/page.tsx
import { Suspense } from "react";
import { cookies, headers } from "next/headers";
import { cacheLife } from "next/cache";

export default function Page({ searchParams }: PageProps<"/">) {
  return (
    <main>
      <h1>Shop</h1>
      <Suspense fallback={<p>Loading greeting…</p>}>
        <Greeting />
      </Suspense>
      <Suspense fallback={<p>Loading results…</p>}>
        <Results searchParams={searchParams} />
      </Suspense>
    </main>
  );
}

async function Greeting() {
  const lang = (await cookies()).get("lang")?.value ?? "en";
  const agent = (await headers()).get("user-agent") ?? "unknown";
  const greeting = await getGreeting(lang); // pass the value, not the cookie store
  return (
    <p>
      {greeting.message} (computed {greeting.computedAt}) for {agent}
    </p>
  );
}

async function getGreeting(lang: string) {
  "use cache";
  cacheLife("days");
  const message = lang === "fr" ? "Bonjour" : "Hello";
  return { message, computedAt: new Date().toISOString() };
}

async function Results({ searchParams }: Pick<PageProps<"/">, "searchParams">) {
  const { q = "" } = await searchParams;
  const results = await search(String(q));
  return (
    <p>
      {results.count} results for “{results.q}” (computed {results.computedAt})
    </p>
  );
}

async function search(q: string) {
  "use cache";
  cacheLife("hours");
  return { q, count: q.length * 3, computedAt: new Date().toISOString() };
}
What’s happening
  1. Page itself reads nothing at runtime. It receives searchParams as a promise (since Next.js 15) and passes the promise down unopened. Awaiting it here would make the whole page wait for the request; passing it on keeps Page and the <h1> in the shell.
  2. Greeting awaits cookies() and headers(), so it's a hole. It pulls out two plain strings, then calls getGreeting(lang). Only lang goes into the cached function, so there's one entry per language, not one per user or per browser.
  3. Results awaits the searchParams promise inside its own <Suspense> and calls search(q), cached per query string.
  4. The shell is the <h1> and the two fallbacks. At request time both holes run in parallel and stream in as each finishes.
  5. The user agent is shown but is not passed to the cached function, so different browsers with the same language share the greeting entry; the agent line is per request because it's rendered outside the cache.
AdvancedRoute table for the shop page
next build
Route (app)
┌ ◐ /
└ ○ /_not-found


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
AdvancedThe shell the build produced

The shell, from the HTML of any request:

curl http://localhost:4505/ (the <main> element of the shell)
<main><h1>Shop</h1><!--$?--><template id="B:0"></template><p>Loading greeting…</p><!--/$--><!--$?--><template id="B:1"></template><p>Loading results…</p><!--/$--></main>
AdvancedObserved: one entry per value, not per user

Four requests a second apart, with different cookies (-b), user agents (-A) and queries; only the two streamed paragraphs shown:

curl -b … -A … "http://localhost:4505/?q=…"
1  lang=fr  curl/A  ?q=chairs   Bonjour (computed 2026-10-25T18:13:39.855Z) for curl/A | 18 results for “chairs” (computed 2026-10-25T18:13:39.855Z)
2  lang=fr  curl/B  ?q=chairs   Bonjour (computed 2026-10-25T18:13:39.855Z) for curl/B | 18 results for “chairs” (computed 2026-10-25T18:13:39.855Z)
3  lang=en  curl/B  ?q=desk     Hello (computed 2026-10-25T18:13:41.916Z) for curl/B   | 12 results for “desk” (computed 2026-10-25T18:13:41.916Z)
4  (none)   curl/C  ?q=chairs   Hello (computed 2026-10-25T18:13:41.916Z) for curl/C   | 18 results for “chairs” (computed 2026-10-25T18:13:39.855Z)
What’s happening
  1. Request 1 misses both caches: getGreeting("fr") and search("chairs") both run.
  2. Request 2 has a different user agent but the same language and query: both cached results come back with request 1's times, and only the agent text differs.
  3. Request 3 changes the language to en and the query to desk: two new keys, two new entries.
  4. Request 4 has no cookie, so lang falls back to "en", the same key as request 3 (same computedAt), and chairs is request 1's entry again. Keys are made from the values the function receives, not from where they came from.
AdvancedError: runtime data inside a cached scope

Reading runtime data inside a cached scope

What the build says when you try (scratch copy):

app/page.tsx (fails to build)
import { cookies } from "next/headers";

async function Theme() {
  "use cache";
  const theme = (await cookies()).get("theme")?.value ?? "light";
  return <p>{theme}</p>;
}

export default function Page() {
  return <Theme />;
}
next build (trimmed)
Error: Route "/": `cookies()` can't be read inside `"use cache"`. Read it outside the cached function and pass what you need as an argument.
Learn more: https://nextjs.org/docs/messages/next-request-in-use-cache
    at g (src/app/page.tsx:5:24)

There's a quieter version of the same mistake: passing the promise instead of the value. According to the use cache reference (not reproduced here), <Cached promise={cookies()} />, where Cached is a 'use cache' component that awaits it, doesn't fail immediately: the build waits for a promise that can only resolve on a request and times out after 50 seconds with "Filling a cache during prerender timed out". The fix is the same: await outside, pass the value.

AdvancedThree fixes when there's no Suspense

Without a Suspense boundary

Reading runtime data with no <Suspense> above it is the "uncached or runtime data during prerendering" build error shown in the caching model. There are three ways out, and the error lists them:

  • Stream it ([stream]): wrap the reading component in <Suspense>. The default answer.
  • Cache it ([cache]): only for uncached data such as a fetch that isn't per request; it doesn't apply to cookies themselves.
  • Block ([block]): export const instant = false on the segment allows the route to wait for the request before sending anything. The build then marks it ƒ (see Partial Prerendering). Use it while migrating, not as a design.

One case has no child to wrap: a cookie that sets an attribute on <html> in the root layout (lang, data-theme). Reading it on the server makes the whole document request-bound. The docs' fix is a tiny inline script in <head> that sets the attribute before paint, so the layout stays static.

AdvancedTimestamps after cookies or searchParams

Request-time timestamps and random values

A detail that only shows up in the server log. In a scratch app, a component inside <Suspense> that awaited searchParams (or cookies()) and then rendered new Date() returned the right HTML, but on each request next start logged:

next start log (scratch app, route /sp)
Error: Route "/sp": Next.js encountered the unstable value `new Date()` while prerendering.
What’s happening
  1. The cause, measured in 16.4.3: with partialPrefetching: true (the template default) Next.js renders the dynamic part of each request twice, the normal render plus a second "runtime prerender" whose result goes into the response's RSC payload for later client navigations (a comment in next/dist/server/app-render/app-render.js describes it; see Request memoization). In that pass searchParams and cookies() resolve, but it is still a prerender, so a new Date() reached in it is an unstable value. A console.log in the component printed twice per request, and the error once per request.
  2. The same app rebuilt with partialPrefetching: false (one line changed) printed the log once per request and no error. The visitor's HTML was correct in both setups. (The Basket component at the top of the chapter didn't log the error even before it had connection(), apparently because its one-second wait came first; don't rely on timing, be explicit.)
  3. With partialPrefetching: true again and await connection() before the timestamp, the error went away: the component still started twice per request, but a log placed after connection() printed once, so the second pass stops there. await io() is the documented alternative. That's why the examples in this chapter call connection() right before rendering a request time.
AdvancedGotcha: keys with too many values
Shell or hole?
React + TypeScript
export default async function Layout({ children, params }: LayoutProps<"/shop/[slug]">) {
  const { slug } = await params; // [slug] is not in generateStaticParams
  return (
    <div>
      <Sidebar />
      <h1>{slug}</h1>
      {children}
    </div>
  );
}

What does this layout contribute to the static shell, and how would you change it?

Show answer

Nothing: awaiting an unknown params at the top of the layout makes the whole layout request-time work, so neither <Sidebar /> nor anything below it can be prerendered (and without a <Suspense> above it, the build reports it).

What’s happening
  1. params for a slug that generateStaticParams doesn't list is runtime data, just like a cookie.
  2. The await is the first line of the layout, so the layout can't render anything at build time. The sidebar and children are inside the part that waits.
  3. The fix from the caching guide is to not await at the top: make the layout non-async, keep <Sidebar /> and {children} outside, and put only the heading in a boundary: <Suspense fallback={<h1>Loading…</h1>}>{params.then(({ slug }) => <SlugHeading slug={slug} />)}</Suspense>.
  4. Now the sidebar, the page's own shell and the heading's fallback are prerendered, and only the heading streams.

#Partial Prerendering

Added

Partial Prerendering (PPR) is the rendering strategy behind everything in this chapter: one route, one response, part of it prerendered and part of it rendered for the request. The build renders the route as far as it can, saves the HTML it produced (the static shell) and React's record of where it had to stop (the "postponed" state). At request time the server sends the saved shell straight away, then resumes the render from the saved state, only for the holes, and streams each hole's HTML into the same response as it's ready.

Before PPR you had to choose per route: static (fast, same for everyone) or dynamic (fresh, slower first byte, nothing cacheable). PPR removes the choice: a product page can have its description, images and layout static, and its stock count and basket dynamic, without the dynamic parts slowing the first byte. It existed as an experiment in Next.js 14 and 15 (experimental.ppr and the experimental_ppr segment export); 16 removed both, and PPR is simply what Cache Components does.

The build output tells you which routes got what:

SymbolMeaningWhen you see it with Cache Components
○ Staticprerendered as static contentnothing in the route waits for a request (cached content counts as static)
◐ Partial Prerenderstatic HTML with dynamic server-streamed contenta shell plus at least one <Suspense> hole
ƒ Dynamicserver-rendered on demandRoute Handlers that read the request or aren't GET; pages that opt out with instant = false and block on runtime data

An app with one of each, starting with the ◐ route:

app/product/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { cacheLife } from "next/cache";

export default function Product() {
  return (
    <main>
      <h1>Product</h1>
      <Description />
      <Suspense fallback={<p id="fallback">Loading stock…</p>}>
        <Stock />
      </Suspense>
    </main>
  );
}

async function Description() {
  "use cache";
  cacheLife("days");
  return <p>A very good chair (described at {new Date().toISOString()})</p>;
}

async function Stock() {
  await connection();
  await new Promise((resolve) => setTimeout(resolve, 1500)); // a slow inventory service
  return <p id="stock">3 left (checked at {new Date().toISOString()})</p>;
}
What’s happening
  1. /product has three parts: a static <h1>, a cached <Description /> (days), and <Stock /> inside <Suspense>.
  2. Stock awaits connection(), so it can't run at build time, and then a slow 1.5-second inventory call. The build saves the heading, the description and the Loading stock… fallback as the shell.
  3. Shell plus hole is ◐ in the route table, and the description's lifetime puts 1d / 1w in its Revalidate and Expire columns.
  4. At request time the shell goes out at once and the stock streams in about 1.5 seconds later, in the same response (measured below).
AdvancedA Route Handler that reads the request
app/api/now/route.ts
export async function GET(request: Request) {
  return Response.json({
    at: new Date().toISOString(),
    agent: request.headers.get("user-agent"),
  });
}
AdvancedA page that opts out of instant
app/blocking/page.tsx
import { cookies } from "next/headers";

export const instant = false; // allow this route to block on runtime data

export default async function Blocking() {
  const theme = (await cookies()).get("theme")?.value ?? "light";
  return <h1>Theme: {theme}</h1>;
}
What’s happening
  1. / is plain JSX and a <Link>: nothing waits, so it's ○, a fully static HTML file. The link is there for the navigation test further down.
  2. /api/now is a GET handler that reads the request's headers, so it can't be prerendered: ƒ. (A GET handler that reads nothing is prerendered like a page; see Caching and Route Handlers.)
  3. /blocking reads a cookie with no <Suspense>. Normally that's a build error; export const instant = false tells Next.js that this route is allowed to block, so the build passes and prints ƒ.
AdvancedRoute table with ○, ◐ and ƒ
next build
Route (app)      Revalidate  Expire
┌ ○ /
├ ○ /_not-found
├ ƒ /api/now
├ ƒ /blocking
└ ◐ /product             1d      1w


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand
AdvancedWhat the server sends for each route

What the server sends

When each part of /product arrived, and the headers of each route (next start, then curl):

node chunks.mjs http://localhost:4505/product
"<h1>Product</h1>"           +57 ms (chunk 1)
"A very good chair"          +57 ms (chunk 1)
"Loading stock"              +57 ms (chunk 1)
"3 left"                     +1571 ms (chunk 11)
end of response              +1586 ms (14 chunks)
curl -sD - (selected headers)
/          x-nextjs-cache: HIT
           x-nextjs-prerender: 1
           Cache-Control: s-maxage=31536000
/product   x-nextjs-prerender: 1
           x-nextjs-postponed: 1
           Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
/api/now   (none of these headers)
/blocking  x-nextjs-prerender: 1
           x-nextjs-postponed: 1
           Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
What’s happening
  1. The first chunk of /product, 57 ms after the request, holds the heading, the cached description and the fallback: the saved shell. The stock arrives 1.5 seconds later in chunk 11 of the same response, and the response ends right after.
  2. Inside the HTML, the hole is a marker: the shell has <!--$?--><template id="B:0"></template><p id="fallback">Loading stock…</p><!--/$-->, and the streamed part arrives later as <div hidden id="S:0">…</div> followed by a tiny inline script call, $RC("B:0","S:0"), that moves the content into place. That is React's streaming SSR; PPR's contribution is that everything before the hole came from a file instead of a render.
  3. x-nextjs-postponed: 1 says "this response is a saved shell plus a resumed render". / is a whole static file (HIT, cacheable for a year by a CDN). /product's response is marked private, no-store because it includes request-specific content; on platforms that support PPR, the CDN serves the shell part itself and streams the rest from the server.
  4. /blocking also says postponed, but its stored shell, .next/server/app/blocking.html, is 0 bytes: there was nothing to prerender before the cookie read. That's what ƒ means for a page under Cache Components: it still goes through the same machinery, with an empty shell.

The files the build left in .next/server/app/ for these routes:

ls .next/server/app (these routes only)
blocking.html  blocking.meta  blocking.segments
index.html     index.meta     index.rsc      index.segments
product.html   product.meta   product.segments

index.rsc is the RSC payload of the fully static home page. /product and /blocking have no single .rsc file, since their payload has a hole in it too; each .segments folder holds per-segment RSC files (_tree.segment.rsc, _full.segment.rsc, __PAGE__.segment.rsc), the pieces segment-level prefetching works with.

AdvancedInstant navigation from a prefetched shell

Instant navigation

The shell isn't only for the first page load. The client router prefetches a route's shell when a <Link> to it is in view, so a click can show it immediately and only the holes wait. Measured in headless Chrome: load /, wait 1.5 s, click the link to /product, then poll the DOM:

headless Chrome
prefetch requests seen before the click:
  /product?_rsc=5CB68i4pnAekjehf  3ms
  /product?_rsc=VBn6xwPKNK4Ukesu  3ms

after the click:
  "/ | Home"                                                      @3ms
  "/product | Product | fallback"                                 @31ms
  "/product | Product | 3 left (checked at 2026-10-25T18:14:42.818Z)"  @1535ms
What’s happening
  1. While / was idle, the router fetched two RSC requests for /product (the _rsc query is a cache-busting hash). With Partial Prefetching, a plain <Link> prefetches the route's App Shell: the static and cached parts, not the holes.
  2. 31 ms after the click, the URL was /product and the page showed its heading and the Loading stock… fallback, from the prefetched shell, without waiting for the server.
  3. At about 1.5 s the stock appeared: the navigation's request rendered the hole and streamed it in.
  4. A route with no shell (ƒ, like /blocking) has nothing useful to prefetch, so a click on a link to it waits for the server. That's why Cache Components treats "every route has a static shell" as the goal, and why instant = false is an escape hatch. Prefetching in depth is in Prefetching and instant navigation.
AdvancedParams, metadata and errors with PPR

Things that interact with PPR

  • Dynamic params. With generateStaticParams, each listed URL gets its own shell with the param-specific content. Other URLs are served the generic App Shell at once and upgraded in the background, then cached for the next visitor (ISR with Cache Components, in Incremental Static Regeneration). generateStaticParams must return at least one entry under Cache Components, and dynamicParams is no longer allowed.
  • Metadata. generateMetadata follows the same rules as components: uncached or runtime data in it needs a decision. Next.js detects bots that can't handle streamed metadata and renders the page fully for them instead of sending the shell first (measured with a Twitterbot user agent in Metadata and SEO).
  • Errors while streaming. The status code is sent with the shell, so an error inside a hole can't turn the response into a 500; it renders the nearest error boundary inside the stream. (Measured on this site: the response stayed 200 with the error sent as a digest in the stream; see Streaming SSR in practice and Error handling with error.js.)
AdvancedGotcha: a top-level hole empties the shell
Predict the symbol for each route
  1. A page that only renders a cached component with cacheLife('days').
  2. A page with a cached header and a <Suspense> around a component that calls headers().
  3. A page that renders a component with cacheLife('seconds') inside <Suspense>.
  4. GET handler that returns Response.json({ ok: true }).
  5. GET handler that reads request.headers.
Show answer

○, ◐, ◐, ○, ƒ.

What’s happening
  1. Cached output with a long lifetime is prerendered, so nothing is left for the request: ○, with 1d / 1w in the Revalidate/Expire columns (like /news in cacheLife, which printed ○ /news 10s 1h).
  2. The header goes into the shell and headers() makes a hole: ◐.
  3. seconds has an expire under five minutes, so it's excluded from prerendering and acts as a hole even though it's cached: ◐ (the /ticker route earlier).
  4. A GET handler that reads nothing is prerendered at build time like a page: ○ (a scratch build of exactly this handler printed ○ /api/static; more in Caching and Route Handlers).
  5. Reading the request makes it per request: ƒ, like /api/now above.

#'use cache: private' and 'use cache: remote'

Added

Plain 'use cache' is a cache shared by everyone and stored wherever the default handler keeps it (memory in each server process). Two variants change one of those two things:

  • 'use cache: remote' keeps sharing but changes the storage: the entry goes to the remote cache handler, meant to be a durable store shared by all instances (Redis, a KV service, your platform's cache). It's for request-time work that would otherwise miss in every fresh serverless instance and hammer the backend.
  • 'use cache: private' changes the sharing: the result is per user. The function may read cookies(), headers() and searchParams directly, and in production the result is not stored in any server cache across requests. It is kept in the browser (the client router cache) for its stale time, and it can be included in prefetches, which is where its value comes from.

A mental model: 'use cache' is the shop's shelf, 'use cache: remote' is the central warehouse that every branch can draw from, and 'use cache: private' is the customer's own bag: nothing in it goes back on the shelf, but they don't have to queue again for what they're carrying.

The comparison table from the use cache: remote reference:

'use cache''use cache: remote''use cache: private'
Server-side storagein memory, or the default cache handlerthe remote cache handlernone
Shared between usersyesyesno, per client
May read cookies or headers itselfno, pass values inno, pass values inyes
Costnonestorage and a network lookupnone
Survives a deploynonon/a

One page with both: a price shared by everyone with the same currency, and "picks" for the current user.

app/page.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";
import { cacheLife } from "next/cache";

export default function Page() {
  return (
    <main>
      <h1>Product</h1>
      <Suspense fallback={<p>Loading price…</p>}>
        <Price />
      </Suspense>
      <Suspense fallback={<p>Loading picks…</p>}>
        <Picks />
      </Suspense>
    </main>
  );
}

async function Price() {
  const currency = (await cookies()).get("currency")?.value ?? "USD";
  const price = await getPrice(currency);
  return (
    <p>
      Price: {price.amount} {currency} (computed {price.computedAt})
    </p>
  );
}

// Shared by everyone with the same currency, stored in the "remote" cache handler.
async function getPrice(currency: string) {
  "use cache: remote";
  cacheLife({ expire: 3600 });
  return {
    amount: currency === "EUR" ? "18.40" : "19.99",
    computedAt: new Date().toISOString(),
  };
}

async function Picks() {
  const picks = await getPicks();
  return (
    <p>
      Picks for {picks.user} (computed {picks.computedAt})
    </p>
  );
}

// Per user: reads the cookie itself. Never stored in a server cache in production.
async function getPicks() {
  "use cache: private";
  cacheLife({ stale: 60 });
  const user = (await cookies()).get("user")?.value ?? "guest";
  return { user, computedAt: new Date().toISOString() };
}
What’s happening
  1. Price reads the currency cookie (so it's a hole) and passes the plain string to getPrice. getPrice is 'use cache: remote', so its entry, keyed by currency, goes to the remote handler. This app doesn't configure one, and with no cacheHandlers config both default and remote are the built-in in-memory LRU, so here it behaves like 'use cache'; in production you'd point remote at a shared store.
  2. getPicks is 'use cache: private' and reads the user cookie itself, which plain 'use cache' forbids. cacheLife({ stale: 60 }) lets the browser keep the result for a minute.
  3. Both are behind runtime data, so neither is in the shell; the route is ◐ with no Revalidate column.
  4. Expected at runtime: the price is computed once per currency and shared across users; the picks are computed on every request, because private results aren't kept on the server.
AdvancedRoute table for private and remote
next build
Route (app)
┌ ◐ /
└ ○ /_not-found


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
AdvancedObserved: shared price, per-request picks
curl -b "user=…; currency=…" http://localhost:4505/ (one second apart)
18:15:02 ann EUR  Price: 18.40 EUR (computed 2026-10-25T18:15:02.765Z) | Picks for ann (computed 2026-10-25T18:15:02.761Z)
18:15:03 ann EUR  Price: 18.40 EUR (computed 2026-10-25T18:15:02.765Z) | Picks for ann (computed 2026-10-25T18:15:03.814Z)
18:15:04 bob EUR  Price: 18.40 EUR (computed 2026-10-25T18:15:02.765Z) | Picks for bob (computed 2026-10-25T18:15:04.853Z)
18:15:05 bob USD  Price: 19.99 USD (computed 2026-10-25T18:15:05.895Z) | Picks for bob (computed 2026-10-25T18:15:05.894Z)
What’s happening
  1. Ann's two requests share the remote price entry (02.765), while her picks are recomputed on the second request (02.761, then 03.814): the private cache isn't kept on the server, even for the same user a second later.
  2. Bob with EUR gets Ann's price entry: same key ("EUR"), shared across users. His picks are his own.
  3. Bob with USD is a new key for the price and a new computation; picks again recomputed.
  4. So in production 'use cache: private' saves nothing on the server for repeat page loads. What it buys is in the browser: the client router can reuse the result for stale seconds on client navigations, and a prefetch can include it before the user clicks. Within one request, matching calls can also share one result.
AdvancedChoosing private, remote or plain

Choosing between them

Use 'use cache: remote' when the work runs at request time (it's behind cookies or connection(), so it's outside the shell), it's expensive or rate-limited, and many requests share the same key: prices per currency, CMS content per language, a dashboard aggregate everyone sees. The docs' rule of thumb is to cache on the dimension with few values and filter the rest in memory: getProductsByCategory(category), not one entry per price filter. Avoid it when the operation is already fast (under about 50 ms), when keys are mostly unique per request, or when data changes every few seconds; the network lookup then costs more than it saves. For content in the static shell, plain 'use cache' is usually enough.

Use 'use cache: private' rarely: when a function already reads cookies or headers and can't reasonably be refactored to take values as arguments, or when compliance requires that request-specific data never lands in a server cache. Its stale must be at least 30 seconds to be part of per-link prefetching and at least 5 minutes to be in the App Shell. If you only want request-level deduplication, cacheLife({ stale: Infinity }) keeps it from lowering the route's client stale time.

AdvancedNesting rules and cache handlers

Nesting rules and handlers

Remote and private don't mix: a remote cache can be nested inside another remote cache or inside plain 'use cache', but not inside a private one, and a private cache can't be nested inside a remote one. connection() is forbidden in all of them.

The handlers are configured in next.config.ts. This is a configuration sketch, not one of the chapter's apps (the handler files are your own implementations of the CacheHandler interface from next/cache):

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

const nextConfig: NextConfig = {
  cacheComponents: true,
  cacheHandlers: {
    default: require.resolve("./cache-handlers/memory-handler.js"), // 'use cache'
    remote: require.resolve("./cache-handlers/redis-handler.js"), // 'use cache: remote'
  },
};

export default nextConfig;

A handler implements get, set, refreshTags, getExpiration and updateTags; the last three are how tag invalidations reach every instance. You can add more named handlers and use them as 'use cache: <name>'. 'use cache: private' has no handler and can't be configured. Self-hosting setups are in Caching when self-hosting.

AdvancedGotcha: private is not a server cache

#The previous model: fetch cache, revalidate and unstable_cache

Added

Most App Router code written before 2026 uses the model Next.js had from 13 to 15, and 16 still runs it for any app that doesn't set cacheComponents: true. Interviewers ask about it constantly, so you need to read it fluently. Its two big differences from Cache Components:

  1. Static or dynamic is decided per route. The build renders each route; if nothing in it is request-specific, the whole route is prerendered (○) and served as a file until revalidated. One cookies(), headers(), searchParams, fetch with cache: 'no-store', or export const dynamic = 'force-dynamic' anywhere makes the whole route render per request (ƒ). There is no shell with holes.
  2. Data is cached through fetch. Next.js patches the server's fetch and stores responses in the Data Cache (on disk under .next/cache/fetch-cache when self-hosting), controlled per call with cache: 'force-cache' | 'no-store' and next: { revalidate, tags }. For non-fetch work there's unstable_cache. Route-level knobs are route segment config exports: dynamic, revalidate, fetchCache.

The defaults moved between versions, which is where interview questions come from:

Next.js 13–14Next.js 15 (and 16 without Cache Components)
fetch() with no optionscached (force-cache)not cached ("auto no cache"), but fetched once at build time if the route is static
GET Route Handlercached by defaultnot cached by default
Client router reuse of dynamic pages30 s0 s (staleTimes.dynamic default changed)
cookies(), headers(), params, searchParamssynchronouspromises you await (synchronous access deprecated in 15, removed in 16)

This app turns Cache Components off (its own next.config.ts) and shows each behaviour. Every route fetches https://httpbin.org/uuid, which returns a new random UUID on every real request, so a repeated UUID means a cache answered. The first route is the classic surprise:

app/default-fetch/page.tsx
import { getUuid } from "@/lib/uuid";

export default async function Page() {
  const uuid = await getUuid(); // no cache option
  return <p>uuid {uuid}</p>;
}
What’s happening
  1. getUuid() (next block) fetches https://httpbin.org/uuid with no cache option. In Next.js 15 and later that means the response is not stored in the Data Cache.
  2. But nothing in this route is request-specific: no cookies, no headers, no no-store. So the build treats the whole route as static, renders it once, and the fetch runs exactly once, during next build.
  3. The route prints as ○ and every request returns the build's UUID (3da2a19d… in the run below) with x-nextjs-cache: HIT and Cache-Control: s-maxage=31536000. The fetch never runs again until a rebuild or a revalidation.
  4. That's the most common surprise in the previous model: "uncached fetch" and "static page" are separate decisions, and the page decision wins.
AdvancedConfig without Cache Components

The app's config turns Cache Components off, and every route uses the same fetch helper:

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

// No cacheComponents: this app uses the previous caching model (Next.js 15 defaults).
const nextConfig: NextConfig = {};

export default nextConfig;
AdvancedThe fetch helper used by every route
lib/uuid.ts
// httpbin.org returns a new random UUID on every real request,
// so a repeated UUID means a cache answered instead.
export async function getUuid(init?: RequestInit) {
  const res = await fetch("https://httpbin.org/uuid", init);
  return res.ok ? ((await res.json()) as { uuid: string }).uuid : `HTTP ${res.status}`;
}
AdvancedRoute: fetch with no-store
app/no-store/page.tsx
import { getUuid } from "@/lib/uuid";

export default async function Page() {
  const uuid = await getUuid({ cache: "no-store" });
  return <p>uuid {uuid}</p>;
}
AdvancedRoute: segment-level revalidate (ISR)
app/isr/page.tsx
import { getUuid } from "@/lib/uuid";

export const revalidate = 10; // seconds

export default async function Page() {
  const uuid = await getUuid();
  return <p>uuid {uuid}</p>;
}
AdvancedRoute: cookies plus a force-cache fetch
app/cookies-force-cache/page.tsx
import { cookies } from "next/headers";
import { getUuid } from "@/lib/uuid";

export default async function Page() {
  const theme = (await cookies()).get("theme")?.value ?? "light";
  const uuid = await getUuid({ cache: "force-cache" });
  return (
    <p>
      theme {theme}, uuid {uuid}
    </p>
  );
}
AdvancedRoute: force-dynamic with two fetches
app/force-dynamic/page.tsx
import { getUuid } from "@/lib/uuid";

export const dynamic = "force-dynamic";

export default async function Page() {
  const plain = await getUuid(); // no cache option
  const cached = await getUuid({ cache: "force-cache" });
  return (
    <p>
      plain {plain}, force-cache {cached}
    </p>
  );
}
AdvancedRoute: unstable_cache with a tag
app/unstable-cache/page.tsx
import { cookies } from "next/headers";
import { unstable_cache } from "next/cache";

const getReport = unstable_cache(
  async (team: string) => ({ team, builtAt: new Date().toISOString() }),
  ["report"], // key prefix
  { tags: ["reports"], revalidate: 3600 },
);

export default async function Page() {
  const team = (await cookies()).get("team")?.value ?? "core";
  const report = await getReport(team);
  return (
    <p>
      report for {report.team} built at {report.builtAt}
    </p>
  );
}
AdvancedRoute Handler revalidating the tag
app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";

export async function POST() {
  revalidateTag("reports", "max");
  return Response.json({ revalidated: "reports" });
}
AdvancedRoute table without Cache Components
next build
Route (app)               Revalidate  Expire
┌ ○ /_not-found
├ ƒ /api/revalidate
├ ƒ /cookies-force-cache
├ ○ /default-fetch
├ ƒ /force-dynamic
├ ○ /isr                         10s      1y
├ ƒ /no-store
└ ƒ /unstable-cache


○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand
AdvancedObserved: what each old-model route returned

There's no ◐ in this legend: without Cache Components, a route is either a file or a function. Then the running app (build finished 18:17:03):

curl -sD - http://localhost:4505/<route> (x-nextjs-cache and Cache-Control from the headers)
18:17:13 /default-fetch        uuid 3da2a19d-dd52-41e8-af85-6ad4c11670ce          HIT    s-maxage=31536000
18:17:13 /default-fetch        uuid 3da2a19d-dd52-41e8-af85-6ad4c11670ce          HIT    s-maxage=31536000
18:17:13 /no-store             uuid a85b279d-7f3d-4187-8529-3dbb3470993e                 private, no-cache, no-store, max-age=0, must-revalidate
18:17:14 /no-store             uuid 9729cebc-58ba-4da3-b0cd-839aa9f4efc2                 private, no-cache, no-store, …
18:17:14 /cookies-force-cache  theme dark,  uuid 5bdfb478-f300-4ce0-bf9f-ba60b6824051   private, no-cache, no-store, …
18:17:14 /cookies-force-cache  theme light, uuid 5bdfb478-f300-4ce0-bf9f-ba60b6824051   private, no-cache, no-store, …
18:17:14 /force-dynamic        plain cfa6e23f-1634-4d56-bec2-05a988c33f32, force-cache 5bdfb478-f300-4ce0-bf9f-ba60b6824051
18:17:14 /force-dynamic        plain da6c6d95-168a-47c5-bfaa-45b53fb95d4c, force-cache 5bdfb478-f300-4ce0-bf9f-ba60b6824051
18:17:14 /isr                  uuid 3da2a19d-dd52-41e8-af85-6ad4c11670ce          STALE  s-maxage=10, stale-while-revalidate=31535990
18:17:15 /isr                  uuid 3ed4361b-b3fe-47a4-b637-b2ed8f179b0f          HIT    s-maxage=10, stale-while-revalidate=31535990
What’s happening
  1. /default-fetch: the build's UUID on both requests, served as a static file (HIT, a one-year s-maxage), as described above.
  2. /no-store: cache: 'no-store' is a signal that the data must be fresh, so the whole route becomes ƒ and every request fetches again (two different UUIDs).
  3. /cookies-force-cache: reading a cookie makes the route ƒ, but the force-cache fetch is still served from the Data Cache: same UUID for both themes. In the previous model the route cache (is the HTML static?) and the Data Cache (is this fetch cached?) are independent.
  4. /force-dynamic: the route is rendered per request, and the fetch with no option became no-store (two different plain UUIDs). The explicit force-cache fetch still came from the Data Cache, and even returned the same entry as /cookies-force-cache: Data Cache entries are keyed by the request, not by the route. So in 16.4.3, force-dynamic changed the default for fetches without options but did not override an explicit force-cache. The bundled previous-model guide says otherwise (it calls force-dynamic equivalent to fetchCache = 'force-no-store', which would refetch even force-cache calls), but 16.4.3's own fetch patch (next/dist/server/lib/patch-fetch.js) applies the force-dynamic default only to fetches with no cache or revalidate option, which is what the run showed. Trust the explicit option, not the guide's sentence.
  5. /isr: export const revalidate = 10 makes a static page with a lifetime: ○ with Revalidate 10s. Eleven seconds after the build, the first request gets the stale page (STALE, still the build's UUID, which the build had fetched once and reused for both static pages) and the next one gets a regenerated page with a new UUID. This is ISR in the App Router.
AdvancedObserved: unstable_cache, tags and a restart

And unstable_cache, then on-demand revalidation by tag, then a server restart:

curl (-b team=… where shown)
18:17:30 /unstable-cache                  report for core built at 2026-10-25T18:17:30.486Z
18:17:30 /unstable-cache  -b team=web     report for web built at 2026-10-25T18:17:30.506Z
18:17:31 /unstable-cache                  report for core built at 2026-10-25T18:17:30.486Z
18:17:31 POST /api/revalidate -> {"revalidated":"reports"}
18:17:31 /unstable-cache                  report for core built at 2026-10-25T18:17:30.486Z
18:17:32 /unstable-cache                  report for core built at 2026-10-25T18:17:31.576Z
(next start restarted)
18:17:35 /unstable-cache                  report for core built at 2026-10-25T18:17:31.576Z
18:17:35 /force-dynamic                   plain 345cb2e2-7ce5-4230-92a9-b01dced8dc79, force-cache 5bdfb478-f300-4ce0-bf9f-ba60b6824051
What’s happening
  1. unstable_cache(fn, keyParts, { tags, revalidate }) wraps a non-fetch function. The key is the key parts plus the arguments, so core and web get separate entries.
  2. revalidateTag("reports", "max") from the Route Handler marks both entries stale. The next core request still gets the old value and triggers the refresh; the one after gets the new one. The same stale-while-revalidate semantics as under Cache Components.
  3. After a restart of next start, both the unstable_cache entry and the force-cache fetch entry are still there. The Data Cache is on disk (.next/cache/fetch-cache held four files at this point, all of kind FETCH, including the two unstable_cache results), so it survives restarts, and on Vercel it survives deployments. That is a real difference from 'use cache', whose default store is memory and whose keys include the build ID.
AdvancedRoute segment config and its replacements

Route segment config, decoded

What each export did in the previous model, and what replaces it under Cache Components (from the migration guide):

Previous modelWhat it didWith Cache Components
export const dynamic = 'force-dynamic'render the route per request; fetches without options become no-storeremove it: request-time work is already request-time
export const dynamic = 'force-static'prerender anyway; cookies()/headers() return empty values'use cache' + cacheLife('max'); optionally export const ensureStatic = 'navigation' (16.4+) to fail if it isn't static
export const dynamic = 'error'fail the build if the route can't be staticremove; make caching explicit with 'use cache'; ensureStatic to enforce
export const revalidate = 3600ISR for the whole route'use cache' + cacheLife('hours') (or a custom profile) on what should be cached
export const revalidate = 0always dynamicremove
export const fetchCache = '…'change the default cache option for every fetch in the segmentremove; wrap what should be cached in 'use cache'
fetch(url, { cache: 'force-cache', next: { revalidate, tags } })Data Cache entry with lifetime and tagsa 'use cache' function with cacheLife and cacheTag around the fetch
unstable_cache(fn, keys, { tags, revalidate })Data Cache for non-fetch workkeeps working; migrate to 'use cache' when convenient
unstable_noStore() / noStore()opt a component out of cachingremove (nothing is cached by default); await connection() if it must be per request
export const dynamicParams = false404 for params not in generateStaticParamsnot allowed (build error); call notFound() in the page
generateStaticParams returning []render every param on first visitmust return at least one param
export const runtime = 'edge'Edge runtime for the routenot supported; remove any runtime export, even 'nodejs'
experimental_ppropt a route into the PPR experimentremoved in 16; PPR is the default

With the flag on, a segment that still exports dynamic, revalidate or fetchCache is a build error, which is how the migration finds them for you. fetch caching and unstable_cache keep working under Cache Components, so they can be migrated later.

AdvancedChecklist for reading an existing app

Reading an existing app

When you open an unfamiliar App Router codebase, these questions tell you which model it uses and what is cached:

  1. next.config: is cacheComponents: true there? If not, everything in this topic applies. (experimental.dynamicIO or experimental.useCache means it adopted the experimental 15-era version of Cache Components; 16 removed both flags in favour of cacheComponents.)
  2. The build output: run next build and read the symbols. ○ routes are files that only change on revalidation; ƒ routes run on every request. Look for Revalidate values.
  3. Segment exports: grep -rn "export const \(dynamic\|revalidate\|fetchCache\)" finds the route-level decisions.
  4. fetch options: which calls pass force-cache, next.revalidate or next.tags? In a 13–14 codebase, calls with no options are cached too.
  5. Invalidation: grep -rn "revalidateTag\|revalidatePath". Every tagged fetch or unstable_cache should have someone invalidating its tag; a single-argument revalidateTag(tag) is a 15-era call that 16 marks deprecated.
AdvancedGotcha: uncached fetch on a static page
Which UUIDs repeat?

Without Cache Components, a page reads cookies() and then makes two fetches to the same URL: fetch(url) and fetch(url, { cache: "force-cache" }). You request it three times. What do you see?

Show answer

The first UUID differs on every request; the second is the same on every request.

What’s happening
  1. Reading cookies() makes the route dynamic, so the page renders per request and nothing is served from a prerendered file.
  2. fetch(url) with no option on a dynamic route is not cached (15+), so it runs every time: three different UUIDs.
  3. fetch(url, { cache: "force-cache" }) stores the response in the Data Cache, keyed by the request, and reuses it on every later request and across routes (the /cookies-force-cache and /force-dynamic routes above shared one entry).
  4. Don't carry this answer back to 13–14 without checking: there fetch defaulted to force-cache, with exceptions such as fetches made after cookies() or headers(). Reading an older app always starts with its version.

#Caching layers at a glance

Added

The Next.js 13–15 documentation described caching as four layers, and interviewers still use those names: Request Memoization, the Data Cache, the Full Route Cache and the Router Cache. The 16 documentation no longer uses those headings, but the layers didn't disappear: each has a counterpart under Cache Components, with new names and some different rules. If you can map one set of names to the other, you can answer the question in either vocabulary.

Follow one request from the browser inwards and ask, at each step, "can someone answer from a copy here?":

Browser client router cache Prerender HTML + RSC per route Render one request's component tree Cached results functions and fetches Data source DB, API, CMS closer to the user: saves more, harder to invalidate the work itself 13–15 name Router Cache Full Route Cache Request Memoization Data Cache 16 with Cache Components Client Cache static shell same (fetch, cache()) 'use cache' entries stored browser memory .next/server/app, CDN nowhere: one render memory / handler (16) .next/cache (old) lifetime stale (≥ 30 s) revalidate / expire one request cacheLife / revalidate
The same four places to answer from a copy, under their 13–15 names and their Cache Components names.

The table interviewers love, with what this chapter observed:

13–15 nameWhat it holdsWith Cache Components (16)Lifetime and invalidationSeen in this chapter
Request Memoizationthe result of a fetch GET with the same URL and options, or a React.cache function, within one server renderunchanged; but React.cache is isolated inside each 'use cache' scopeends with the render; nothing to invalidatecovered in Request memoization
Data Cachefetch responses with force-cache or next.revalidate, and unstable_cache results, across requests and users'use cache' entries (default: in-memory LRU per process; cacheHandlers for others). The fetch Data Cache and unstable_cache still work alongsidecacheLife revalidate/expire, cacheTag + updateTag/revalidateTag; 'use cache' keys include the build ID, the Data Cache persists across deploysunstable_cache and force-cache entries survived a server restart; a 'use cache' entry didn't
Full Route Cachethe HTML and RSC payload of a static route, made at build time or by ISRthe static shell (plus postponed state) of every route, and ISR entries for paramsregenerated when the cached content inside it revalidates; revalidatePath, tagsindex.html + index.meta with postponed; x-nextjs-cache: HIT/STALE/REVALIDATED
Router CacheRSC payloads of visited and prefetched routes in the browserthe Client Cache: prefetched App Shells and visited segmentsstale from cacheLife (at least 30 s), staleTimes globally; cleared by a page reload, by the revalidation functions or refresh() in a Server Action, by router.refresh(), and by setting or deleting cookiesthe prefetched shell of /product shown 31 ms after a click

How the main differences come out in an interview:

  • Granularity. The Full Route Cache was all-or-nothing per route. The static shell is per component: a route always has one, and only the holes are per request.
  • What's cached by default. 13–14: most fetch calls and most routes. 15: routes, but not fetch (it just ran at build time on static routes). 16 with Cache Components: only what's under 'use cache', plus anything that can be prerendered.
  • Where the data cache lives. The old Data Cache was durable (disk, or the platform's store) and survived deploys. 'use cache' is in memory by default and scoped to a build. That's deliberate: a new deploy may change the shape of what a function returns.
  • How you invalidate. The functions are the same family (revalidateTag, revalidatePath), with 16's additions: updateTag for read-your-own-writes, refresh for re-rendering the client, and a required profile argument for revalidateTag.
Which layer answered?

Under Cache Components, for each situation say which copy the visitor got.

  1. A product page's description, cached with cacheLife('days'), on a direct visit an hour after the build.
  2. The same page, reached by clicking a <Link> that was in view for a few seconds, before the server responded.
  3. getUser(id) called by the layout and by the page during one request, where getUser is wrapped in React.cache.
  4. getPrice('EUR') ('use cache', behind a cookie read) on the second request after a server restart.
Show answer

The static shell; the client cache (prefetched App Shell); request memoization; and the in-memory 'use cache' store (a miss on the first request after the restart, a hit on the second).

What’s happening
  1. The description is long-lived cached content, so it was rendered at build time into the static shell, and the direct visit was served that stored shell with no render of the description at all.
  2. The <Link> prefetched the route's App Shell into the client cache, so the click rendered from that copy (31 ms in the /product measurement) and only the holes waited for the server.
  3. Both calls happen in one render with the same argument, so React.cache returns the first call's promise to the second. Nothing is stored past the request.
  4. The restart emptied the in-memory store, so the first request computed the price and stored it; the second request hit that entry. On serverless, or with several instances, a remote handler would be needed for this to hit reliably.

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.