Rendering Fundamentals

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Next.js 16.3.8 (the latest release on 2026-10-03, when the facts and examples below were checked; the official documentation reports @doc-version 16.3.8), React 19.3.0 and Node.js 20.9.0 or later. It covers the App Router and shows the Pages Router equivalents in one section. Rendering is the topic where the two caching models matter most, so every example is labelled: the previous model is the default (nothing is cached unless you ask, and a route becomes dynamic as soon as it uses request data), and Cache Components is the opt-in model (cacheComponents: true, with the 'use cache' directive and Partial Prerendering as the default behaviour). Every example was built and run with Next.js 16.3.8, Node.js 24.16 and npm 11.13 on macOS in a scratch copy of the Bookshelf — the catalogue of books, authors and reviews that the rest of this section grows, here reduced to six routes so that the rendering behaviour is the only thing that varies — and each one is followed by the official page it derives from.

This is the overview page of the rendering part of the section. It answers four questions: what are the rendering strategies, when does each part of a page get rendered, how does Next.js choose, and which strategy should a given page use. Each strategy has its own deeper page: the rendering philosophy and the request flow are in the introduction, and the pages on Server and Client Components, server-side rendering and streaming and Suspense (written next to this one) go further into the individual mechanisms; the caching pages, which are written later in the section, cover 'use cache', revalidation and Partial Prerendering in depth. The React-only background is in Server Rendering, SSG and React Server Components.

The Rendering Spectrum

A web page is the result of three kinds of work: computing what the page contains (running components, querying data), delivering it (HTML over the network, then JavaScript) and activating it in the browser (hydration, after which event handlers work). The rendering strategies differ in where and when each of them happens.

Some vocabulary used throughout, taken from the official glossary:

  • Prerendering is rendering a route at build time, or in the background during revalidation. The result is HTML plus an RSC payload (the serialised React Server Components tree used for client-side navigations), which can be cached and served from a CDN. The glossary lists static rendering as a synonym.

  • Dynamic rendering (also runtime rendering) is rendering a component at request time instead of build time. A component becomes dynamic when it uses a Request-time API (cookies(), headers(), searchParams, draftMode()) or, as the next section shows, uncached data.

  • Hydration is React attaching event handlers to server-rendered HTML so that it becomes interactive.

  • The static shell is the prerendered HTML that is sent immediately: everything that could be rendered ahead of time, plus the <Suspense> fallbacks of the parts that could not. When the dynamic params of a route are not known at build time, the reusable, URL-independent version of the shell is the App Shell (16.3).

Table 1. The rendering strategies at a glance
Strategy Where and when the HTML is produced Where the data comes from

CSR — client-side rendering

The browser, after the JavaScript has loaded. The server (or a CDN) only sends a shell.

fetch in an effect, SWR or TanStack Query, in the browser.

SSR — server-side rendering

The server, on every request. Called dynamic rendering in the App Router.

Components that read request data or uncached data.

SSG — static site generation

The build (next build). The same HTML is served to everyone, typically from a CDN.

Data that is the same for every visitor and known at build time.

ISR — incremental static regeneration

The build, then again in the background after a time or on demand. Stale HTML is served while a new copy is made.

Shared data that changes, with a lifetime or an invalidation event.

Streaming

The server, in several chunks of one response, one chunk per <Suspense> boundary.

Slow data behind a boundary; the fast part of the page is not blocked.

PPR — Partial Prerendering

The build for the static shell; the server, per request, for the dynamic holes, in the same response.

Both: shared and cached data in the shell, request data in the holes.

The older frameworks (and the Pages Router) make the choice per page: a page is static or server-rendered. The official Rendering Philosophy guide describes the App Router model as a spectrum at the component level: one route can contain static content, cached content and request-time content, and one response can carry all three. The strategies above are the poles of that spectrum. The figure shows what happens after the browser asks for a page.

A diagram titled What happens after the browser asks for a page, per rendering strategy, with six horizontal timelines that are illustrative and not to scale. CSR: a small shell arrives first and shows a blank or loading state, then a long JavaScript download, then JavaScript runs and fetches data in the browser; content appears and the page is interactive at about the same time, last of all the strategies. SSR: the server renders for a long time before any HTML is sent; content appears when the HTML arrives, then the JavaScript downloads and the page hydrates. SSG: the HTML comes at once from a CDN, so content appears first and early; JavaScript download and hydration follow. ISR: the same as SSG, with a dashed bar showing regeneration in the background after the page was served. Streaming: a shell is sent after a short server step, JavaScript downloads in parallel, a later chunk of content arrives when the data behind the Suspense boundary is ready, and hydration finishes after it. PPR: a static shell comes from a CDN at once while the dynamic holes stream in from the server in parallel; the page hydrates once the JavaScript has downloaded.

The sections below take the strategies in turn. Each shows a Bookshelf route, the code that produces the behaviour and what was observed with next start.

CSR: the browser does the rendering

With client-side rendering the component that shows the data runs in the browser. In the App Router that means a Client Component (a file that starts with the 'use client' directive) that fetches in an effect. The Bookshelf seller dashboard is behind a login and has no SEO value, so it is a reasonable candidate.

CSR in the App Router: a Client Component that fetches after hydration
// app/dashboard/page.tsx
"use client";

import { useEffect, useState } from "react";

type Book = { id: string; title: string };

