Next.js NotesRohit’s interview study guide
Chapter 06

Rendering Strategies

When each page's HTML is made (at build time, per request, or both in one response) and how to see which one you got: route tables, response headers and streams measured on a running app.

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

#Static and dynamic rendering

Added

Rendering a route means running its Server Components to produce two things: the HTML for a first visit and the RSC payload the router uses for client-side navigations. The only real question is when that happens. Static (prerendered) output is made once, during next build or later in the background, stored, and handed to every visitor. Dynamic output is made for each request, because it depends on something only that request knows: a cookie, a header, the query string, or data that must be fresh.

You don't pick static or dynamic with a switch. Next.js decides from what your code touches. During next build it renders every route, and anything that can finish without a request (JSX, imports, cached data) ends up in the build output. Anything that needs the request (or uncached I/O) can't finish, and what happens next depends on the caching model. With Cache Components (cacheComponents: true, the default for new apps in Next.js 16) the decision is made per component: the build keeps everything it could render as a static shell and leaves a hole wherever a <Suspense> boundary wraps request-time work; at request time the holes are rendered and streamed into the same response. That's Partial Prerendering, covered from the caching side in Partial Prerendering. In the previous model (every version before 16, and 16 without Cache Components) the decision is per route: one cookies() call anywhere and the whole route is rendered per request.

A mental model that works for both: think of a printed form. The build prints as much of the form as it can without knowing who will fill it in. Cache Components print the form with blanks left for the personal parts and fill them in at the counter, in front of the visitor. The previous model has only two options: hand out a fully printed copy, or write the whole form out by hand for each visitor.

○ Static ◐ Partial Prerender ƒ Dynamic header, nav content (cached data) same for everyone header, nav content (cached data) cart, greeting header, nav content (read per request) cart, greeting whole page from the cache shell at once, holes stream in HTML waits for the render made at build time (or in the background) made for each request
With Cache Components the line between static and dynamic runs through the page, not around it.

What decides it, per kind of code. The left column is the model this site teaches; the right column is what you'll meet in apps built before Next.js 16 (or with cacheComponents off), checked against the "previous model" guide in the bundled docs.

What the code doesCache Components (Next.js 16 default)Previous model (13–15, or 16 without Cache Components)
Plain JSX, imports, synchronous workIn the static shellStatic
A 'use cache' function or componentIn the static shell (unless its cacheLife is very short)Not available; fetch cache or unstable_cache instead
An uncached fetch or database callA hole: must sit inside <Suspense>, or the build failsRuns once at build and is baked in (unless fetch uses no-store)
cookies(), headers(), searchParamsA hole: must sit inside <Suspense>The whole route becomes dynamic
await connection()A hole: everything after it runs per requestThe whole route becomes dynamic
paramsIn the shell for values generateStaticParams returned, a hole otherwise● for listed values, others rendered on first request
new Date(), Math.random()Build error unless after connection() or inside 'use cache'Evaluated once at build and frozen
export const dynamic = 'force-dynamic'Not allowed (build error)Forces the whole route dynamic

The route table that next build prints is the quickest way to see the result. With Cache Components the symbols are ○ (Static: the whole page prerendered), ◐ (Partial Prerender: a static shell plus streamed holes) and ƒ (Dynamic: an empty shell, everything rendered per request). The previous model uses ○, ● (SSG: prerendered from generateStaticParams) and ƒ.

Here is one small café app with one route of each kind. lib/db.ts stands in for a database: each call takes 300 ms.

lib/db.ts
// A stand-in for a database: every call takes 300 ms, like a real query would.
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export async function getMenu() {
  await wait(300);
  return ["Flat white", "Cortado", "Cold brew"];
}

export async function getStock(item: string) {
  await wait(300);
  return { item, left: Math.floor(Math.random() * 20) };
}
app/about/page.tsx
export default function About() {
  return <h1>About our café</h1>;
}
app/menu/page.tsx
import { cacheLife } from "next/cache";
import { getMenu } from "@/lib/db";

export default async function Menu() {
  "use cache";
  cacheLife("hours");
  const items = await getMenu();
  return (
    <ul>
      {items.map((item) => (
        <li key={item}>{item}</li>
      ))}
    </ul>
  );
}
app/account/page.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";

async function Greeting() {
  const name = (await cookies()).get("name")?.value ?? "guest";
  return <p>Welcome back, {name}</p>;
}

export default function Account() {
  return (
    <main>
      <h1>Your account</h1>
      <Suspense fallback={<p>Loading your details…</p>}>
        <Greeting />
      </Suspense>
    </main>
  );
}
app/stock/page.tsx
import { Suspense } from "react";
import { getStock } from "@/lib/db";

async function Stock() {
  const { item, left } = await getStock("Cold brew");
  return <p>{item}: {left} left</p>;
}

export default function StockPage() {
  return (
    <main>
      <h1>Today’s stock</h1>
      <Suspense fallback={<p>Checking stock…</p>}>
        <Stock />
      </Suspense>
    </main>
  );
}
app/blocking/page.tsx
import { cookies } from "next/headers";
import { getStock } from "@/lib/db";

export const instant = false;

export default async function Blocking() {
  const name = (await cookies()).get("name")?.value ?? "guest";
  const { left } = await getStock("Cold brew");
  return <h1>Hello {name}, {left} cold brews left</h1>;
}
next build
Route (app)      Revalidate  Expire
┌ ○ /_not-found
├ ○ /about
├ ◐ /account
├ ƒ /blocking
├ ○ /menu                1h      1d
└ ◐ /stock


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand
What’s happening
  1. /about touches nothing but JSX, so the build renders it completely: ○. Nothing about it can differ between visitors, so there is nothing to do at request time.
  2. /menu awaits a 300 ms query, but the page is a 'use cache' component with cacheLife("hours"), so the build waits for the query, stores the result and puts it in the HTML: ○ again, now with Revalidate 1h and Expire 1d columns. Those come from the hours profile (revalidate after an hour, expire after a day) and turn the static page into ISR, the subject of Incremental Static Regeneration.
  3. /account reads a cookie. The build can't know any visitor's cookie, so Greeting can't finish; it is wrapped in <Suspense>, so the build keeps <h1>Your account</h1> and the fallback paragraph as the static shell and leaves Greeting as a hole: ◐.
  4. /stock doesn't read the request at all, but its query is uncached. With Cache Components uncached I/O is treated like request data (it has to be fresh), so it becomes a hole too: ◐. Removing the <Suspense> would fail the build; wrapping getStock in 'use cache' would make the page ○ instead.
  5. /blocking awaits a cookie and the query at the top of the page, outside any <Suspense>. That would normally fail the build (see below); export const instant = false tells Next.js this route is allowed to block, so the build accepts an empty shell and prints ƒ.
  6. /_not-found is the built-in 404 page and appears in every table. What this shows: one app, one config, and the symbol of each route follows from the code inside it, not from a setting.
AdvancedWatching the responses

The symbols are a promise about what a visitor gets. To check that promise, run the app (next build && next start -p 4506) and look at the responses. observe.mjs is a 25-line script (shown in Streaming SSR in practice) that prints the status, the caching headers, and each chunk of the body as it arrives with the visible HTML in it. Chunks with no visible HTML (scripts carrying the RSC payload) are left out below. The ~30 ms before the first byte is mostly the script connecting; compare the numbers with each other.

node observe.mjs http://localhost:4506/<route> (cookie name=Rohit)
/about
200 after 41 ms
cache-control: s-maxage=31536000
x-nextjs-cache: HIT
x-nextjs-prerender: 1, 1
chunk 0 +42 ms 6705 B  <h1>About our café</h1>
done +42 ms

/menu
200 after 29 ms
cache-control: s-maxage=3600, stale-while-revalidate=82800
x-nextjs-cache: HIT
x-nextjs-prerender: 1, 1
chunk 0 +29 ms 6959 B  <li>Flat white</li> | <li>Cortado</li> | <li>Cold brew</li>
done +30 ms

/account
200 after 41 ms
cache-control: private, no-cache, no-store, max-age=0, must-revalidate
x-nextjs-prerender: 1
x-nextjs-postponed: 1
chunk 0 +42 ms 1552 B  <h1>Your account</h1> | template id="B:0" | <p>Loading your details…</p>
chunk 3 +62 ms 923 B  hidden id="S:0" | <p>Welcome back, <!-- -->Rohit</p>
done +64 ms

/stock
200 after 30 ms
cache-control: private, no-cache, no-store, max-age=0, must-revalidate
x-nextjs-prerender: 1
x-nextjs-postponed: 1
chunk 0 +31 ms 1549 B  <h1>Today’s stock</h1> | template id="B:0" | <p>Checking stock…</p>
chunk 11 +330 ms 937 B  hidden id="S:0" | <p>Cold brew<!-- -->: <!-- -->0<!-- --> left</p>
done +330 ms

/blocking
200 after 31 ms
cache-control: private, no-cache, no-store, max-age=0, must-revalidate
x-nextjs-prerender: 1
x-nextjs-postponed: 1
chunk 0 +334 ms 1437 B  <h1>Hello <!-- -->Rohit<!-- -->, <!-- -->2<!-- --> cold brews left</h1>
done +336 ms
What’s happening
  1. The two ○ routes come straight from the cache: x-nextjs-cache: HIT, x-nextjs-prerender: 1, and the whole page in one chunk. Their Cache-Control tells a CDN how long it may keep them: a year for /about (s-maxage=31536000), an hour plus up to 23 hours of serving stale for /menu (s-maxage=3600, stale-while-revalidate=82800, the hours profile again).
  2. The ◐ routes answer with x-nextjs-postponed: 1: the prerendered shell was sent and the rest of the render was postponed to this request. Their Cache-Control is private, no-store because the full response contains per-visitor content that a shared cache must not keep.
  3. On /account, chunk 0 is the shell: the heading, a <template id="B:0"> placeholder and the fallback. 20 ms later the greeting arrives as <div hidden id="S:0"> with the cookie's value in it, plus a small script that swaps it into B:0. Same response, same status, two moments.
  4. On /stock the shell arrives at 31 ms and the stock line at 330 ms: the 300 ms query ran after the shell had been sent, so the visitor saw the page frame immediately. Run it again and the number changes (it printed 19, 1 and 0 on three requests): it really is per request.
  5. /blocking also sent its headers at 31 ms, but its first byte of HTML only at 334 ms: with no fallback to show, the visitor stares at a blank tab until the query finishes. That's what ƒ costs, and it's the trade-off instant = false makes explicit.
AdvancedBuild errors for unprerenderable pages

What the build says when a page can't be prerendered

With Cache Components, request-time work outside <Suspense> isn't silently made dynamic; the build stops and tells you to choose. This page reads a cookie with no boundary around it (built in a scratch copy of the template, not one of the chapter's apps):

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

export default async function Home() {
  const name = (await cookies()).get("name")?.value ?? "guest";
  return <h1>Hello {name}</h1>;
}
next build (Next.js 16.4.3, Cache Components on)
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
    at body (<anonymous>)
    at html (<anonymous>)
