Next.js path · Rendering and caching · Topic 3 of 11

Static, dynamic and partial prerendering

Not started

In Next.js 16 with Cache Components (the default for new apps), each route is rendered at build time as far as it can go. Whatever completes without information from the request (static markup, module imports, pure computation, and data you have marked as cached) goes into the **static shell**: HTML for a direct visit and an RSC Payload for client navigations, both servable from a CDN. Components that need the request, such as cookies, headers, search parameters or fresh data, cannot finish then. Each one must sit inside a <Suspense> boundary: its fallback is baked into the shell and its real content is **streamed** into the same response when a request arrives. This is Partial Prerendering. It used to be an experiment; with Cache Components it is simply how rendering works.

Why it matters: Whether a route is served instantly from a CDN or waits for your server on every hit comes down to which components touch request data, and where your Suspense boundaries are. The build output tells you which one you got.

  1. 1Prerender what you can

    At build time Next.js renders each route's tree. Everything that finishes without request data becomes the static shell: HTML plus the RSC Payload.

  2. 2Request data makes a hole

    `cookies()`, `headers()`, `searchParams`, unknown `params`, uncached fetches and `connection()` can't run at build time. Inside a `<Suspense>` boundary they become a hole that streams in per request.

  3. 3Partial Prerendering is the default

    With Cache Components on, a route is a static shell with dynamic holes. The build marks it ◐; fully static routes are ○, fully request-time ones ƒ.

  4. 4No boundary is a build error

    Request data read outside any Suspense boundary would block the whole page, so the build fails and names the route, unless you opt that route out.

1

How it works

The explanation, with examples you can run.

What the build tells you

A throwaway app built with next build on Next.js 16.4.2 printed:

Route (app)             Revalidate  Expire├ ○ /plain├ ○ /api/static├ ƒ /api/time├   /blog/[slug]│ ├ ◐ /blog/[slug]│ ├ ○ /blog/a│ └ ○ /blog/b├ ○ /cached                     1h      1d├ ƒ /cookie-nosuspense├ ◐ /cookie-suspense└ ◐ /random ○  (Static)             prerendered as static content◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed contentƒ  (Dynamic)            server-rendered on demand
  • /plain renders only markup: fully static.
  • /cookie-suspense reads cookies() inside a Suspense boundary: a shell plus one streamed hole.
  • /random calls connection() before crypto.randomUUID(), which says "this must be per request"; also a hole.
  • /cached awaits a function marked use cache with cacheLife("hours"): its result is in the shell, with a 1 hour revalidate and 1 day expiry.
  • /blog/a and /blog/b came from generateStaticParams; any other slug gets the reusable shell (/blog/[slug]) with its param-specific parts behind fallbacks.
  • /cookie-nosuspense read cookies with no boundary. The first build failed with "Next.js encountered uncached or runtime data during prerendering" and listed three fixes. It only built once the route explicitly opted out of the check, and then it is ƒ: nothing is prerendered and every visit waits for the server.

Thinking in shells and holes

export default function Page() {  return (    <>      <Header />                                   {/* in the shell */}      <ProductList />                              {/* "use cache": in the shell */}      <Suspense fallback={<CartSkeleton />}>       {/* fallback in the shell */}        <Cart />                                   {/* reads cookies(): streamed */}      </Suspense>    </>  )}

The deeper request data sits in the tree, the more of the page is static. A layout that awaits params at the top makes everything below it wait; passing the params promise down and awaiting it in a small child keeps the layout in the shell.

Suspense doesn't make anything dynamic by itself; it only gives a place to show a fallback. Values that differ per run, such as Math.random(), Date.now() and crypto.randomUUID(), must be either cached (everyone sees the same value until revalidation) or placed after await connection() inside a boundary.

Apps without Cache Components use the previous model: a route is static unless it touches request data anywhere, in which case the whole route becomes dynamic, and options like export const dynamic = "force-dynamic" override that per segment. Those segment options are removed when Cache Components is on.

2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.A page renders a header and, inside `<Suspense>`, a component that awaits `cookies()`. What does the build produce?
  2. 2.The same component awaits `cookies()` but has no Suspense boundary anywhere above it. What happens with Cache Components on?
  3. 3.Is wrapping a purely synchronous component in `<Suspense>` enough to make it render per request?

0 of 3 right so far. Get all three to finish this topic.