export default function DashboardPage() {
  const [books, setBooks] = useState<Book[] | null>(null);

  useEffect(() => {
    const controller = new AbortController();
    fetch("/api/books", { signal: controller.signal })
      .then((r) => r.json())
      .then(setBooks)
      .catch(() => {}); // an aborted request lands here when the component unmounts
    return () => controller.abort();
  }, []);

  if (!books) return <p>Loading...</p>;
  return (
    <ul>
      {books.map((b) => (
        <li key={b.id}>{b.title}</li>
      ))}
    </ul>
  );
}

Building it prints ○ /dashboard in the route table: the route is static. This surprises people who expect "CSR" to mean "no server rendering": Next.js still renders the Client Component once, at build time, with its initial state, so the file that is served contains the loading UI:

$ curl -s http://localhost:3401/dashboard | grep -o '<p>Loading[^<]*</p>'
<p>Loading...</p>
$ curl -sI http://localhost:3401/dashboard | grep -i cache-control
Cache-Control: s-maxage=31536000

(The scratch page printed an ellipsis character after Loading; the output is shortened here.) The books arrive only after the JavaScript has downloaded, hydrated and run the effect, and after the second round trip to /api/books. That is the cost of CSR: the data is never in the HTML, so a crawler that does not run JavaScript sees only the loading state, and the largest content appears last. Google documents that it can render JavaScript, but describes the rendering as a separate, deferred step, so content that matters for search belongs in the HTML (see the SEO column of the comparison table).

Use a data-fetching library (SWR or TanStack Query) instead of a hand-written effect in real code: they add caching, deduplication, revalidation on focus and retries. A component that must never render on the server at all (it reads window, or wraps a browser-only widget) can be loaded with next/dynamic and ssr: false, which must be called from a Client Component; see the lazy-loading guide.

SSG: render once, at build time

A route is statically generated when everything it needs is available at build time and nothing in it depends on the request. The About page of the Bookshelf is the simplest case, and the book detail page is the dynamic-route case: the known books are listed in generateStaticParams, and one HTML file is produced for each.

SSG for a fixed page and for a dynamic route (previous model)
// app/about/page.tsx
export default function AboutPage() {
  return (
    <main>
      <h1>About Bookshelf</h1>
      <p>Rendered at: {new Date().toISOString()}</p>
    </main>
  );
}
// app/books/[id]/page.tsx
import { notFound } from "next/navigation";
import { getBook, listBooks } from "@/lib/books";

// one HTML file per known book is produced by `next build`
export async function generateStaticParams() {
  return (await listBooks()).map((b) => ({ id: b.id }));
}

export default async function BookPage({ params }: PageProps<"/books/[id]">) {
  const { id } = await params;
  const book = await getBook(id);
  if (!book) notFound();
  return (
    <main>
      <h1>{book.title}</h1>
      <p>{book.author}</p>
      <p>Rendered at: {new Date().toISOString()}</p>
    </main>
  );
}

The About page prints a timestamp so that you can see when it was rendered. (Under Cache Components the timestamp would be a build error, for the reason explained in the next section; the Cache Components version of the page leaves it out.) The result, from next build, and requests made two seconds apart:

Route (app)      Revalidate  Expire
┌ ○ /                    1m      1y
├ ○ /about
├   /books/[id]
│ ├ ● /books/1
│ ├ ● /books/2
│ └ ● /books/3
...
○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses generateStaticParams)

$ curl -sD - http://localhost:3401/about -o /dev/null | grep -i "cache-control\|x-nextjs-cache"
x-nextjs-cache: HIT
Cache-Control: s-maxage=31536000
# two requests, two seconds apart, return the same text: Rendered at: 2026-10-03T14:00:46.193Z

Cache-Control: s-maxage=31536000 tells shared caches (a CDN) that the response may be reused for a year, and the Rendered at text is the build time, not the request time. The ● /books/[id] rows are the generateStaticParams paths. What about an id that was not listed? By default (dynamicParams = true) the page is rendered on demand the first time it is requested and cached afterwards; with export const dynamicParams = false it is a 404. Requesting /books/99, an id that does not exist in the catalogue, ran notFound() and returned 404 with the page cached (s-maxage=31536000) in the previous model. dynamicParams is not available under Cache Components, where unknown params are handled by the App Shell and, from 16.3, ISR with an App Shell (see the PPR subsection). The mechanics of generateStaticParams are in Dynamic Routes and Route Groups.

A statically generated site can be hosted by anything that serves files. With output: 'export' Next.js writes plain HTML, RSC payload and JavaScript files to the out folder and needs no Node.js server at all. The same routes built that way (without the routes that read request data) produced out/index.html, out/about.html, out/books/1.html and out/dashboard.html:

next.config.ts for a static export
import type { NextConfig } from "next";

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

export default nextConfig;

The static-exports guide lists what a static export cannot do, among them cookies, rewrites, redirects, headers, Proxy, Server Actions, Draft Mode, ISR, the default image loader and Route Handlers that rely on the request; a dynamic route needs generateStaticParams and dynamicParams = false.

Source: Static Exports

ISR: static, but not frozen

SSG has one weakness: a change in the data needs a new build. Incremental static regeneration removes it. Next.js serves the prerendered page; once the page is older than its revalidation time, the next request still gets the old copy, but it triggers a regeneration in the background, and requests after the new copy is ready get the new copy. This is called stale-while-revalidate. In the previous model it takes one line, the revalidate route segment config.

ISR in the previous model: the catalogue is regenerated at most once a minute
// app/page.tsx
import Link from "next/link";
import { listBooks } from "@/lib/books";

// Prerender at build time, regenerate at most once per 60 seconds
export const revalidate = 60;