What’s happening
  1. The prerender reaches await cookies() with no request, so it can't continue, and it walks up the tree looking for a <Suspense> boundary to use as the hole. There is none: the whole page would have to wait for the request.
  2. The three fixes map to the three outcomes in the café app: [stream] is /account (◐), [cache] is /menu (○, only for data that's the same for everyone, so not for a cookie), [block] is /blocking (ƒ).
  3. The stack only names body and html. To find the line, open the route in next dev (the overlay points at it) or run next build --debug-prerender.

Unstable values get the same treatment. new Date() in a page body fails with Next.js encountered the unstable value `new Date()` while prerendering and offers [dynamic] (call await connection() first), [cache] ('use cache' freezes one value for everyone until revalidation), [client] (render it in the browser) and [measure] (performance.now() for telemetry). In the previous model the same line runs once at build time and every visitor sees the build's timestamp, which is exactly the kind of silent surprise the new errors exist to prevent.

AdvancedThe previous model: one switch per route

Turn Cache Components off and the same ideas give very different results. This app is what an App Router project looked like on Next.js 13 to 15:

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

// The previous model: what every app had before Next.js 16, and what 16 does without Cache Components.
const nextConfig: NextConfig = {
  cacheComponents: false,
};

export default nextConfig;
lib/db.ts
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export async function getStock(item: string) {
  await wait(300);
  return { item, left: Math.floor(Math.random() * 20) };
}
app/about/page.tsx
export default function About() {
  return <h1>About our café</h1>;
}
app/stock/page.tsx
import { getStock } from "@/lib/db";

export default async function StockPage() {
  const { item, left } = await getStock("Cold brew");
  return <p>{item}: {left} left</p>;
}
app/account/page.tsx
import { cookies } from "next/headers";

export default async function Account() {
  const name = (await cookies()).get("name")?.value ?? "guest";
  return <h1>Welcome back, {name}</h1>;
}
app/live-stock/page.tsx
import { connection } from "next/server";
import { getStock } from "@/lib/db";

export default async function LiveStock() {
  await connection();
  const { item, left } = await getStock("Cold brew");
  return <p>{item}: {left} left</p>;
}
next build
Route (app)
┌ ○ /_not-found
├ ○ /about
├ ƒ /account
├ ƒ /live-stock
└ ○ /stock


○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand
node observe.mjs, two requests to each route (trimmed)
/stock       200 after 27 ms   cache-control: s-maxage=31536000   x-nextjs-cache: HIT   <p>Cold brew: 12 left</p>
/stock       200 after 25 ms   cache-control: s-maxage=31536000   x-nextjs-cache: HIT   <p>Cold brew: 12 left</p>
/account     200 after 64 ms   cache-control: private, no-cache, no-store, max-age=0, must-revalidate   <h1>Welcome back, Rohit</h1>
/live-stock  200 after 343 ms  cache-control: private, no-cache, no-store, max-age=0, must-revalidate   <p>Cold brew: 4 left</p>
/live-stock  200 after 345 ms  cache-control: private, no-cache, no-store, max-age=0, must-revalidate   <p>Cold brew: 6 left</p>
What’s happening
  1. /stock has no request API in it, so the previous model calls it ○ and renders it once, during the build: the random stock number (12 here) is baked into the HTML and every visitor sees it for a year (s-maxage=31536000). The 300 ms "query" never runs again. This is the classic bug of the old model: a database call in a page that looks dynamic but isn't.
  2. /account calls cookies(), and that one call makes the whole route ƒ: there is no shell to send early, the page is rendered top to bottom for each request.
  3. /live-stock fixes the frozen number the old way, with await connection() (in older code: export const dynamic = 'force-dynamic', export const revalidate = 0, cache: 'no-store' on a fetch, or unstable_noStore()). It works, but the whole response now waits for the query: 343 ms before the first byte, on every request.
  4. Compare with the Cache Components app: there /stock streams a shell in 31 ms and the number at 330 ms, and you couldn't have shipped the frozen version by accident because the build would have refused it.
AdvancedAwaiting at the top makes everything below dynamic
Advancednext dev always renders on demand
Two visitors, one minute apart

An app on Next.js 15 (no Cache Components) has the /stock page from the previous-model app above. Visitor A loads it at 10:00, visitor B at 10:01. Each time getStock would pick a new random number. What does each visitor see, and what does the route table say?

Show answer

Both see the same number (12 in the run above), and the route table says ○ /stock.

What’s happening
  1. The page uses no request API (cookies(), headers(), searchParams, connection()), so the previous model treats it as static and prerenders it during next build.
  2. During that prerender getStock runs once, waits 300 ms and returns a random number; the HTML with that number is written to the build output.
  3. At 10:00 and 10:01 next start serves that stored HTML (x-nextjs-cache: HIT); getStock doesn't run at all. With no revalidate, the page never changes until the next deploy.
  4. To make it per request: add await connection() (whole route ƒ), or add export const revalidate = 60 to refresh it at most once a minute (ISR). In Next.js 16 with Cache Components the build would have stopped and asked you to choose.
AdvancedHow the defaults moved between versions

#generateStaticParams and dynamicParams

Added

A dynamic segment such as app/products/[id] stands for an unlimited number of URLs, and the build can't prerender an unlimited number of pages. generateStaticParams gives the build a list: "these are the ids worth preparing in advance". Every URL then falls into one of three groups: listed (prerendered during the build), unlisted but valid (rendered on its first visit, then stored like a prerendered page), and not allowed (a 404). What differs between the caching models is how the second group is handled on that first visit and how you express the third.

Think of a café's display counter. The best sellers are baked before opening (the list). Anything else on the menu is made the first time someone orders it, and the café keeps a tray of it on the counter for the next customer (generated on demand, then cached). Something that isn't on the menu at all is refused (404). generateStaticParams decides what's on the counter at opening time; the fallback behaviour decides what happens with everything else.

The pieces, side by side:

Cache Components (16 default)Previous modelPages Router
The listgenerateStaticParams, must return at least one entrygenerateStaticParams, may return []getStaticPaths paths
Unlisted URL, first visitApp Shell sent at once, the page streams in, then the full page is storedRendered while the visitor waits, then storedfallback: true or 'blocking'
First visit waits for the full pageensureStatic = 'navigation'the default (dynamicParams = true)fallback: 'blocking'
Only listed URLs allowedcall notFound() for anything elseexport const dynamicParams = falsefallback: false

Here is the Cache Components version. Two best sellers are listed; the catalogue has four products.

lib/products.ts
export type Product = { id: string; name: string; price: number };

const products: Product[] = [
  { id: "1", name: "Espresso machine", price: 499 },
  { id: "2", name: "Burr grinder", price: 189 },
  { id: "3", name: "Milk jug", price: 24 },
  { id: "4", name: "Tamper", price: 39 },
];

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export async function getProduct(id: string) {
  "use cache";
  await wait(500); // a slow query, cached per id
  const product = products.find((p) => p.id === id);
  return product ? { ...product, fetchedAt: new Date().toISOString() } : null;
}

export async function getBestSellerIds() {
  return ["1", "2"];
}
app/products/[id]/page.tsx
import { Suspense } from "react";
import { notFound } from "next/navigation";
import { getBestSellerIds, getProduct } from "@/lib/products";

export async function generateStaticParams() {
  const ids = await getBestSellerIds();
  return ids.map((id) => ({ id }));
}

async function ProductDetails({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const product = await getProduct(id);
  if (!product) notFound();
  return (
    <p>
      {product.name}: ${product.price} (fetched {product.fetchedAt})
    </p>
  );
}

export default function ProductPage({ params }: PageProps<"/products/[id]">) {
  return (
    <main>
      <h1>Shop</h1>
      <Suspense fallback={<p>Loading product…</p>}>
        <ProductDetails params={params} />
      </Suspense>
    </main>
  );
}
next build
Route (app)
┌ ○ /_not-found
└   /products/[id]
  ├ ◐ /products/[id]
  ├ ○ /products/1
  └ ○ /products/2


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
What’s happening
  1. generateStaticParams runs once during the build and returns [{ id: "1" }, { id: "2" }]. Next.js renders the page once per entry, with params resolved to that value.
  2. For /products/1 and /products/2 the params are known and getProduct is 'use cache', so ProductDetails finishes during the build: both rows are ○, complete HTML with the product in it.
  3. The extra row ◐ /products/[id] is the fallback, also called the App Shell: one more render with params left unresolved. ProductDetails awaits params inside <Suspense>, so the shell is <h1>Shop</h1> plus "Loading product…". Any id not in the list starts from this shell.
  4. Where you await params matters. Because the page passes the promise down instead of awaiting it at the top, the heading stays in the shell for every URL, listed or not.
  5. notFound() for a missing product is how you say "not on the menu" with Cache Components: dynamicParams isn't available (the build rejects it, see below).
AdvancedListed, unlisted and unknown ids at runtime

The run below hits a listed product, an unlisted one three times, and one that doesn't exist twice, one second apart (next start on a fresh build; fetched is the time inside the cached query, so it shows when the data was really fetched).

node observe.mjs http://localhost:4506/products/<id>
/products/1
200 after 51 ms
cache-control: s-maxage=900, stale-while-revalidate=31535100
x-nextjs-cache: HIT
x-nextjs-prerender: 1, 1
chunk 0 +52 ms 7747 B  <h1>Shop</h1> | <p>Espresso machine<!-- -->: $<!-- -->499<!-- --> (fetched <!-- -->2026-10-25T18:04:02.687Z<!-- -->)</p>

/products/3   (first visit)
200 after 39 ms
cache-control: private, no-cache, no-store, max-age=0, must-revalidate
x-nextjs-prerender: 1
x-nextjs-postponed: 1
chunk 0 +43 ms 1556 B  <h1>Shop</h1> | template id="B:0" | <p>Loading product…</p>
chunk 10 +559 ms 984 B  hidden id="S:0" | <p>Milk jug<!-- -->: $<!-- -->24<!-- --> (fetched <!-- -->2026-10-25T18:04:08.346Z<!-- -->)</p>

/products/3   (second and third visits)
200 after 32 ms
cache-control: s-maxage=900, stale-while-revalidate=31535100
x-nextjs-cache: HIT
x-nextjs-prerender: 1
chunk 0 +33 ms 7737 B  <h1>Shop</h1> | <p>Milk jug<!-- -->: $<!-- -->24<!-- --> (fetched <!-- -->2026-10-25T18:04:08.346Z<!-- -->)</p>

/products/999   (first visit)
200 after 26 ms
cache-control: private, no-cache, no-store, max-age=0, must-revalidate
x-nextjs-prerender: 1
x-nextjs-postponed: 1
chunk 0 +27 ms 1556 B  <h1>Shop</h1> | template id="B:0" | <p>Loading product…</p>
chunk 9 +529 ms 112 B  <meta name="robots" content="noindex"/>

/products/999   (second visit)
404 after 34 ms
cache-control: s-maxage=900, stale-while-revalidate=31535100
x-nextjs-cache: HIT
x-nextjs-prerender: 1
chunk 0 +35 ms 7710 B  <meta name="robots" content="noindex"/> | <h1>Shop</h1> | <p>Loading product…</p>
What’s happening
  1. /products/1 is a cache hit: the HTML written during the build, in one chunk. s-maxage=900 is the default cache profile's 15-minute revalidate, because getProduct has no cacheLife.
  2. The first visit to /products/3 gets the fallback shell at 43 ms, then the product at 559 ms (the 500 ms query plus rendering). The visitor never waited on a blank page, even for a URL the build had never seen.
  3. While that response streamed, Next.js rendered /products/3 with its now-known params in the background and stored it. The second visit is a HIT with the product already in the HTML and the same fetched time: it is the stored page, not a new render. This is ISR for dynamic routes; in the Pages Router it was fallback: true.
  4. /products/999 starts the same way, but notFound() runs after the 200 status and the shell were already sent. Next.js can't change the status any more, so it streams <meta name="robots" content="noindex"> to keep the page out of search results.
  5. The stored result for 999 is a real 404 from the second visit on, still a HIT. Its HTML contains the shell, and the browser renders the not-found UI when the page hydrates: headless Chrome showed "404 This page could not be found." for that URL.
AdvancedBlocking first visits with ensureStatic

A real 404 and no fallback: ensureStatic = 'navigation'

If a soft 200 on the first visit to a bad URL isn't acceptable (or you don't want a loading state for a page that will be static anyway), ensureStatic = 'navigation' requires the route's output to be complete static HTML. For an unlisted id the first request then waits for the full page, like fallback: 'blocking' in the Pages Router. It also needs generateStaticParams and fails the build if anything in the route is uncached or reads the request, which is the guarantee you want for a public catalogue page.

lib/products.ts
export type Product = { id: string; name: string; price: number };

const products: Product[] = [
  { id: "1", name: "Espresso machine", price: 499 },
  { id: "2", name: "Burr grinder", price: 189 },
  { id: "3", name: "Milk jug", price: 24 },
  { id: "4", name: "Tamper", price: 39 },
];

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export async function getProduct(id: string) {
  "use cache";
  await wait(500); // a slow query, cached per id
  const product = products.find((p) => p.id === id);
  return product ? { ...product, fetchedAt: new Date().toISOString() } : null;
}

export async function getBestSellerIds() {
  return ["1", "2"];
}
app/products/[id]/page.tsx
import { notFound } from "next/navigation";
import { getBestSellerIds, getProduct } from "@/lib/products";

export const ensureStatic = "navigation"; // the first visit waits for the complete page

export async function generateStaticParams() {
  const ids = await getBestSellerIds();
  return ids.map((id) => ({ id }));
}

export default async function ProductPage({ params }: PageProps<"/products/[id]">) {
  const { id } = await params;
  const product = await getProduct(id);
  if (!product) notFound();
  return (
    <main>
      <h1>Shop</h1>
      <p>
        {product.name}: ${product.price} (fetched {product.fetchedAt})
      </p>
    </main>
  );
}
next build
Route (app)
┌ ○ /_not-found
└   /products/[id]
  ├ ◐ /products/[id]
  ├ ○ /products/1
  └ ○ /products/2


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
node observe.mjs http://localhost:4506/products/<id>
/products/3     200 after 615 ms   x-nextjs-cache: MISS   <p>Milk jug: $24 (fetched 2026-10-25T18:21:15.321Z)</p>
/products/3     200 after 41 ms    x-nextjs-cache: HIT    <p>Milk jug: $24 (fetched 2026-10-25T18:21:15.321Z)</p>
/products/999   404 after 566 ms   x-nextjs-cache: MISS   <meta name="robots" content="noindex"/>
/products/999   404 after 27 ms    x-nextjs-cache: HIT    <meta name="robots" content="noindex"/>
What’s happening
  1. The page awaits params at the top, outside <Suspense>. Normally that leaves only an empty fallback shell for unlisted ids; ensureStatic = 'navigation' says the visitor should get complete static output, so the server waits for the render instead of streaming.
  2. The first visit to /products/3 is a MISS that takes 615 ms (the query plus rendering) and arrives as one complete page; the second is a HIT with the same fetched time.
  3. Because nothing was sent before notFound() ran, the first visit to /products/999 gets a real 404 status. That's the difference from the streaming version above.
  4. The trade-off is the first visitor's wait. Choose it for pages where search engines and link previews must see the right status and full content even on first request; choose the streaming fallback when a fast first paint matters more.
AdvancedWhat the build rejects with Cache Components

generateStaticParams must return at least one entry. The build uses the listed params to prove the route doesn't read the request where it shouldn't; an empty list gives it nothing to check:

next build: generateStaticParams returns []
Error: When using Cache Components, all `generateStaticParams` functions must return at least one result. This is to ensure that we can perform build-time validation that there is no other dynamic accesses that would cause a runtime error.

Learn more: https://nextjs.org/docs/messages/empty-generate-static-params
    at a.s.params (src/app/products/[id]/page.tsx:4:10)
  2 |
  3 | export async function generateStaticParams() {
> 4 |   return [];
    |          ^

If you truly know no values at build time, the docs suggest returning a placeholder ([{ id: "__placeholder__" }]) and calling notFound() for it, at the cost of weaker build-time checks. And dynamicParams is gone:

next build: export const dynamicParams = false (trimmed)
Error: Route segment config "dynamicParams" is not compatible with `nextConfig.cacheComponents`. Please remove it.
> 2 | export const dynamicParams = false;
    |              ^^^^^^^^^^^^^
AdvancedThe previous model: dynamicParams true and false

Without Cache Components, dynamicParams is the switch for unlisted URLs. Here is the same product page twice, once with the default (true) and once with false:

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

const nextConfig: NextConfig = {
  cacheComponents: false,
};

export default nextConfig;
lib/products.ts
export type Product = { id: string; name: string; price: number };

const products: Product[] = [
  { id: "1", name: "Espresso machine", price: 499 },
  { id: "2", name: "Burr grinder", price: 189 },
  { id: "3", name: "Milk jug", price: 24 },
  { id: "4", name: "Tamper", price: 39 },
];

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export async function getProduct(id: string) {
  await wait(500);
  const product = products.find((p) => p.id === id);
  return product ? { ...product, fetchedAt: new Date().toISOString() } : null;
}

export async function getBestSellerIds() {
  return ["1", "2"];
}
app/products/[id]/page.tsx
import { notFound } from "next/navigation";
import { getBestSellerIds, getProduct } from "@/lib/products";

// export const dynamicParams = true; // the default

export async function generateStaticParams() {
  const ids = await getBestSellerIds();
  return ids.map((id) => ({ id }));
}

export default async function ProductPage({ params }: PageProps<"/products/[id]">) {
  const { id } = await params;
  const product = await getProduct(id);
  if (!product) notFound();
  return (
    <main>
      <h1>Shop</h1>
      <p>
        {product.name}: ${product.price} (fetched {product.fetchedAt})
      </p>
    </main>
  );
}
next build
Route (app)
┌ ○ /_not-found
└   /products/[id]
  ├ ● /products/1
  └ ● /products/2


○  (Static)  prerendered as static content
●  (SSG)     prerendered as static HTML (uses generateStaticParams)

The dynamicParams = false app is identical except for one line in the page. Its other two files are repeated so that it is a complete app of its own:

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

const nextConfig: NextConfig = {
  cacheComponents: false,
};

export default nextConfig;
lib/products.ts
export type Product = { id: string; name: string; price: number };

const products: Product[] = [
  { id: "1", name: "Espresso machine", price: 499 },
  { id: "2", name: "Burr grinder", price: 189 },
  { id: "3", name: "Milk jug", price: 24 },
  { id: "4", name: "Tamper", price: 39 },
];

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export async function getProduct(id: string) {
  await wait(500);
  const product = products.find((p) => p.id === id);
  return product ? { ...product, fetchedAt: new Date().toISOString() } : null;
}

export async function getBestSellerIds() {
  return ["1", "2"];
}
app/products/[id]/page.tsx
import { notFound } from "next/navigation";
import { getBestSellerIds, getProduct } from "@/lib/products";

export const dynamicParams = false; // only the ids generateStaticParams returned

export async function generateStaticParams() {
  const ids = await getBestSellerIds();
  return ids.map((id) => ({ id }));
}

export default async function ProductPage({ params }: PageProps<"/products/[id]">) {
  const { id } = await params;
  const product = await getProduct(id);
  if (!product) notFound();
  return (
    <main>
      <h1>Shop</h1>
      <p>
        {product.name}: ${product.price} (fetched {product.fetchedAt})
      </p>
    </main>
  );
}
next build
Route (app)
┌ ○ /_not-found
└   /products/[id]
  ├ ● /products/1
  └ ● /products/2


○  (Static)  prerendered as static content
●  (SSG)     prerendered as static HTML (uses generateStaticParams)
node observe.mjs, dynamicParams = true (the default)
/products/1     200 after 38 ms    x-nextjs-cache: HIT    <p>Espresso machine: $499 (fetched 2026-10-25T18:05:32.049Z)</p>
/products/3     200 after 567 ms   x-nextjs-cache: MISS   <p>Milk jug: $24 (fetched 2026-10-25T18:05:36.285Z)</p>
/products/3     200 after 25 ms    x-nextjs-cache: HIT    <p>Milk jug: $24 (fetched 2026-10-25T18:05:36.285Z)</p>
/products/999   404 after 538 ms   x-nextjs-cache: MISS   <meta name="robots" content="noindex"/>
node observe.mjs, dynamicParams = false
/products/1     200 after 44 ms   x-nextjs-cache: HIT   <p>Espresso machine: $499 (fetched 2026-10-25T18:05:43.891Z)</p>
/products/3     404 after 27 ms   x-nextjs-cache: HIT   This page could not be found.
/products/3     404 after 35 ms   x-nextjs-cache: HIT   This page could not be found.
/products/999   404 after 40 ms   x-nextjs-cache: HIT   This page could not be found.
What’s happening
  1. Both builds print ● (SSG) for the two listed ids: the previous model's symbol for "prerendered from generateStaticParams". There is no fallback row, because there is no shell to prerender in this model.
  2. With dynamicParams = true, /products/3 exists but wasn't listed: the first visit is a MISS that waits 567 ms for the whole render (nothing is streamed), then the page is stored and the next visit is a HIT. A non-existent id costs a full render too, then gets a real 404 from notFound().
  3. With dynamicParams = false, every unlisted id is a 404 straight away (27–40 ms, so the 500 ms query never ran): Next.js serves its prerendered not-found page. /products/3 is a real product, but it isn't on the list, so it's refused.
  4. Use false when the list is the complete, closed set (a fixed set of locales, docs versions) and you want to stop people (and bots) from making the server render arbitrary ids. Use the default when the list is just the popular subset.
AdvancedgenerateStaticParams doesn't run again
AdvancedValues are strings, and nested segments chain

#Incremental Static Regeneration

Added

A static page is fast and cheap, but its content is only as fresh as the last build. Incremental Static Regeneration (ISR) keeps the static speed and lets one page at a time be rebuilt while the app runs, with no redeploy. Two things can trigger a rebuild: time (the page is older than its revalidate period) and an event (your code calls revalidateTag or revalidatePath after the data changed).

The rule that makes ISR fast is stale-while-revalidate: when a page is too old, the next visitor still gets the old copy instantly, and that request kicks off a regeneration in the background. Only the visitors after the regeneration see the new page. Picture a newspaper stand: when today's edition is out of date, the next customer still gets the copy on the stand while the next edition is printed, and later customers get the new one. Nobody waits at the stand, and nobody prints a paper when no one is buying (no traffic means no regeneration).

With Cache Components you don't configure ISR per route. A route is regenerated on the schedule of the shortest-lived 'use cache' inside it, set with cacheLife (the profiles and the stale/revalidate/expire values are explained in cacheLife: how long cached data lives), and invalidated by the tags you attach with cacheTag (cacheTag and revalidating on demand). The route table prints the result in its Revalidate and Expire columns. In the previous model you set it on the route with export const revalidate = <seconds> and invalidated with revalidatePath or fetch tags. Here the goal is to watch it happen: what each request gets, and when.

The app is a menu page whose price comes from a fake headless CMS. Two Route Handlers play the outside world: one changes the price in the CMS (Next.js isn't told), one is the webhook a CMS would call after a publish.

lib/cms.ts
// A fake headless CMS. The price lives on globalThis so the page and the
// Route Handlers (separate bundles) share one value inside the one server process.
const store = globalThis as unknown as { price?: string };

export async function getPrice() {
  await new Promise((resolve) => setTimeout(resolve, 200)); // network latency
  const price = store.price ?? "3.50";
  if (price === "fail") throw new Error("CMS is down");
  return price;
}

export function setPrice(value: string) {
  store.price = value;
}
app/menu/page.tsx
import { cacheLife, cacheTag } from "next/cache";
import { getPrice } from "@/lib/cms";

export default async function Menu() {
  "use cache";
  cacheLife({ stale: 300, revalidate: 10, expire: 3600 });
  cacheTag("menu");
  const price = await getPrice();
  return (
    <p>
      Flat white £{price}, rendered {new Date().toISOString()}
    </p>
  );
}
app/api/price/route.ts
import { setPrice } from "@/lib/cms";

// The CMS changes its data. Nothing in Next.js is told about it.
export async function POST(request: Request) {
  const value = new URL(request.url).searchParams.get("value") ?? "3.50";
  setPrice(value);
  return Response.json({ price: value });
}
app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";

// What a CMS webhook would call after a publish.
export async function POST() {
  revalidateTag("menu", "max");
  return Response.json({ revalidated: "menu" });
}
next build
Route (app)          Revalidate  Expire
┌ ○ /_not-found
├ ƒ /api/price
├ ƒ /api/revalidate
└ ○ /menu                   10s      1h


○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand
What’s happening
  1. The whole page is a 'use cache' component, so the build renders it once (200 ms of "CMS latency") and stores it: ○ /menu. The rendered timestamp is inside the cached render, so it shows when the stored copy was made.
  2. cacheLife({ stale: 300, revalidate: 10, expire: 3600 }) sets the schedule: after 10 seconds the copy is stale and the next request triggers a background regeneration; after an hour with no regeneration it has expired and a request has to wait for a fresh render. The table prints 10s and 1h.
  3. stale: 300 is for the client router (how long a browser may reuse what it prefetched). It's set to 5 minutes on purpose: per the cacheLife docs, a stale under 30 seconds or an expire under 5 minutes would keep the content out of the prerender altogether and the page would become a streamed hole.
  4. cacheTag("menu") names the entry so the webhook can invalidate it with revalidateTag("menu", "max"). "max" means "serve stale while revalidating" (the recommended profile); { expire: 0 } would make the next request wait instead.
  5. The two ƒ rows are the Route Handlers: POST handlers always run per request.
AdvancedWatching it regenerate

Here is one session against next start, using a small helper: g is curl on /menu printing the clock, the x-nextjs-cache header and the visible text; p is a curl -X POST. The build finished at 18:08:12.

ISR timeline, Cache Components app (times are UTC, from date -u)
18:08:14  GET /menu   HIT          Flat white £3.50, rendered 2026-10-25T18:08:12.267Z
18:08:14  POST /api/price?value=3.80        {"price":"3.80"}
18:08:14  GET /menu   HIT          Flat white £3.50, rendered 2026-10-25T18:08:12.267Z
18:08:25  GET /menu   STALE        Flat white £3.50, rendered 2026-10-25T18:08:12.267Z
18:08:26  GET /menu   HIT          Flat white £3.80, rendered 2026-10-25T18:08:26.172Z
18:08:27  GET /menu   HIT          Flat white £3.80, rendered 2026-10-25T18:08:26.172Z
18:08:27  POST /api/price?value=4.10        {"price":"4.10"}
18:08:27  POST /api/revalidate              {"revalidated":"menu"}
18:08:27  GET /menu   STALE        Flat white £3.80, rendered 2026-10-25T18:08:26.172Z
18:08:28  GET /menu   HIT          Flat white £4.10, rendered 2026-10-25T18:08:27.265Z
What’s happening
  1. At 18:08:14 the stored copy is 2 seconds old: HIT, £3.50. The CMS then changes the price to £3.80, and the next request is still £3.50: nothing told Next.js, and the copy isn't stale yet.
  2. At 18:08:25 the copy is 13 seconds old, past its 10-second revalidate. That visitor gets STALE: the old £3.50 page, instantly, and their request starts a regeneration in the background.
  3. One second later the regenerated copy is in place: HIT, £3.80, with a new rendered time of 18:08:26.172. So the visitor at :25 paid nothing for the refresh, and only one regeneration ran.
  4. At 18:08:27 the CMS publishes £4.10 and calls the webhook. revalidateTag("menu", "max") only marks the entry stale; it doesn't render anything. The next request gets STALE (£3.80) and triggers the render; the one after that gets £4.10.
  5. That's the shape of all ISR: freshness is bounded by the revalidate period (or by how soon your webhook fires) plus one request. If you need the data correct on the very next request (after a user's own edit), use updateTag in a Server Action or { expire: 0 }, covered in Revalidating and redirecting after a mutation.
Requests HIT £3.50 CMS → 3.80 STALE £3.50 HIT £3.80 tag → STALE £3.80 HIT £4.10 +10 s: stale Stored copy £3.50, fresh (built 18:08:12) £3.50, stale £3.80 £4.10 :12 :14 :22 :25 :27
The observed run: a stale copy is always served once more, and that request pays for the refresh in the background.
AdvancedThe headers a CDN sees

A cached page also tells any CDN in front how long to keep it. For this route Cache-Control was s-maxage=10, stale-while-revalidate=3590: shared caches may serve it as fresh for 10 seconds (the revalidate) and then serve it stale while they refetch, for up to 3,590 more seconds (expire minus revalidate, so an hour in total). x-nextjs-cache is Next.js's own view of its cache: HIT (served from the cache), STALE (served from the cache, regenerating in the background), MISS (not in the cache, rendered now) and REVALIDATED (regenerated by on-demand revalidation), per the ISR guide. In this run revalidateTag showed up as STALE followed by HIT; the previous model's revalidatePath below shows up as a MISS.

AdvancedWhen regeneration fails

If the data source throws during a background regeneration, Next.js keeps serving the last good copy. The same session continued: the CMS starts failing at 18:08:28, and is fixed at 18:08:41.

ISR timeline, continued
18:08:28  POST /api/price?value=fail        {"price":"fail"}
18:08:39  GET /menu   STALE        Flat white £4.10, rendered 2026-10-25T18:08:27.265Z
18:08:40  GET /menu   HIT          Flat white £4.10, rendered 2026-10-25T18:08:27.265Z
18:08:41  GET /menu   HIT          Flat white £4.10, rendered 2026-10-25T18:08:27.265Z
18:08:41  POST /api/price?value=4.20        {"price":"4.20"}
18:08:41  GET /menu   HIT          Flat white £4.10, rendered 2026-10-25T18:08:27.265Z
18:08:42  GET /menu   HIT          Flat white £4.10, rendered 2026-10-25T18:08:27.265Z
18:08:51  GET /menu   STALE        Flat white £4.10, rendered 2026-10-25T18:08:27.265Z
18:08:56  GET /menu   HIT          Flat white £4.20, rendered 2026-10-25T18:08:51.270Z
next start log (excerpt)
⨯ Error: CMS is down
    at h (.next/server/chunks/ssr/[root-of-the-server]__14ddtaqhwd47k._.js:1:215)
    at async i (.next/server/chunks/ssr/[root-of-the-server]__14ddtaqhwd47k._.js:1:352) {
  digest: '1663901986'
}
What’s happening
  1. At 18:08:39 the copy is stale, so the request gets £4.10 (STALE) and starts a regeneration. getPrice throws "CMS is down", the server logs the error, and the stored copy is left alone. Visitors never see an error page.
  2. The requests at :40, :41 and :42 are plain HITs, and none of them retried, even after the CMS was fixed at :41. In this run the failed attempt counted as a regeneration for scheduling, so the next try only came with the next stale request, at :51.
  3. That one regenerated successfully (£4.20, rendered 18:08:51.270), and the visitor at :56 got it. The ISR guide words it as "on the next subsequent request, Next.js will retry"; what was observed here is that "next" means the next request after another revalidate period.
  4. Practically: a CMS outage freezes your pages at their last good version instead of breaking them, and recovery takes up to one revalidate period plus one visit. Return the error out of the cached function only if you want an error state cached; throwing is what keeps the old copy.
AdvancedThe previous model: export const revalidate

Without Cache Components, ISR is a route segment option, and it is the same stale-while-revalidate machinery:

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

const nextConfig: NextConfig = {
  cacheComponents: false,
};

export default nextConfig;
lib/cms.ts
const store = globalThis as unknown as { price?: string };

export async function getPrice() {
  await new Promise((resolve) => setTimeout(resolve, 200));
  const price = store.price ?? "3.50";
  if (price === "fail") throw new Error("CMS is down");
  return price;
}

export function setPrice(value: string) {
  store.price = value;
}
app/menu/page.tsx
import { getPrice } from "@/lib/cms";

export const revalidate = 10; // seconds: the whole route is regenerated at most every 10 s

export default async function Menu() {
  const price = await getPrice();
  return (
    <p>
      Flat white £{price}, rendered {new Date().toISOString()}
    </p>
  );
}
app/api/price/route.ts
import { setPrice } from "@/lib/cms";

export async function POST(request: Request) {
  const value = new URL(request.url).searchParams.get("value") ?? "3.50";
  setPrice(value);
  return Response.json({ price: value });
}
app/api/revalidate/route.ts
import { revalidatePath } from "next/cache";

export async function POST() {
  revalidatePath("/menu");
  return Response.json({ revalidated: "/menu" });
}
next build
Route (app)          Revalidate  Expire
┌ ○ /_not-found
├ ƒ /api/price
├ ƒ /api/revalidate
└ ○ /menu                   10s      1y


○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand
ISR timeline, previous model (build finished at 18:09:21)
18:09:24  GET /menu   HIT          Flat white £3.50, rendered 2026-10-25T18:09:21.669Z
18:09:24  POST /api/price?value=3.80        {"price":"3.80"}
18:09:24  GET /menu   HIT          Flat white £3.50, rendered 2026-10-25T18:09:21.669Z
18:09:35  GET /menu   STALE        Flat white £3.50, rendered 2026-10-25T18:09:21.669Z
18:09:36  GET /menu   HIT          Flat white £3.80, rendered 2026-10-25T18:09:35.541Z
18:09:36  POST /api/price?value=4.10        {"price":"4.10"}
18:09:36  POST /api/revalidate              {"revalidated":"/menu"}
18:09:36  GET /menu   MISS         Flat white £4.10, rendered 2026-10-25T18:09:36.616Z
18:09:37  GET /menu   HIT          Flat white £4.10, rendered 2026-10-25T18:09:36.616Z
18:09:37  POST /api/price?value=fail        {"price":"fail"}
18:09:48  GET /menu   STALE        Flat white £4.10, rendered 2026-10-25T18:09:36.616Z
18:09:49  GET /menu   HIT          Flat white £4.10, rendered 2026-10-25T18:09:36.616Z
18:09:49  POST /api/price?value=4.20        {"price":"4.20"}
18:09:49  GET /menu   HIT          Flat white £4.10, rendered 2026-10-25T18:09:36.616Z
18:10:00  GET /menu   STALE        Flat white £4.10, rendered 2026-10-25T18:09:36.616Z
18:10:01  GET /menu   HIT          Flat white £4.20, rendered 2026-10-25T18:10:01.015Z
What’s happening
  1. export const revalidate = 10 makes the route ○ with 10s in the Revalidate column; Expire shows 1y because nothing set an expiry (the expireTime default is a year), and the first response carried Cache-Control: s-maxage=10, stale-while-revalidate=31535990.
  2. The time-based part is identical: HIT while fresh, one STALE response past 10 seconds, then the new copy.
  3. On-demand is different. revalidatePath("/menu") in this model makes the next request a MISS: that visitor waits for a fresh render (here it finished within the same second) instead of getting the stale copy. With Cache Components, revalidateTag(tag, "max") gave STALE first.
  4. A failed regeneration behaves the same: the last good copy (£4.10) stays, the next attempt waits for the next stale request (18:10:00), and that one picks up £4.20.
  5. You'll also meet ISR configured per fetch in this model: fetch(url, { next: { revalidate: 60, tags: ["menu"] } }). The route then regenerates at the lowest revalidate of any fetch or segment on it.
AdvancedThe ISR cache lives on one server
AdvancedShort lifetimes stop being static

#Streaming SSR in practice

Added

Classic server rendering builds the whole HTML document and then sends it, so the slowest query decides when the visitor sees anything. Streaming sends the response in pieces as they become ready, over one HTTP response (chunked transfer encoding): first the parts that don't wait for anything (layouts, headings, <Suspense> fallbacks), then each suspended part as soon as its data arrives. Two numbers that used to be the same come apart: time to first byte (now the time to render the shell) and time to complete (still the slowest part).

React streams out of order. Each <Suspense> boundary is an independent slot: the shell contains a placeholder (<template id="B:0">) and the fallback; when that boundary's content is ready, React appends it at the end of the stream inside <div hidden id="S:0"> together with a tiny inline script that moves it into place. So a boundary further down the page can arrive before one above it, and each one paints without waiting for the JavaScript bundle or hydration. Think of a restaurant: the table is laid and the bread arrives at once (the shell), and each dish comes out when the kitchen finishes it, not in menu order.

The catch is the HTTP contract: the status code and headers go out with the first chunk. Anything that goes wrong after that (an error, a notFound(), a redirect()) has to be handled inside the HTML, because the response is already 200 OK. How loading.js creates a boundary per segment and where to place boundaries for data are covered in Streaming with Suspense; this topic measures what actually goes over the wire.

The dashboard below has three widgets: a slow one first in the page (2 s), a fast one (0.5 s) and one whose API fails after 1 s. slow() calls connection() first so the work happens per request, not at build time.

lib/slow.ts
import { connection } from "next/server";

// Request-time work that takes `ms` milliseconds (connection() keeps it out of the prerender).
export async function slow<T>(ms: number, value: T): Promise<T> {
  await connection();
  await new Promise((resolve) => setTimeout(resolve, ms));
  return value;
}
app/dashboard/page.tsx
import { Suspense } from "react";
import { slow } from "@/lib/slow";
import WidgetError from "./widget-error";

async function Recommendations() {
  const items = await slow(2000, ["Oat milk", "Decaf beans"]);
  return <p>Recommended: {items.join(", ")}</p>;
}

async function Revenue() {
  const total = await slow(500, 1234);
  return <p>Revenue today: £{total}</p>;
}

async function Weather() {
  const ok = await slow(1000, false); // the weather API fails after 1 s
  if (!ok) throw new Error("weather API timed out");
  return <p>Sunny</p>;
}

export default function Dashboard() {
  return (
    <main>
      <h1>Dashboard</h1>
      <Suspense fallback={<p>Loading recommendations…</p>}>
        <Recommendations />
      </Suspense>
      <Suspense fallback={<p>Loading revenue…</p>}>
        <Revenue />
      </Suspense>
      <WidgetError title="Weather unavailable">
        <Suspense fallback={<p>Loading weather…</p>}>
          <Weather />
        </Suspense>
      </WidgetError>
    </main>
  );
}
app/dashboard/widget-error.tsx
"use client";

import { catchError, type ErrorInfo } from "next/error";

function WidgetFallback(props: { title: string }, { error }: ErrorInfo) {
  const digest = (error as { digest?: string }).digest;
  return (
    <p role="alert">
      {props.title} (digest {digest})
    </p>
  );
}

export default catchError(WidgetFallback);
next build
Route (app)
┌ ○ /_not-found
├ ◐ /article
└ ◐ /dashboard


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
What’s happening
  1. The page itself awaits nothing. <h1>Dashboard</h1> and the three fallbacks are the static shell, prerendered at build: ◐ /dashboard. (/article is the next example.)
  2. Each widget suspends on its own slow() call inside its own <Suspense>, so the three timers run in parallel from the moment the request arrives; none waits for another.
  3. Weather throws after 1 s. WidgetError is made with catchError from next/error (a component-level error boundary for Client Components, stable since Next.js 16.3) and catches it, so only that widget is replaced. Without it, the nearest error.tsx would catch the error and replace its whole segment.
  4. In the error fallback, error.digest is all you get in production: the message stays on the server, which logs the error with the same digest so you can find it.
AdvancedMeasuring the stream

This is the measuring tool used throughout the chapter. It reads the response as a stream and prints when each chunk arrived (Node's fetch, with compression turned off so chunks aren't buffered):

observe.mjs
// node observe.mjs <url> [regex] [Header=value ...]
// Prints the status, the caching headers, and every chunk of the body as it arrives,
// with the parts of its HTML (scripts removed) that match the regex.
const [url, pat, ...hdrs] = process.argv.slice(2);
const headers = { "Accept-Encoding": "identity" };
for (const h of hdrs) { const i = h.indexOf("="); headers[h.slice(0, i)] = h.slice(i + 1); }
const t0 = performance.now();
const res = await fetch(url, { headers, redirect: "manual" });
const ms = () => Math.round(performance.now() - t0);
console.log(`${res.status} after ${ms()} ms`);
for (const k of ["cache-control", "x-nextjs-cache", "x-nextjs-prerender", "x-nextjs-postponed", "location"]) if (res.headers.has(k)) console.log(`${k}: ${res.headers.get(k)}`);
const reader = res.body.getReader(); const dec = new TextDecoder(); let i = 0;
while (true) {
  const { done, value } = await reader.read(); if (done) break;
  const text = dec.decode(value, { stream: true });
  const html = text.replace(/<script[\s\S]*?<\/script>/g, "");
  const hits = pat ? [...html.matchAll(new RegExp(pat, "g"))].map((m) => m[0]) : [];
  console.log(`chunk ${i++} +${ms()} ms ${value.length} B${hits.length ? "  " + hits.join(" | ") : ""}`);
}
console.log(`done +${ms()} ms`);
node observe.mjs http://localhost:4506/dashboard (chunks without visible HTML left out)
200 after 120 ms
cache-control: private, no-cache, no-store, max-age=0, must-revalidate
x-nextjs-prerender: 1
x-nextjs-postponed: 1
chunk 0 +133 ms 1768 B  <h1>Dashboard</h1> | template id="B:0" | <p>Loading recommendations…</p> | template id="B:1" | <p>Loading revenue…</p> | template id="B:2" | <p>Loading weather…</p>
chunk 12 +647 ms 925 B  hidden id="S:1" | <p>Revenue today: £<!-- -->1234</p>
chunk 15 +2148 ms 109 B  hidden id="S:0" | <p>Recommended: <!-- -->Oat milk, Decaf beans</p>
done +2148 ms
What’s happening
  1. The status 200 and the headers arrive at 120 ms (the first request after next start), and chunk 0 at 133 ms carries the whole shell: the heading, three <template id="B:n"> placeholders and the three fallbacks. That is the time to first byte, and it doesn't depend on any widget.
  2. Revenue (0.5 s) arrives at 647 ms as <div hidden id="S:1">: it fills B:1, the second slot. Recommendations, which is first in the page, comes last at 2148 ms into S:0/B:0. Order in the stream follows readiness, not position.
  3. The weather widget shows no visible HTML at all, because its failure travels as a script (below). The response ends at 2148 ms: time to complete is the slowest widget, and nothing else waited for it.
  4. Chunks 1–11, 13 and 14 (left out) are scripts: the RSC payload (self.__next_f.push(…)) that React uses to hydrate, sent alongside the HTML.

curl can show the same thing without a script. -N turns off curl's output buffering, and --trace-time --trace-ascii logs every piece of data as it's received (these times are the laptop's local clock):

curl -sN -H 'Accept-Encoding: identity' --trace-ascii trace.txt --trace-time -o /dev/null http://localhost:4506/dashboard; grep '<= Recv data' trace.txt
14:35:23.010166 <= Recv data, 1775 bytes
14:35:23.019106 <= Recv data, 66 bytes
14:35:23.019378 <= Recv data, 2199 bytes
14:35:23.019533 <= Recv data, 2964 bytes
14:35:23.019787 <= Recv data, 214 bytes
14:35:23.019899 <= Recv data, 420 bytes
14:35:23.020118 <= Recv data, 78 bytes
14:35:23.020293 <= Recv data, 67 bytes
14:35:23.020479 <= Recv data, 4837 bytes
14:35:23.021272 <= Recv data, 370 bytes
14:35:23.514434 <= Recv data, 115 bytes
14:35:23.515249 <= Recv data, 1011 bytes
14:35:24.017047 <= Recv data, 247 bytes
14:35:24.017100 <= Recv data, 81 bytes
14:35:25.016834 <= Recv data, 115 bytes
14:35:25.016964 <= Recv data, 132 bytes
14:35:25.017148 <= Recv data, 24 bytes

The headers were logged at 14:35:23.0097, then the data groups land at +0 s (shell and payload), +0.5 s (revenue), +1.0 s (the weather error) and +2.0 s (recommendations, then the end of the stream). In a browser that becomes four visibly different screens. Headless Chrome, reading the text of each widget every 50 ms after starting the navigation:

headless Chrome, /dashboard: what the page showed and when
+142 ms   Dashboard | Loading recommendations… | Loading revenue… | Loading weather…
+522 ms   Dashboard | Loading recommendations… | Revenue today: £1234 | Loading weather…
+1052 ms  Dashboard | Loading recommendations… | Revenue today: £1234 | Weather unavailable (digest 2540165211)
+2308 ms  Dashboard | Recommended: Oat milk, Decaf beans | Revenue today: £1234 | Weather unavailable (digest 2540165211)
one response: 200 OK and headers are fixed at 0 ms shell h1 + 3 fallbacks S:1 revenue fills slot 2 $RX B:2 weather error S:0 recommended fills slot 1, last 0 0.5 s 1 s 2 s first byte complete
The measured dashboard response: the shell at once, then each boundary when it's ready, in any order.
AdvancedErrors after the first byte

The weather failure is in the stream as two scripts at +1056 ms (raw chunk text, from another request to the same server):

raw chunks at +1030 ms (scripts kept)
<script>$RX=function(b,c,d,e,f){var a=document.getElementById(b);a&&(b=a.previousSibling,b.data="$!",a=a.dataset,null!=c&&(a.dgst=c),d&&(a.msg=d),e&&(a.stck=e),f&&(a.cstck=f),b._reactRetry&&b._reactRetry())};;$RX("B:2","2540165211")</script>
<script>self.__next_f.push([1,"e:E{\"digest\":\"2540165211\"}\n"])</script>
What’s happening
  1. $RX("B:2", "2540165211") marks boundary B:2 as failed on the server and attaches the digest. React will render that boundary on the client instead, where it re-throws the error.
  2. The second script is the RSC payload's version of the same thing: an error row with only a digest, no message and no stack. Server error details never reach the browser in production.
  3. On the client the error reaches the nearest error boundary, here WidgetError, which renders "Weather unavailable (digest 2540165211)". The server log has ⨯ Error: weather API timed out … digest: '2540165211'. The digest is the same on every request for the same error; it changes between builds.
  4. The status is still 200. The same applies to notFound() after streaming started (Next.js adds <meta name="robots" content="noindex">, as /products/999 showed in generateStaticParams and dynamicParams) and to redirect() (it becomes a client-side redirect, measured in Choosing a strategy per route). To get a real status, decide before anything streams: in proxy.ts, in next.config redirects, or in a route that waits for its full render.
AdvancedBots get a different response

Search engines need <title> and meta tags in the <head>, and some crawlers don't run JavaScript. Next.js recognises these "HTML-limited bots" by user agent (configurable with htmlLimitedBots) and for them waits for generateMetadata before sending the page. This route's title takes 1.5 s to look up:

app/article/page.tsx
import type { Metadata } from "next";
import { Suspense } from "react";
import { slow } from "@/lib/slow";

export async function generateMetadata(): Promise<Metadata> {
  const title = await slow(1500, "Brewing guide"); // a slow CMS lookup for the title
  return { title };
}

async function Body() {
  const text = await slow(300, "Grind fine, brew for 25 seconds.");
  return <p>{text}</p>;
}

export default function Article() {
  return (
    <main>
      <h1>Article</h1>
      <Suspense fallback={<p>Loading article…</p>}>
        <Body />
      </Suspense>
    </main>
  );
}
node observe.mjs http://localhost:4506/article, as Chrome and as Twitterbot
User-Agent: Mozilla/5.0 Chrome/155
200 after 46 ms
x-nextjs-prerender: 1
x-nextjs-postponed: 1
chunk 0 +49 ms 1411 B  <h1>Article</h1> | <p>Loading article…</p>
chunk 10 +359 ms 928 B  hidden id="S:1" | <p>Grind fine, brew for 25 seconds.</p>
chunk 11 +1558 ms 458 B  <title>Brewing guide</title> | hidden id="S:2" | hidden id="S:0"
done +1559 ms

User-Agent: Twitterbot/1.0
200 after 1548 ms
chunk 0 +1551 ms 1432 B  <title>Brewing guide</title> | <h1>Article</h1> | <p>Grind fine, brew for 25 seconds.</p>
done +1552 ms
What’s happening
  1. For a browser, the prerendered shell goes out at 49 ms, the body text at 359 ms, and the <title> only at 1558 ms, streamed into the body ("streaming metadata"). It stays there: in headless Chrome document.title read "Brewing guide" while the <title> element sat in <body>, not <head>. Browsers accept that; people got the content 1.2 s before the title existed.
  2. For Twitterbot/1.0 there is no x-nextjs-prerender header: the bot skipped the static shell, and the server rendered the page fresh and held everything until the metadata was ready. One chunk at 1551 ms, with <title> in <head> and the content already filled in.
  3. On the dashboard (which has static metadata) the same user agent still received a streamed response, shell first: the wait is for metadata, not for every boundary.
  4. So slow metadata costs crawlers time to first byte. Keep generateMetadata fast (cache its data with 'use cache'), and remember that a shell built from build-only data must also render at request time, because bots re-render it.
AdvancedWhat can break streaming

The server streams; anything between it and the user can undo that by buffering. Nginx buffers proxied responses unless you send X-Accel-Buffering: no; some CDNs and serverless platforms buffer whole responses (AWS Lambda needs response streaming switched on); gzip and Brotli can hold small chunks; Safari waits for the first 1,024 bytes before painting. The streaming guide in the bundled docs lists these. The check is the one above: compare the first-chunk time with the total, through the real path (CDN and all), with Accept-Encoding: identity or a browser's network panel (a short time to first byte and a long content download means it streams).

AdvancedSequential awaits inside one boundary don't stream
AdvancedPut the LCP element in the shell

#Edge and Node.js runtimes

Added

Server code in Next.js runs in a runtime: the environment that provides its globals and APIs. The Node.js runtime is a normal Node process with everything Node has (fs, net, native modules, every npm package). The Edge runtime is a much smaller environment built on V8 isolates with only web-standard APIs (fetch, Request/Response, Web Streams, Web Crypto, URL, TextEncoder, timers) and no file system or Node built-ins. It was designed for platforms that run code in many locations close to users, start in milliseconds and limit what code can do.

A useful picture: Node is the full kitchen at the restaurant, slower to open but able to cook anything; Edge is a coffee cart at the station: right where people are, open instantly, but with a short menu, and anything not on the menu (a database driver that needs TCP sockets, sharp, fs) simply isn't available. The question used to be which routes deserve the cart. In Next.js 16 the answer has mostly become "none of them":

  • Node.js is the default for pages, layouts and Route Handlers, and the bundled docs now mark export const runtime = 'edge' as deprecated.
  • Cache Components need Node.js. With cacheComponents: true the runtime export is rejected at build time, even runtime = 'nodejs'.
  • proxy.ts (formerly middleware.ts) runs on Node.js and its runtime can't be configured. The old middleware.ts still builds in 16 (with a deprecation warning) and still runs on Edge.
  • ISR, revalidate and the Node APIs are not available on Edge.

Here are both runtimes side by side in one app, with Cache Components off (that's the only way the runtime export still builds), plus a proxy.ts that reports which Node version ran it.

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

// The runtime export only exists without Cache Components.
const nextConfig: NextConfig = {
  cacheComponents: false,
};

export default nextConfig;
lib/where.ts
// Reports what the current server runtime offers.
export function where() {
  return {
    node: typeof process !== "undefined" ? (process.versions?.node ?? null) : null,
    edgeRuntime: typeof (globalThis as { EdgeRuntime?: string }).EdgeRuntime === "string",
    hasFetch: typeof fetch === "function",
    hasCrypto: typeof crypto?.subtle === "object",
  };
}
app/api/node/route.ts
import { readFile } from "node:fs/promises";
import { where } from "@/lib/where";

export async function GET() {
  const pkg = JSON.parse(await readFile("package.json", "utf8")); // Node API: works
  return Response.json({ runtime: "nodejs", ...where(), app: pkg.name });
}
app/api/edge/route.ts
import { where } from "@/lib/where";

export const runtime = "edge"; // deprecated in Next.js 16

export async function GET() {
  return Response.json({ runtime: "edge", ...where() });
}
proxy.ts
import { NextResponse } from "next/server";

export function proxy() {
  const response = NextResponse.next();
  response.headers.set("x-proxy-node", process.versions.node);
  return response;
}

export const config = { matcher: "/api/:path*" };
next build
Route (app)
┌ ○ /_not-found
├ ƒ /api/edge
└ ƒ /api/node


ƒ Proxy (Middleware)

○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand
AdvancedWhat the build and the two handlers report
next build warnings (same build)
⚠ The Edge Runtime is deprecated. You can use the "nodejs" runtime instead. Learn more: https://nextjs.org/docs/messages/edge-runtime-deprecated
⚠ Using edge runtime on a page currently disables static generation for that page
Turbopack build encountered 1 warning:
./.sandbox/nextjs/ch06/runtimes/src/lib/where.ts:4:45
Warning: A Node.js API is used (process.versions at line: 4) which is not supported in the Edge Runtime.
curl -s -D - http://localhost:4506/api/node; curl -s -D - http://localhost:4506/api/edge (trimmed)
x-proxy-node: 26.7.0
{"runtime":"nodejs","node":"26.7.0","edgeRuntime":false,"hasFetch":true,"hasCrypto":true,"app":"template"}

x-proxy-node: 26.7.0
{"runtime":"edge","node":null,"edgeRuntime":true,"hasFetch":true,"hasCrypto":true}
What’s happening
  1. The build prints both handlers as ƒ and adds a ƒ Proxy (Middleware) row for proxy.ts. The runtime = "edge" export triggers two warnings: the runtime is deprecated, and Edge routes can't be statically generated.
  2. Turbopack also warns that lib/where.ts uses process.versions, which Edge doesn't support, because the file is imported by the Edge handler. The import traces in the full log list both handlers: shared code is checked against the strictest runtime that imports it.
  3. /api/node reads package.json with fs and reports Node 26.7.0: everything Node offers is there.
  4. /api/edge sees no process.versions.node (null), but the EdgeRuntime global exists, and fetch and Web Crypto work. The Edge runtime's API reference has no file system at all, so the readFile in the Node handler has no Edge equivalent.
  5. Both responses carry x-proxy-node: 26.7.0: proxy.ts ran in Node.js for both, regardless of the runtime of the route behind it.
AdvancedWhat the build says in Next.js 16

With Cache Components on (the template's config), any runtime export stops the build:

next build: export const runtime = "edge" with cacheComponents: true
Error: Route segment config "runtime" is not compatible with `nextConfig.cacheComponents`. Please remove it.
> 1 | export const runtime = "edge";
    |              ^^^^^^^

And proxy.ts refuses an Edge config:

next build: proxy.ts with export const config = { runtime: "edge" }
Error: Next.js can't recognize the exported `config` field in route. Proxy does not support Edge runtime.
> 3 | export const config = { runtime: "edge" };
    |              ^^^^^^

An old middleware.ts (16.4.3, Cache Components off) still builds, warns, and runs on Edge: a header set to typeof EdgeRuntime came back as string.

next build with src/middleware.ts
⚠ The "middleware" file convention is deprecated. Please use "proxy" instead.
  npx @next/codemod@canary middleware-to-proxy .
  Learn more: https://nextjs.org/docs/messages/middleware-to-proxy
What’s happening
  1. Cache Components assume the Node.js server: prerendering, the static shell, 'use cache' and the cache handlers all live there. So the option is removed, not just discouraged.
  2. proxy.ts is fixed to Node, so it can use the same libraries as the rest of your server code (a session library, a database client for a quick lookup). Where it physically runs is now up to how your platform runs Node.js functions, not a separate edge network.
  3. If an existing app depends on Edge middleware, the v16 upgrade guide says to keep middleware.ts for now; renaming it to proxy.ts changes the runtime.
AdvancedHow it got here

Next.js 12 introduced middleware on the Edge runtime only. Next.js 13 let App Router routes opt in with export const runtime = 'edge' (also spelled 'experimental-edge', a value 15 deprecated). Middleware gained the Node.js runtime experimentally in 15.2 and stably in 15.5. Next.js 16 renamed middleware to proxy with Node.js as its fixed runtime, and in the 16 line the bundled docs and the build mark the Edge runtime for routes as deprecated. The reasons interviews tend to accept: most of the latency an Edge function saves is lost again on the round trip to a database that lives in one region, the API restrictions break libraries, and a static shell served from a CDN (Partial Prerendering) gives the "instant first byte from near the user" benefit without moving your code.

AdvancedShared modules inherit the strictest runtime

#Static exports and single-page apps

Added

Everything so far assumed a Next.js server (next start, a container, a platform) running next to your files. output: 'export' removes the server. next build renders every route once, writes plain files to an out/ folder (one HTML file per page, the RSC payloads as .txt files, and the JavaScript), and you upload that folder to any static host: S3, GitHub Pages, Nginx, a CDN bucket. After that, the only code that runs is in the browser.

The mental model is printing a book instead of running a shop. Everything a reader will ever see must be decided before printing: no per-visitor pages, no "make it when someone asks", no updates without printing again. In exchange there's nothing to operate and nothing to scale. Navigation between pages still feels like an SPA, because the Next.js router runs in the browser and fetches the next page's payload instead of reloading.

So the rule is simple: anything that needs a server at request time can't be in an export. That covers request APIs (cookies(), headers()), dynamic routes without generateStaticParams, Server Actions, ISR and revalidation, proxy.ts, next.config redirects, rewrites and headers, image optimisation with the default loader, Draft Mode, and Route Handlers that read the request. One more limit applies in Next.js 16: an export can't use Cache Components. The static exports guide's list of unsupported features doesn't mention it; the docs only hint at it, in the "Platform Support" tables of 'use cache' and the streaming guide, which mark static export as unsupported. The build is explicit: with cacheComponents: true it renders every page and then fails with Error: Invariant: PPR cannot be enabled in export mode (16.4.3, with partialPrefetching true and false alike; the check in Next.js's export/index.js looks at nextConfig.cacheComponents, not at partialPrefetching). Export apps therefore use the previous model.

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

const nextConfig: NextConfig = {
  output: "export", // next build writes plain files to out/
  cacheComponents: false, // in 16.4.3 a static export can't use Cache Components (see below)
};

export default nextConfig;
app/page.tsx
import Link from "next/link";

export default function Home() {
  return (
    <main>
      <h1>Brew notes</h1>
      <Link href="/blog/espresso">Espresso</Link> · <Link href="/blog/pour-over">Pour-over</Link> ·{" "}
      <Link href="/timer">Timer</Link>
    </main>
  );
}
app/blog/[slug]/page.tsx
const posts: Record<string, string> = {
  espresso: "18 g in, 36 g out, about 28 seconds.",
  "pour-over": "15 g of coffee to 250 g of water.",
};

export function generateStaticParams() {
  return Object.keys(posts).map((slug) => ({ slug }));
}

export default async function Post({ params }: PageProps<"/blog/[slug]">) {
  const { slug } = await params;
  return (
    <article>
      <h1>{slug}</h1>
      <p>{posts[slug]}</p>
    </article>
  );
}
app/posts.json/route.ts
export const dynamic = "force-static"; // required for a Route Handler in a static export

export async function GET() {
  return Response.json({ posts: ["espresso", "pour-over"] });
}
app/timer/page.tsx
import Timer from "./timer";

export default function TimerPage() {
  return (
    <main>
      <h1>Brew timer</h1>
      <Timer />
    </main>
  );
}
app/timer/timer.tsx
"use client";

import { useEffect, useState } from "react";

// Browser-only state: this is the "SPA" part of a static site.
export default function Timer() {
  const [last, setLast] = useState<string | null>(null);
  useEffect(() => {
    setLast(localStorage.getItem("last-brew") ?? "never");
    localStorage.setItem("last-brew", new Date().toLocaleTimeString("en-GB"));
  }, []);
  return <p>Last brew: {last ?? "…"}</p>;
}
next build
Route (app)
┌ ○ /
├ ○ /_not-found
├   /blog/[slug]
│ ├ ● /blog/espresso
│ └ ● /blog/pour-over
├ ○ /posts.json
└ ○ /timer


○  (Static)  prerendered as static content
●  (SSG)     prerendered as static HTML (uses generateStaticParams)
AdvancedWhat the build wrote
cd out && find . -type f | grep -v _next/static | sort (the template's SVGs and favicon left out)
./404.html
./__next.__PAGE__.txt
./__next._full.txt
./__next._tree.txt
./_not-found.html
./_not-found.txt
./_not-found/__next._full.txt
./_not-found/__next._not-found.__PAGE__.txt
./_not-found/__next._tree.txt
./blog/espresso.html
./blog/espresso.txt
./blog/espresso/__next._full.txt
./blog/espresso/__next._tree.txt
./blog/espresso/__next.blog.$d$slug.__PAGE__.txt
./blog/pour-over.html
./blog/pour-over.txt
./blog/pour-over/__next._full.txt
./blog/pour-over/__next._tree.txt
./blog/pour-over/__next.blog.$d$slug.__PAGE__.txt
./index.html
./index.txt
./posts.json
./timer.html
./timer.txt
./timer/__next._full.txt
./timer/__next._tree.txt
./timer/__next.timer.__PAGE__.txt
What’s happening
  1. output: "export" makes next build write the site to out/ (1.1 MB here, 25 files under _next/static). The route table looks like any static build: ○ for plain pages, ● for the two blog posts from generateStaticParams.
  2. Each page becomes <route>.html for first visits plus a set of .txt files: the RSC payload, split per segment (__next._tree.txt, __next._full.txt, …__PAGE__.txt) so the client router can prefetch and navigate with plain file requests.
  3. The Route Handler became a file, posts.json, because GET ran once at build. Without dynamic = "force-static" the build stops with export const dynamic = "force-static"/export const revalidate not configured on route "/posts.json" with "output: export". (That option is one of the route segment configs Cache Components reject, which is another reason an export app runs the previous model.)
  4. Timer is a Client Component. It is prerendered too (as "Last brew: …"), and its useEffect reads localStorage in the browser. Browser-only APIs must stay inside effects or event handlers, because the prerender runs in Node.
AdvancedServing it from a plain file server

To check it really needs no Next.js server, the out/ folder was served with Python's plain file server (python3 -m http.server 4506 -d out), then opened in headless Chrome:

curl against python3 -m http.server -d out
/                      200 text/html <h1>Brew notes</h1>
/blog/espresso.html    200 text/html <h1>espresso</h1> <p>18 g in, 36 g out, about 28 seconds.</p>
/blog/espresso         301 → /blog/espresso/  (a directory listing)
/blog/latte.html       404 text/html;charset=utf-8 <h1>Error response</h1> <p>Error code: 404</p>
/posts.json            200 application/json {"posts":["espresso","pour-over"]}
headless Chrome: load /, click "Espresso"; performance entries (static JS left out)
{
 "url": "/blog/espresso/",
 "nav": [
  "/"
 ],
 "resources": [
  "/favicon.ico",
  "/blog/espresso",
  "/timer",
  "/blog/pour-over",
  "/timer/__next._tree.txt",
  "/blog/pour-over/__next._tree.txt",
  "/blog/espresso/__next._tree.txt",
  "/timer/__next.timer.__PAGE__.txt",
  "/blog/pour-over/__next.blog.$d$slug.__PAGE__.txt",
  "/blog/espresso/__next.blog.$d$slug.__PAGE__.txt"
 ]
}
the static server's log for the same run (trimmed)
"HEAD /blog/espresso HTTP/1.1" 301 -
"HEAD /blog/pour-over HTTP/1.1" 301 -
"HEAD /timer HTTP/1.1" 301 -
"HEAD /timer/ HTTP/1.1" 200 -
"HEAD /blog/pour-over/ HTTP/1.1" 200 -
"HEAD /blog/espresso/ HTTP/1.1" 200 -
"GET /timer/__next._tree.txt?_rsc=5CB68i4pnAekjehf HTTP/1.1" 200 -
"GET /blog/pour-over/__next._tree.txt?_rsc=5CB68i4pnAekjehf HTTP/1.1" 200 -
"GET /blog/espresso/__next._tree.txt?_rsc=5CB68i4pnAekjehf HTTP/1.1" 200 -
"GET /timer/__next.timer.__PAGE__.txt?_rsc=lxIRx2dpbXPmBzYs HTTP/1.1" 200 -
"GET /blog/espresso/__next.blog.$d$slug.__PAGE__.txt?_rsc=hjRwlybSvoEojtGc HTTP/1.1" 200 -
"GET /blog/pour-over/__next.blog.$d$slug.__PAGE__.txt?_rsc=hjRwlybSvoEojtGc HTTP/1.1" 200 -

The page showed espresso and its text after the click. On /timer.html (opened twice in a fresh browser profile) the paragraph read "Last brew: never" and then "Last brew: 14:14:28".

What’s happening
  1. The .html files and posts.json are served as they are: no server code involved, the build did all the work.
  2. /blog/latte was never generated, and there is no server to generate it, so it's the host's 404. dynamicParams has nothing to do in an export; every page must exist at build time.
  3. The URL without .html is the classic static-hosting problem: this server answered /blog/espresso with a 301 to the folder of the same name (where the .txt payloads live), which is a directory listing. Real hosts need a rule such as Nginx's try_files $uri $uri.html $uri/ =404; (the docs' example), or trailingSlash: true in next.config, which writes blog/espresso/index.html instead.
  4. The click was a client-side navigation: the only document load was /. While the links were visible the router prefetched them: a HEAD per link (this server answered 301 to the folder, which is why the address bar ended at /blog/espresso/), then each link's .txt segments. The click rendered the post from those files without loading espresso.html. That is the "SPA feel" of an export.
  5. The timer proves the browser-only part works: "never" on the first visit, the stored time on the second.
AdvancedWhat fails in an export, and how

Each of these was built in a scratch copy of the template with output: "export" and Cache Components off, except the cacheComponents: true row, which turns it on to show that the two can't be combined. Some stop the build; some build fine and break later, which is worse:

FeatureWhat happened with next build
cookies() in a pageBuild fails: Route / with `dynamic = "error"` couldn't be rendered statically because it used `cookies()`.
[slug] page without generateStaticParamsBuild fails: Page "/blog/[slug]" is missing "generateStaticParams()" so it cannot be used with "output: export" config.
A Server Action ("use server" form action)Build fails: Server Actions are not supported with static export.
Route Handler without dynamic = "force-static"Build fails (message above)
cacheComponents: trueBuild fails: Invariant: PPR cannot be enabled in export mode
redirects() in next.configBuilds, with ⚠ Specified "redirects" will not automatically work with "output: export".
proxy.tsBuilds, with ⚠ Statically exporting a Next.js application via `next export` disables API routes and middleware.
next/image with a PNG and the default loaderBuilds without a warning; the HTML points at /_next/image?url=%2Fnext.png&w=256&q=75, which returned 404 from the static server (SVGs are left unoptimised, so they worked)
export const revalidate = 60Builds (the table even shows 1m), but the files in out/ are plain files: nothing will ever regenerate them

The image case deserves the warning: configure images.unoptimized: true or a loader pointing at an image CDN (the docs show Cloudinary) before shipping an export with next/image.

AdvancedSPA mode: one HTML file for every URL

Teams moving a client-side React app (Vite, Create React App) to Next.js often start with an export that is a strict SPA: one index.html, and the existing client router decides what to show. The Vite migration guide in the bundled docs does it with an optional catch-all route and a client-only import:

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

const nextConfig: NextConfig = {
  output: "export",
  cacheComponents: false,
};

export default nextConfig;
app/[[...slug]]/page.tsx
import { ClientOnly } from "./client";

// One HTML file, for "/". The host must send every other path to it too.
export function generateStaticParams() {
  return [{ slug: [""] }];
}

export default function Page() {
  return <ClientOnly />;
}
app/[[...slug]]/client.tsx
"use client";

import dynamic from "next/dynamic";

// ssr: false: nothing below this line is rendered at build time.
const App = dynamic(() => import("@/spa/App"), { ssr: false, loading: () => <p>Starting app…</p> });

export function ClientOnly() {
  return <App />;
}
spa/App.tsx
"use client";

import { useState } from "react";

// The existing client-side app (in real life: React Router, TanStack Router…).
export default function App() {
  const [path, setPath] = useState(() => window.location.pathname);
  const go = (to: string) => {
    window.history.pushState(null, "", to);
    setPath(to);
  };
  return (
    <main>
      <h1>{path === "/orders" ? "Orders" : "Home"}</h1>
      <p>Rendered in the browser at {window.innerWidth}px wide</p>
      <button onClick={() => go(path === "/orders" ? "/" : "/orders")}>Switch page</button>
    </main>
  );
}
next build
Route (app)
┌ ○ /_not-found
└   /[[...slug]]
  └ ● /


○  (Static)  prerendered as static content
●  (SSG)     prerendered as static HTML (uses generateStaticParams)
out/index.html, the <body> with scripts removed
<body><div hidden=""><!--$--><!--/$--></div><!--$!--><template data-dgst=""></template><p>Starting app…</p><!--/$--><!--$--><!--/$--></body>
What’s happening
  1. [[...slug]] is an optional catch-all, so one page matches /, /orders and anything else. generateStaticParams returns only { slug: [""] }, so the build writes a single page: ● / in the table and one index.html in out/ (plus 404.html and _not-found.html).
  2. dynamic(() => import("@/spa/App"), { ssr: false }) keeps the app out of the prerender entirely: the HTML body is just the loading fallback, "Starting app…". Everything else happens in the browser. ssr: false is only allowed inside a Client Component, which is why client.tsx exists: in a Server Component the build fails with Error: `ssr: false` is not allowed with `next/dynamic` in Server Components. Please move it into a Client Component.
  3. Because only index.html exists, the host must answer every path with it (an "SPA fallback" rule). With a small Node file server doing that (below), headless Chrome opened /orders directly and got Orders | Rendered in the browser at 1280px wide | Switch page; clicking the button changed the URL to / and showed Home (that's the app's own history.pushState, not the Next.js router).
  4. This gives up everything Next.js renders for you (no content in the HTML, no per-route code splitting for the old app), and that's the point: it's a starting position. Routes can then move one at a time into real app/ pages, prerendered, and the catch-all shrinks.
spa-serve.mjs (the test server: unknown paths get index.html)
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { extname, join } from "node:path";
const root = process.argv[2];
const types = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".txt": "text/plain", ".woff2": "font/woff2", ".ico": "image/x-icon" };
createServer(async (req, res) => {
  const path = decodeURIComponent(new URL(req.url, "http://x").pathname);
  for (const file of [path, path + ".html", "/index.html"]) {
    try {
      const body = await readFile(join(root, file));
      res.writeHead(200, { "content-type": types[extname(file)] ?? "application/octet-stream" });
      return res.end(body);
    } catch {}
  }
}).listen(4506);
Advancednext export is gone

#Choosing a strategy per route

Added

Every strategy in this chapter answers the same two questions for each piece of a page: who is it for (everyone, or this visitor) and how fresh must it be (fixed at deploy, refreshed now and then, or correct right now). Everything shared and allowed to be a little old goes into the static output; everything personal or must-be-current is rendered per request; and the boundary between the two is a <Suspense>. Most real routes are a mix, which is exactly what Partial Prerendering is for: you no longer pick one strategy per route, you place the boundary.

A practical way to work through a route: start from "everything static", then for each component ask the two questions and move it down the list only as far as it needs to go.

Will a Node server run? no output: 'export' all pages known at build yes, per component: Same for every visitor? yes Changes after deploy? no yes ○ static marketing page 'use cache' + tags blog, catalogue (ISR) no Show anything before it? yes no ◐ Suspense hole cart, dashboard data ƒ blocking checkout Requests that must be refused or redirected with a real status: decide in proxy.ts, before anything renders. Authorisation still happens next to the data.
Start static and move each part down only as far as its audience and freshness require.

The common route types, with the strategy and what it costs:

RouteWho it's for / how freshStrategy in Next.js 16Build symbolWhat a visitor waits for
Marketing, landing, legaleveryone / per deployplain components○nothing: served from cache or CDN
Blog, docs, catalogueeveryone / minutes to days'use cache' + cacheLife + cacheTag, popular slugs in generateStaticParams○ per known slugnothing after the first visit to a slug
Product pageshared details, personal cart and stockcached details in the shell, cart in <Suspense>◐the shell at once, the personal part streams
Dashboardthe frame for everyone, the data per userstatic frame, each widget in its own <Suspense>◐the frame at once, each widget when ready
Checkout, paymentthis visitor / exactly nowper request, gate in proxy.ts, instant = false if there's nothing useful to show earlyƒthe whole render
A site with no servereveryone / per deployoutput: 'export'○/●nothing, but nothing per visitor either

Here is a small shop with one route of each kind, in one Cache Components app:

lib/shop.ts
import { cacheLife, cacheTag } from "next/cache";
import { cookies } from "next/headers";

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export async function getPost(slug: string) {
  "use cache";
  cacheLife("days");
  await wait(100);
  return { slug, title: slug.replace(/-/g, " ") };
}

export async function getProduct(id: string) {
  "use cache";
  cacheLife("hours");
  cacheTag(`product-${id}`);
  await wait(100);
  return { id, name: `Grinder ${id}`, price: 100 + Number(id) };
}

// Per visitor: never cached, read at request time.
export async function getCart() {
  const raw = (await cookies()).get("cart")?.value ?? "";
  return raw ? raw.split(",") : [];
}

export async function getSession() {
  const user = (await cookies()).get("session")?.value;
  return user ? { user } : null;
}
app/page.tsx
// Marketing page: no data, no request APIs. Fully static.
export default function Home() {
  return <h1>Fresh beans, every week</h1>;
}
app/blog/[slug]/page.tsx
import { getPost } from "@/lib/shop";

export function generateStaticParams() {
  return [{ slug: "first-crack" }, { slug: "water-matters" }];
}

// Blog: cached content, known slugs prerendered, new slugs generated on first visit.
export default async function Post({ params }: PageProps<"/blog/[slug]">) {
  const { slug } = await params;
  const post = await getPost(slug);
  return <h1>{post.title}</h1>;
}
app/products/[id]/page.tsx
import { Suspense } from "react";
import { getCart, getProduct } from "@/lib/shop";

export function generateStaticParams() {
  return [{ id: "1" }];
}

async function Details({ params }: { params: Promise<{ id: string }> }) {
  const product = await getProduct((await params).id);
  return <h1>{product.name}: £{product.price}</h1>;
}

async function CartButton({ params }: { params: Promise<{ id: string }> }) {
  const [{ id }, cart] = await Promise.all([params, getCart()]);
  return <button>{cart.includes(id) ? "In your cart" : "Add to cart"}</button>;
}

// Product: shared details in the static shell, the visitor's cart streamed in.
export default function Product({ params }: PageProps<"/products/[id]">) {
  return (
    <main>
      <Suspense fallback={<h1>Loading product…</h1>}>
        <Details params={params} />
      </Suspense>
      <Suspense fallback={<button disabled>Add to cart</button>}>
        <CartButton params={params} />
      </Suspense>
    </main>
  );
}
app/dashboard/page.tsx
import { Suspense } from "react";
import { getCart } from "@/lib/shop";

async function Orders() {
  const cart = await getCart();
  return <p>{cart.length} item(s) waiting</p>;
}

// Dashboard: a static frame, everything inside it per user.
export default function Dashboard() {
  return (
    <main>
      <h1>Your dashboard</h1>
      <Suspense fallback={<p>Loading your orders…</p>}>
        <Orders />
      </Suspense>
    </main>
  );
}
app/checkout/page.tsx
import { redirect } from "next/navigation";
import { getCart, getSession } from "@/lib/shop";

export const instant = false; // no skeleton: the visitor waits for the real page

// Checkout: per request, never cached. proxy.ts already turned away visitors
// without a session (a real 307); this check is the one that protects the data.
export default async function Checkout() {
  const session = await getSession();
  if (!session) redirect("/login");
  const cart = await getCart();
  return <h1>Paying for {cart.length} item(s), {session.user}</h1>;
}
proxy.ts
import { NextResponse, type NextRequest } from "next/server";

// Runs before rendering, so this redirect is a real HTTP 307.
export function proxy(request: NextRequest) {
  if (!request.cookies.has("session")) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
  return NextResponse.next();
}

export const config = { matcher: "/checkout" };
next build
Route (app)
┌ ○ /
├ ○ /_not-found
├   /blog/[slug]
│ ├ ◐ /blog/[slug]
│ ├ ○ /blog/first-crack
│ └ ○ /blog/water-matters
├ ƒ /checkout
├ ◐ /dashboard
└   /products/[id]
  ├ ◐ /products/[id]
  └ ◐ /products/1


ƒ Proxy (Middleware)

○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand
What’s happening
  1. / is ○: nothing in it can differ between visitors or deploys.
  2. Blog posts: the two listed slugs are ○ (the post data is 'use cache' with cacheLife("days")), and the ◐ /blog/[slug] row is the fallback for slugs added after the deploy. This page awaits params at the top, so that fallback shell is empty; a first visit to a new slug waits for the render, and then the page is stored.
  3. Products: even the listed /products/1 is ◐, not ○, because CartButton reads the visitor's cart. The shared details are in the shell; the button is a hole with a disabled "Add to cart" placeholder.
  4. /dashboard is ◐: a static frame and one hole per widget.
  5. /checkout is ƒ: it reads the session and cart at the top with instant = false, so there is no shell. proxy.ts runs before it for /checkout only (the ƒ Proxy (Middleware) row) and redirects visitors without a session cookie.
  6. One app, five strategies, no route-level config apart from instant = false: the strategy is where each await sits and whether its data is cached.
AdvancedWhat each route returned
node observe.mjs http://localhost:4506/<route> (cookie cart=1,2; trimmed)
/                    200 after 66 ms  HIT, s-maxage=31536000                         <h1>Fresh beans, every week</h1>
/blog/first-crack    200 after 43 ms  HIT, s-maxage=86400, stale-while-revalidate=518400   <h1>first crack</h1>
/blog/new-roast      200 after 45 ms  postponed, first HTML at +176 ms               <h1>new roast</h1>
/blog/new-roast      200 after 34 ms  HIT, s-maxage=86400                            <h1>new roast</h1>
/products/1          200 after 40 ms  postponed   +41 ms <h1>Grinder 1: £101</h1> <button disabled="">Add to cart</button>   +49 ms <button>In your cart</button>
/products/2          200 after 41 ms  postponed   +42 ms <h1>Loading product…</h1> <button disabled="">Add to cart</button>   +44 ms <button>In your cart</button>   +143 ms <h1>Grinder 2: £102</h1>
/dashboard           200 after 39 ms  postponed   +40 ms <h1>Your dashboard</h1> <p>Loading your orders…</p>   +41 ms <p>2 item(s) waiting</p>
/checkout            307 after 40 ms  location: /login
/checkout (session)  200 after 39 ms  postponed   +41 ms <h1>Paying for 2 item(s), rohit</h1>
What’s happening
  1. The marketing page and a listed post come back as cache HITs in one chunk. The post's Cache-Control is the days profile: fresh for a day, then up to six more days of stale-while-revalidate.
  2. The new slug new-roast takes 176 ms the first time (rendered on demand behind an empty shell) and is a HIT from then on: ISR for a slug nobody listed.
  3. /products/1 ships the product name and price in the very first chunk (it was prerendered) and the cart state 8 ms later. /products/2 was not listed: its shell has the product fallback, the cart streams at 44 ms, the cached-on-demand details at 143 ms. Both are private, no-store responses because of the personal part; the shared parts are still static.
  4. /checkout without a session never reaches the page: proxy.ts answers with a real 307 and location: /login. With a session it's a full per-request render in one piece.
AdvancedWhy the redirect lives in proxy.ts

The obvious way to protect checkout is redirect("/login") inside the page. In the previous model that gives a real 307. With Cache Components it doesn't, even in a blocking (instant = false) route, because the response has already started:

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

const nextConfig: NextConfig = {
  cacheComponents: false,
};

export default nextConfig;
app/checkout/page.tsx
import { cookies } from "next/headers";
import { redirect } from "next/navigation";

export default async function Checkout() {
  const user = (await cookies()).get("session")?.value;
  if (!user) redirect("/login");
  return <h1>Paying, {user}</h1>;
}
next build
Route (app)
┌ ○ /_not-found
└ ƒ /checkout


○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand
app/checkout/page.tsx
import { cookies } from "next/headers";
import { redirect } from "next/navigation";

export const instant = false; // allowed to block: no static shell

export default async function Checkout() {
  const user = (await cookies()).get("session")?.value;
  if (!user) redirect("/login");
  return <h1>Paying, {user}</h1>;
}
next build
Route (app)
┌ ○ /_not-found
└ ƒ /checkout


○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand
curl -s -D - http://localhost:4506/checkout (no cookie), each app in turn
previous model:     HTTP/1.1 307 Temporary Redirect
                    location: /login

Cache Components:   HTTP/1.1 200 OK
                    x-nextjs-prerender: 1
                    x-nextjs-postponed: 1
                    (in the body's RSC payload) NEXT_REDIRECT;replace;/login;307;
headless Chrome, Cache Components app: ends on /login (which shows the 404 page: there is no /login route)
What’s happening
  1. Without Cache Components, /checkout is an ordinary dynamic route: the page runs before anything is sent, redirect() throws, and Next.js turns it into an HTTP 307 with a Location header. Crawlers, curl and fetch clients all see the redirect.
  2. With Cache Components every page is served through the prerendering pipeline: even an ƒ route sends its (empty) static shell, and the 200 with it, before the page runs. The redirect() then can only travel inside the stream as an error row with a NEXT_REDIRECT digest, which the browser's router performs. A browser ends up on /login; a client that doesn't run the JavaScript sees a 200 with an empty page. The redirect reference says that in a streaming context it "will insert a meta tag to emit the redirect on the client side"; here there was none: the HTML had no <meta http-equiv="refresh">, and Chrome with scripts disabled stayed on /checkout with an empty body (re-checked on 16.4.3, also with partialPrefetching: false). A redirect() inside a <Suspense> boundary does get the meta tag, as the /admin page in Securing Server Actions showed.
  3. proxy.ts runs before any rendering, so NextResponse.redirect there is a real 307, as the shop app showed. Use it for "this visitor may not be here" decisions that need a status code (sign-in gates, locale redirects), and next.config redirects() for fixed moves.
  4. Proxy is an optimistic check that only looks at the cookie. The page (or the data layer it calls) must still verify the session before reading or charging anything; see Authorisation close to the data.
AdvancedOne personal read in a shared spot
AdvancedCheck your strategy the cheap way

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.