export default async function CataloguePage() {
  const books = await listBooks();
  return (
    <main>
      <h1>Catalogue</h1>
      <p>Rendered at: {new Date().toISOString()}</p>
      <ul>
        {books.map((b) => (
          <li key={b.id}>
            <Link href={`/books/${b.id}`}>{b.title}</Link>
          </li>
        ))}
      </ul>
    </main>
  );
}

The build table shows 1m under Revalidate and 1y under Expire for /. To watch the cycle without waiting a minute, the same page was built with revalidate = 5:

$ curl -sD - http://localhost:3401/ -o /dev/null | grep -i "x-nextjs-cache\|cache-control"
x-nextjs-cache: HIT
Cache-Control: s-maxage=5, stale-while-revalidate=31535995
# (text: Rendered at 14:01:25.552Z); wait six seconds, request again
x-nextjs-cache: STALE        # the old copy is served and regeneration starts
# (text: still 14:01:25.552Z); wait two more seconds, request again
x-nextjs-cache: HIT          # the new copy
# (text: Rendered at 14:01:36.159Z)

The x-nextjs-cache header is what a self-hosted next start server reports on 16.3.8; treat it as a diagnostic, not as an API. The Cache-Control value is the contract with a CDN: for 60 seconds the CDN may serve the page without asking, and afterwards it may serve it stale while it asks for a fresh copy. ISR is also on-demand: revalidatePath and revalidateTag called from a Server Action or Route Handler mark the page stale immediately, which is how the catalogue should react when a price changes.

Under Cache Components the revalidate segment config is not allowed. The build fails with:

Error: Route segment config "revalidate" is not compatible with `nextConfig.cacheComponents`. Please remove it.

and the same message appears for dynamic. The lifetime moves to the cached function or component, as the next example shows. The same catalogue, written for Cache Components:

ISR under Cache Components: the lifetime belongs to the cached component
// app/page.tsx
import Link from "next/link";
import { cacheLife, cacheTag } from "next/cache";
import { listBooks } from "@/lib/books";

async function Catalogue() {
  "use cache";
  cacheLife("hours"); // also: seconds, minutes, days, weeks, max, or a custom profile
  cacheTag("books"); // revalidateTag or updateTag can invalidate it on demand
  const books = await listBooks();
  return (
    <>
      <p>Rendered at: {new Date().toISOString()}</p>
      <ul>
        {books.map((b) => (
          <li key={b.id}>
            <Link href={`/books/${b.id}`}>{b.title}</Link>
          </li>
        ))}
      </ul>
    </>
  );
}

export default function CataloguePage() {
  return (
    <main>
      <h1>Catalogue</h1>
      <Catalogue />
    </main>
  );
}

The route table row is ○ / 1h 1d: static, revalidated after an hour, expired after a day, and the timestamp inside the cached component stays the same across requests (two requests one second apart returned 2026-10-03T14:02:34.084Z twice). Date is allowed inside 'use cache' because the result is cached on purpose.

SSR: render on every request

Some pages are different for every request: the account page shows the signed-in reader, the search page depends on the query. Their HTML cannot be made ahead of time, so the server renders them when the request arrives. In the App Router you do not "choose SSR": you use request data, and the route becomes dynamic.

SSR in the previous model: reading cookies() makes the route dynamic
// app/account/page.tsx
import { cookies } from "next/headers";

export default async function AccountPage() {
  const session = (await cookies()).get("session")?.value;
  return (
    <main>
      <h1>{session ? "Your account" : "Please sign in"}</h1>
      <p>Rendered at: {new Date().toISOString()}</p>
    </main>
  );
}
// app/search/page.tsx -- searchParams is a Request-time API too
import { listBooks } from "@/lib/books";

export default async function SearchPage({ searchParams }: PageProps<"/search">) {
  const { q } = await searchParams;
  const books = await listBooks(typeof q === "string" ? q : "");
  return (
    <main>
      <h1>Search</h1>
      <p>Rendered at: {new Date().toISOString()}</p>
      <ul>{books.map((b) => <li key={b.id}>{b.title}</li>)}</ul>
    </main>
  );
}

Both routes print ƒ in the route table. Their responses are not cacheable by shared caches, and each request produced a new timestamp (14:01:10.060Z, then 14:01:12.133Z, two seconds later):

$ curl -sD - http://localhost:3401/account -o /dev/null | grep -i "cache-control\|transfer-encoding"
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
Transfer-Encoding: chunked

The cost is on the server: every visit runs React, and the visitor waits for the whole render before the first byte of HTML (the SSR row of the timeline). SSR is the right tool when the content really is per-request; the page on server-side rendering covers the request-time APIs, status codes, headers, after() and the Pages Router getServerSideProps.

Streaming: do not wait for the slowest part

With plain SSR the HTML is sent only when all of it is ready. Streaming sends the response in chunks using HTTP chunked transfer encoding. React’s server renderer produces HTML aligned with <Suspense> boundaries: everything outside the boundaries (the layouts, the navigation and the fallbacks) goes first, and when a boundary’s content resolves its HTML follows, with a small inline script that swaps the fallback for the content. Two streams travel together on the first load, the HTML stream and the component (RSC) payload that React uses to hydrate.

Streaming: the heading is sent at once; the slow part follows (previous model)
// app/live/page.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";

async function Recommendations() {
  await cookies();
  await new Promise((r) => setTimeout(r, 1500)); // stands in for a slow query
  return <p>Recommended for you</p>;
}

export default function LivePage() {
  return (
    <main>
      <h1>Live</h1>
      <Suspense fallback={<p>Loading recommendations...</p>}>
        <Recommendations />
      </Suspense>
    </main>
  );
}

On the same machine, with no network in between, the first byte of /live arrived after 2.6 ms and the response finished after 1.5 s; a static page took under a millisecond for both. The fallback arrives first, the content later. loading.js is the same mechanism applied to a whole page: Next.js wraps page.js in a <Suspense> boundary whose fallback is loading.js (see layouts, templates and special files). The status code is fixed when the first chunk is sent, which is why notFound() inside a streamed boundary cannot change it into a 404; the streaming page covers that and the other consequences.

In the previous model this route is still dynamic: the shell is not prerendered, it is rendered per request and streamed. Under Cache Components the same code becomes Partial Prerendering, below.

PPR: a static shell with dynamic holes

Partial Prerendering combines the static and the streamed. At build time Next.js renders the route as far as it can: static content, cached content ('use cache') and, for everything that needs request data, the <Suspense> fallback. That prerendered static shell can be served from a CDN immediately; the dynamic parts are rendered by the server when the request arrives and are streamed into the same response. With Cache Components this is the default behaviour, with no segment config and no per-route switch. (PPR started as an experimental flag in Next.js 14; the experimental.ppr and dynamicIO flags were removed in 16 in favour of cacheComponents.)

PPR under Cache Components: static heading, streamed account greeting
// app/account/page.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";

async function Greeting() {
  const session = (await cookies()).get("session")?.value;
  return (
    <p>
      {session ? "Your account" : "Please sign in"} - rendered at {new Date().toISOString()}
    </p>
  );
}

export default function AccountPage() {
  return (
    <main>
      <h1>Account</h1>
      <Suspense fallback={<p>Loading your account...</p>}>
        <Greeting />
      </Suspense>
    </main>
  );
}

The only change from the previous-model page is that the part that reads cookies() moved into its own component and got a <Suspense> boundary. The route table marks it ◐ /account, "Partial Prerender: prerendered as static HTML with dynamic server-streamed content". The raw response contains both the fallback (Loading your account…​, from the shell) and the resolved greeting, and next start marked the response as resumed from a postponed prerender (x-nextjs-postponed: 1, an internal header seen on 16.3.8). The book page uses the same pattern: the book and its author are in the shell for the three known ids, and a basket counter that reads a cookie streams in (Basket: …​ in the shell; Basket: 3 items for a request with cookie: basket=3).

Build Time versus Request Time

Every rendering question is a question about when code runs. next build runs your components for every prerenderable route and stores the result; next start (or a serverless platform) runs the rest on demand; the browser runs the Client Components. Anything evaluated during the build is frozen into the static output until the next build or revalidation.

Table 2. Where each kind of work runs
When What runs Output and consequence

Build (next build)

Server and Client Components of static, SSG and ISR routes and of the shell of PPR routes; generateStaticParams; the 'use cache' functions that can complete during prerendering; module-level code of every route.

HTML and RSC payload files in .next/server/app; values such as new Date() or process.env.X read while rendering are baked in; NEXT_PUBLIC_ variables are inlined into the JavaScript.

Revalidation (background)

The same rendering, started by the first request after the lifetime expired or by revalidatePath / revalidateTag.

A replacement HTML and RSC payload; the old copy is served until it is ready (ISR).

Request (server)

Dynamic routes, the dynamic holes of PPR routes, Route Handlers that read the request, Server Actions, Proxy.

HTML streamed to the browser; on client navigations only the RSC payload.

Browser

Hydration of Client Components, effects, event handlers, client-side navigation, CSR data fetching.

Interactivity; the cost is JavaScript bytes and main-thread time.

On disk, a production build contains the prerendered artefacts. These are implementation details that can change between versions, but they make the model concrete. In the previous-model scratch app:

$ ls .next/server/app
about.html  about.meta  about.rsc  about.segments
dashboard.html  dashboard.meta  dashboard.rsc  dashboard.segments
index.html  index.meta  index.rsc  index.segments
...
# no files at all for /account: it has no prerendered output

With Cache Components, account.html, account.meta and account.segments exist (the shell), but there is no account.rsc: the complete payload cannot exist before the request.

Three practical consequences follow.

  • Values read while rendering a static route are frozen. The timestamp on the About page never changes until the next build. The same applies to environment variables read in a Server Component without NEXT_PUBLIC_: they are read when the route is rendered, which for a static route is the build. See environment variables at build time and at runtime for how to read runtime values (use a dynamic route or connection()).

  • Per-user data must never reach a prerendered route. A static page, an ISR page and a 'use cache' result are shared by every visitor. Personal data belongs in dynamic parts that read request data.

  • next dev is not a rendering test. The development server renders pages on demand and does not cache them, so a route that is static in production still looks dynamic in development. Judge the strategy by next build and by requests to next start; the docs say pages are always rendered on demand in development.

How Next.js Decides for Each Route

You never write "this route is static". Next.js decides during next build, by rendering the route and watching what it touches. The rules differ between the two caching models, so they are listed separately.

The previous model: a route is static until something makes it dynamic

By default Next.js prerenders a route. It switches the route to dynamic rendering when it finds:

  • a Request-time API: cookies(), headers(), searchParams, draftMode() (the glossary list); and, in practice, connection(), which exists to wait for an incoming request;

  • uncached data: a fetch with cache: 'no-store' or next: { revalidate: 0 }, or, as the experiment below shows, a request that is discovered after a Request-time API;

  • the segment config export const dynamic = 'force-dynamic' or export const revalidate = 0;

  • a dynamic segment that generateStaticParams does not cover, rendered on demand and then cached (the SSG case).

These routes were built and measured. Each row is one route from the scratch app:

Table 3. What turned each Bookshelf route static or dynamic (previous model, next build on 16.3.8)
Route What it does Symbol Observed at request time

/about

No data, no request APIs

○

Same timestamp on every request, Cache-Control: s-maxage=31536000

/ with revalidate = 60

Reads shared data, has a lifetime

○ (1m, 1y)

s-maxage=60, stale-while-revalidate=31535940

/books/[id]

generateStaticParams lists 1, 2, 3

●

Static for the three ids; a missing id renders on demand and returns 404

/account

await cookies()

ƒ

New timestamp every request, private and no-store

/search

await searchParams

ƒ

Same

/conn

await connection() before reading new Date()

ƒ

Same

/forced

export const dynamic = "force-dynamic"

ƒ

Same

/fresh

fetch(…​, { cache: "no-store" })

ƒ

Same

/dashboard

'use client', effect fetch

○

Static HTML with the loading state

The surprising row is the one the official previous-model guide describes: a fetch that sets no cache option is fetched once during next build if it is reachable before any Request-time API in the route, because the route is prerendered up to that point, while a request discovered after a Request-time API runs on every request. A mock upstream served the current time; two routes made the identical fetch("http://localhost:3401/time"):

The same fetch, before and after a Request-time API (previous model)
// app/pf-before/page.tsx  (○ static: the fetch ran during the build)
export default async function Page() {
  const res = await fetch("http://localhost:3401/time"); // no cache option
  const { now } = await res.json();
  return <p>upstream said {now}</p>;
}

// app/pf-after/page.tsx   (ƒ dynamic: the fetch runs on each request)
import { cookies } from "next/headers";

export default async function Page() {
  await cookies(); // Request-time API first
  const res = await fetch("http://localhost:3401/time"); // the same fetch, discovered later
  const { now } = await res.json();
  return <p>upstream said {now}</p>;
}

The first route returned 2026-10-03T14:04:56.296Z on every request, the time of the build; the second returned a new value on each request. To make the intent explicit and independent of the order of statements, set cache or next.revalidate on the fetch yourself, or use connection(). Next.js 15 changed the default of fetch itself (see "What changed recently"): a fetch without options is not stored in the Data Cache, but a route can still be prerendered when the fetch happens at build time, which is what the experiment shows.

Cache Components: every route is prerendered, and you say where the holes are

With cacheComponents: true the question changes. Next.js always produces a static shell for the route, and it requires you to be explicit about everything that cannot complete during prerendering. A component is handled by what it uses:

  • 'use cache' — its result is cached and joins the shell, as long as its lifetime (cacheLife) is not too short;

  • <Suspense> — its fallback joins the shell and the content streams at request time;

  • predictable values — imports, synchronous file reads, pure computation — complete during prerendering and join the shell automatically;

  • random values and timestamps (Math.random(), Date.now(), crypto.randomUUID()) — either defer them with connection() inside a <Suspense>, or cache the result with 'use cache'.

If a component touches request data or uncached data and is not behind a <Suspense> boundary or a cache, the build fails (and the dev overlay reports it) instead of silently making the route dynamic. This was reproduced:

A cookie read outside Suspense under Cache Components
// app/bad/page.tsx
import { cookies } from "next/headers";

export default async function BadPage() {
  const session = (await cookies()).get("session")?.value;
  return <p>{session ?? "anonymous"}</p>;
}
Error: Route "/bad": 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

A bare new Date() in a static page fails the same way, with its own message (Next.js "encountered the unstable value new Date() while prerendering") and its own fixes: await connection() first, 'use cache', render it on the client, or use a timing API such as performance.now() for telemetry. The errors are documented at blocking-prerender-dynamic and blocking-prerender-current-time.

The /live, /search, /account, /conn and /fresh routes, written with a boundary around the dynamic part, all print ◐ in the route table, / and /about print ○, and /books/[id] prints ◐ for the three known ids and for the App Shell route. Note how deep the dynamic access sits: the official guidance is to push await params, await cookies() and data reads down the tree, because everything above the boundary can be part of the shell.

The Cache Components version of the same routes
Route (app)        Revalidate  Expire
┌ ○ /                      1h      1d
├ ○ /about
├ ◐ /account
├ ƒ /api/books
├   /books/[id]
│ ├ ◐ /books/[id]
│ ├ ◐ /books/1
│ ├ ◐ /books/2
│ └ ◐ /books/3
├ ◐ /conn
├ ○ /dashboard
├ ◐ /fresh
├ ◐ /live
└ ◐ /search

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

/api/books stays ƒ because the Route Handler reads the request URL.

The decision in one picture:

flowchart TD R["Route rendered by next build"] --> M{"cacheComponents enabled?"} M -->|"no: previous model"| P1{"Request-time API, no-store fetch, or force-dynamic?"} P1 -->|yes| D1["ƒ Dynamic: rendered on every request"] P1 -->|no| P2{"Dynamic segment not covered by generateStaticParams?"} P2 -->|yes| D2["Rendered on first request, then cached"] P2 -->|no| S1["○ or ● Static: prerendered, revalidate decides ISR"] M -->|"yes: Cache Components"| C1{"Request data or uncached data outside Suspense and use cache?"} C1 -->|yes| E["Build error: add Suspense, use cache, or connection"] C1 -->|no| C2{"Anything behind a Suspense boundary at request time?"} C2 -->|yes| PP["◐ Partial Prerender: static shell plus streamed holes"] C2 -->|no| S2["○ Static: the whole route is the shell"]
Table 4. How to read the route table
Symbol Meaning (from next build) Typical cause

○

Static: prerendered as static content

No request data, no uncached data. In the Pages Router, a page with no data function (Automatic Static Optimization).

●

SSG: prerendered as static HTML (uses generateStaticParams); the Pages Router uses it for getStaticProps

A dynamic route with known params. Children are listed below it.

ƒ

Dynamic: server-rendered on demand

Request-time API, uncached data, force-dynamic, a Route Handler that reads the request, getServerSideProps.

◐

Partial Prerender: static HTML with dynamic, server-streamed content

Cache Components with a <Suspense> boundary around request-time or uncached work.

Revalidate, Expire

Shown as 1m, 1h, 1y. Revalidate is how long the prerendered route stays fresh; Expire is the time after which, with no requests, the next request waits for fresh content (the expire of cacheLife)

revalidate, cacheLife, or the Pages Router revalidate value.

The table printed by next build no longer shows page sizes or First Load JS (removed in 16); measure bundle size with the analyzer or the browser instead. The ƒ Proxy (Middleware) row that appears in the table when a proxy.ts exists is Proxy, not a page: see Redirects, Rewrites and Proxy.

Both models side by side

The same intentions are written differently. This table is a translation guide for the rest of the section, including the code of pages that were written for the previous model.

Table 5. One intention, two models
I want…​ Previous model Cache Components

A page rendered once at build time

Do nothing; avoid request data

Do nothing; avoid request data and unstable values

A page regenerated every N seconds

export const revalidate = N

'use cache' with cacheLife(…​) on the component or function

On-demand invalidation

revalidatePath, revalidateTag(tag, profile), fetch with next.tags

cacheTag(…​) with revalidateTag or updateTag

A page rendered on every request

Read request data, or dynamic = 'force-dynamic', revalidate = 0

Read request data, or connection(), inside a <Suspense> boundary

Prerender some params, render the rest on demand

generateStaticParams with dynamicParams (default true)

generateStaticParams; from 16.3 ISR with an App Shell for unknown params

A static shell around a dynamic part

Not available as a default; stream a dynamic route with <Suspense>

The default: <Suspense> around the dynamic part (Partial Prerendering)

Segment config dynamic, revalidate, fetchCache

Supported

Rejected at build time; use 'use cache', <Suspense> and connection()

Which Strategy for Which Page

The books that accompany this section reason page by page: the catalogue "should be static", the cart "needs the client", the order page "needs the server". That reasoning generalises into questions you can ask of any page.

  1. Is the content the same for every visitor? If not, it is request data (SSR, or a PPR hole), or private client data (CSR).

  2. Does it matter for search or for the first paint? If yes, the HTML must contain it (SSG, ISR, SSR, PPR); if the page is behind a login and is an application rather than a document, CSR is acceptable.

  3. Is it known before the request, and how often does it change? Never: SSG. On a clock or an event: ISR or a cached component. On every request: SSR.

  4. Is only a small part personal or live? Keep the rest static and stream the part: PPR under Cache Components, or a streamed dynamic route in the previous model.

flowchart TD A["A page of the application"] --> Q1{"Same content for every visitor?"} Q1 -->|no| Q2{"Must it be in the HTML for SEO or first paint?"} Q2 -->|"no: logged-in app screen"| CSR["CSR: Client Component with SWR or TanStack Query"] Q2 -->|yes| Q3{"Only a small part is personal or live?"} Q3 -->|yes| PPR["PPR: static shell plus Suspense holes (Cache Components)"] Q3 -->|no| SSR["SSR: dynamic route, stream with Suspense"] Q1 -->|yes| Q4{"Changes between deployments?"} Q4 -->|"no: fixed content"| SSG["SSG: static, or output export for static hosting"] Q4 -->|yes| Q5{"Can it be stale for a while?"} Q5 -->|"yes: clock or event"| ISR["ISR: revalidate, or use cache with cacheLife and cacheTag"] Q5 -->|"no: must be exact"| SSR

Applied to the pages of the Bookshelf:

Table 6. A strategy for each Bookshelf page
Page Reasoning Strategy (previous model / Cache Components)

/about, /terms

Same for everyone, changes only with a deployment.

SSG / static shell (○)

/ (catalogue)

Same for everyone, changes when stock or prices change, must be fast and indexable.

ISR with revalidate and on-demand revalidatePath / 'use cache' with cacheLife('hours') and cacheTag('books')

/books/[id]

The book is shared and indexable; a basket count or "in your library" badge is per-user.

SSG with generateStaticParams and revalidation on change / PPR: the book in the shell, the personal badge in a <Suspense> hole

Reviews under a book

Shared, changes often, can be slow.

ISR or streaming behind <Suspense> / 'use cache' with a short cacheLife

/search?q=

Depends on the query, which is request data; a shared cache by query is optional.

SSR (ƒ) / <Suspense> around the results, with 'use cache' on a function taking the query as an argument

/account, /orders

Per-user and private; must never be shared by a cache.

SSR (ƒ) / PPR with the shell and the per-user data behind <Suspense>

/checkout

Per-user, depends on cookies and Server Actions, prices re-read on the server.

SSR / dynamic part behind <Suspense>; never cached publicly

/dashboard (seller analytics)

Behind a login, no SEO value, interactive and refreshed often.

CSR with SWR or TanStack Query, or SSR with streaming

Two rules of thumb. First, the rendering strategy of a page is the strategy of its least cacheable part, unless you isolate that part: in the previous model one cookies() call turns the whole route into ƒ; under Cache Components you isolate it with <Suspense> and the rest stays in the shell. Second, prefer the most static strategy that is still correct: it is the cheapest to run and the fastest to deliver, and it fails the least.

Comparing the Strategies

"Faster" depends on what you measure. The table uses the standard Core Web Vitals vocabulary: TTFB (time to first byte), FCP (first contentful paint), LCP (largest contentful paint) and INP (interaction to next paint, the metric that replaced FID in 2024). The entries are qualitative and describe the usual case: real numbers depend on the network, the CDN, the data and the JavaScript.

Table 7. Comparison of the rendering strategies
TTFB FCP / LCP Interactivity (INP) SEO Freshness Server cost Hosting needs

CSR

Very low: a small file from a CDN.

Late: the content waits for JavaScript and a data request.

After download and hydration; a heavy client bundle hurts INP.

Weak: data is not in the HTML.

As fresh as the last fetch.

Low for the page, and one API call per visitor.

Static hosting plus an API.

SSR

High: the first byte waits for the whole render.

Early once the HTML arrives; LCP depends on the slowest data.

After JavaScript and hydration.

Strong: complete HTML.

Exact at the time of the request.

High: React runs on every request.

A Node.js server (or serverless functions).

SSG

Very low: static file from a CDN.

Earliest: complete HTML immediately.

After JavaScript and hydration.

Strong.

Frozen until the next build.

Very low: rendered once.

Any file host or CDN.

ISR

Very low while the cache is warm; stale-while-revalidate hides regeneration.

Same as SSG.

Same as SSG.

Strong.

Within the lifetime; immediate with on-demand revalidation.

Low: one render per lifetime, plus the regeneration work.

A server and a persistent (and, for several instances, shared) cache.

Streaming

Low: the shell is sent before the slow data is ready.

Shell paints early; LCP is the streamed element if it is the largest.

Sooner for parts whose code and data have arrived; hydrates boundary by boundary.

Strong for content in the HTML, with caveats for bots (see the streaming page).

Exact for the streamed parts.

High, like SSR, but perceived latency is lower.

A server and a proxy or CDN that does not buffer responses.

PPR

Very low: the shell comes from a CDN or the origin cache.

Early: the shell paints at once.

As streaming.

Strong for the shell; the holes behave as streaming.

Shell as fresh as its cacheLife, holes exact.

Low for the shell, SSR cost only for the holes.

A server with streaming support; CDN-level shell delivery needs platform integration (see the PPR platform guide).

The hosting column deserves its own sentence. The documentation states that a single Node.js process handles every feature correctly (functional fidelity), while performance features such as the shell delivered at CDN latency, or instant stale-while-revalidate propagation, depend on the platform (performance fidelity). Running several instances needs a shared cache so that an invalidation reaches every one of them; this is the cache coordination and consistency described in the guides on revalidation and CDN caching. A static export is the only option that needs no server at all, and with it you give up everything that needs one.

Hydration: Where Rendering Ends and Interactivity Begins

Except for pure CSR, the browser receives finished HTML. It is not interactive yet: React must download the JavaScript, run the components again in the browser and attach event handlers to the existing DOM. This is hydration, done with hydrateRoot in React. Three things follow.

  • Server and client must render the same markup. If a Client Component renders new Date(), Math.random() or reads window during rendering, the server HTML and the first client render disagree and React reports a hydration mismatch. Move such values into an effect, a state initialised after mount, or (in a Server Component) behind connection(). Since 16.2 the dev overlay shows a diff of the server and client output.

  • In the App Router only Client Components ship JavaScript and hydrate. Server Components render to the RSC payload and are never sent as code. A page that is mostly Server Components hydrates far less than the same page in the Pages Router, where every page component is also a client component and the whole tree hydrates. That reduction is a large part of why the App Router is described as lighter.

  • Suspense boundaries hydrate independently. Streamed content and the code for it arrive in pieces, and React can hydrate the parts that are ready first, and prioritise the one the user is interacting with. That is why the streaming timeline ends interactive at the same moment as its content, not after it.

The time between "content visible" and "interactive" is where INP problems start: a large Client Component bundle or a heavy first render delays the first interaction that works. Keep 'use client' at the leaves, and see the page on Server and Client Components.

The Pages Router Equivalents

In the Pages Router rendering is chosen per page and by a data function in the page file, not by what the components use. The same strategies exist under other names.

Table 8. Rendering in the Pages Router and the App Router
Strategy Pages Router (pages/) App Router (app/)

SSG, nothing to fetch

A page without a data function: Automatic Static Optimization (○)

A route without request or uncached data (○)

SSG with data

getStaticProps (●), getStaticPaths for dynamic routes

Async Server Component and generateStaticParams (●)

ISR

getStaticProps returns revalidate; getStaticPaths fallback for unknown paths

export const revalidate (previous model) or 'use cache' with cacheLife; dynamicParams replaces fallback

SSR

getServerSideProps (ƒ); getInitialProps (legacy)

Request-time APIs or uncached data; connection()

CSR

Effect or SWR in the page; no data function

Client Component with an effect or SWR

Streaming / PPR

Not available; the HTML is produced in one piece

<Suspense>, loading.js; Cache Components

These three pages build in the same scratch app next to the App Router routes (the pages folder with an app folder is allowed; a path cannot exist in both):

Pages Router: SSG with ISR, SSR and a static page
// pages/pr/ssg.tsx
import type { GetStaticProps } from "next";

export const getStaticProps: GetStaticProps<{ builtAt: string }> = async () => ({
  props: { builtAt: new Date().toISOString() },
  revalidate: 60, // ISR in the Pages Router
});

export default function PagesSsg({ builtAt }: { builtAt: string }) {
  return <p>Built at: {builtAt}</p>;
}
// pages/pr/ssr.tsx
import type { GetServerSideProps } from "next";

export const getServerSideProps: GetServerSideProps<{ renderedAt: string }> = async () => ({
  props: { renderedAt: new Date().toISOString() },
});

export default function PagesSsr({ renderedAt }: { renderedAt: string }) {
  return <p>Rendered at: {renderedAt}</p>;
}
// pages/pr/static.tsx -- no data function: statically optimised
export default function PagesStatic() {
  return <p>Static page, no data functions</p>;
}
Route (pages)    Revalidate  Expire
┌ ● /pr/ssg              1m      1y
├ ƒ /pr/ssr
└ ○ /pr/static

The responses matched: /pr/ssg returned Cache-Control: s-maxage=60, stale-while-revalidate=31535940, /pr/ssr returned the private no-store header with a new timestamp on every request, and /pr/static had no cache header from the data layer. The Pages Router also hydrates the whole page and sends the page props as JSON. The page on server-side rendering puts getServerSideProps next to its App Router equivalent.

What Changed Recently

Table 9. Rendering changes by version
Version Change

13.4

The App Router became stable: components render on the server by default, layouts stream, loading.js and <Suspense> stream.

14

Partial Prerendering was introduced as an experimental preview (the experimental.ppr flag).

15

fetch requests and GET Route Handlers are no longer cached by default, the client router cache staleTimes.dynamic defaults to 0, and the request APIs cookies(), headers(), params and searchParams became asynchronous.

16.0 (2025-10-21)

Cache Components (cacheComponents) replaced experimental.ppr, dynamicIO and useCache; PPR is the default behaviour under it. Synchronous access to the request APIs was removed. The build output no longer prints page sizes. Turbopack became the default bundler for dev and build.

16.2 (2026-03-18)

The dev overlay shows a diff of the server and client output for hydration mismatches.

16.3 (2026-08-03)

ISR with an App Shell for unknown params under Cache Components, Instant Navigations with partialPrefetching, and the instant segment config, which makes Next.js report code that would block a navigation from updating the UI immediately (it works only with cacheComponents).

Opt-in today

cacheComponents and partialPrefetching are still opt-in; the 16.3 announcement says these behaviours "will become the default in a future major version". Both caching models are therefore current.

Where the Books Are Outdated

The two books consulted for this section (Real-World Next.js, Packt 2022, and Practical Next.js for E-Commerce, Apress 2023) were written for the Pages Router, in which the per-page choice between SSR, SSG and ISR was the whole of rendering. Their rendering ideas — decide per page by how fresh the data must be and whether the page is personal — still hold, and the questions in the previous section are their generalisation. What has changed:

Table 10. Outdated rendering material and its current form
In the books Today

Rendering is chosen per page with getStaticProps, getServerSideProps, getStaticPaths and getInitialProps

In the App Router rendering follows what components use; the data functions exist only in the Pages Router (still supported, now the legacy choice for new work)

ISR with revalidate returned from getStaticProps, and fallback modes in getStaticPaths

export const revalidate or, under Cache Components, 'use cache' with cacheLife; dynamicParams replaces fallback

next export for static sites

output: 'export' in the config; the next export command was removed in 14

fetch results cached forever by default, and cached GET Route Handlers (the 13 and 14 behaviour)

Not cached by default since 15; opt in with cache: 'force-cache', next.revalidate, 'use cache'

The whole page is hydrated

Only Client Components hydrate; Server Components are not shipped as code

One response per page, produced at once

Streaming with <Suspense>, and Partial Prerendering under Cache Components

FID as the interactivity metric

INP replaced FID as a Core Web Vital in 2024

middleware.ts for request-time logic in front of every page

proxy.ts on the Node.js runtime (16); keep it for redirects and rewrites, not for rendering decisions

Common Pitfalls

Table 11. Pitfalls of rendering strategy and what to do
Pitfall What to do

A timestamp, a random value or an environment variable on a static page is frozen at build time.

Move it to a dynamic part (connection(), request data), cache it deliberately with 'use cache', or render it in the browser. Under Cache Components the build fails and tells you.

Reading cookies() in a layout makes every route below it dynamic (previous model) or breaks the build (Cache Components).

Read request data in the leaf component that needs it and wrap it in <Suspense>; pass the params promise down instead of awaiting it high in the tree.

An ISR or 'use cache' result contains something personal.

It is served to every visitor. Cache only shared data; keep personal data in dynamic parts.

Using revalidate or dynamic after turning on cacheComponents.

The build fails with "Route segment config …​ is not compatible". Replace them with 'use cache', cacheLife, <Suspense> and connection().

Surprised that a fetch without options ran at build time.

Before a Request-time API in the previous model it is part of prerendering. Set cache, next.revalidate or call connection() to state the intent.

Judging the rendering from next dev.

Run next build and next start, read the route table, and request the routes. Development renders everything on demand.

CSR for content that must be indexed, or a 'use client' page that does its data fetching in an effect.

Fetch in a Server Component and pass props down, or keep CSR to logged-in screens.

A CDN or proxy buffers the response, so streaming and PPR show no benefit.

Disable response buffering for the Next.js origin; see the streaming page and the self-hosting guide.

Several instances serve different versions of an ISR page.

Configure a shared cache handler, so that revalidation reaches every instance; see the revalidation guide.

ISR or dynamicParams on a static export.

They do not apply: a static export has no server. Use dynamicParams = false and list every path.

Hydration mismatch warnings from dates, Math.random() or window.

Render such values after mount, or on the server behind connection().

The pages on Server and Client Components, server-side rendering and streaming and Suspense, and later the caching pages, continue from here.