Skip to content
Chapter 32Lesson 2

Shells and holes with PPR

Partial Prerendering, the Next.js model that ships one route as a build-time static shell from the edge plus dynamic holes streamed into Suspense boundaries, and the judgment of what to cache.

Picture the dashboard you are building. At the top sits a header: logo, nav, the same pixels for every user, unchanged between requests. Below it sits the reason anyone opened the page: an org-scoped invoices table, different for every org, recomputed on every request, around 400ms to query because it joins a few tables and does some math.

The two pieces cost completely different amounts. The header can be baked once and served from a cache near the user in about 30ms. The table cannot, because it has to run a fresh database read keyed on which org is asking. Yet they are one page at one URL. How do you make that single URL ship as two transport modes at once, the header flushed instantly from the edge and the table streamed in when it is ready, without splitting it into two pages or two requests?

The answer is Partial Prerendering , and you already have the pieces. You have streamed a dashboard before: the server sent the non-suspended shell first and streamed the slow boundaries in afterward, all over one response. You also know the seam between cached and dynamic content is a Suspense boundary. PPR joins those two facts. The shell is the same shell, except now it is prerendered at build time and served from the edge, and those same Suspense boundaries are the holes the dynamic parts stream into. By the end you will be able to look at any route, predict what flushes instantly and what streams in, and decide where that line should fall.

Under Cache Components, a route ships as two things over one HTTP response:

  • A static shell: everything backed by 'use cache'. This HTML was prerendered ahead of time and flushes to the browser immediately.
  • Dynamic holes: everything inside a <Suspense> boundary that reads request data. Each one streams in as it resolves.

The user sees the shell in tens of milliseconds. The holes then arrive chunk by chunk over the same response, transported by exactly the streaming mechanism you already met, unchanged.

One thing here is new, and it is the whole of what this lesson adds:

One bit of housekeeping. PPR used to be an experimental opt-in, so older tutorials may show an experimental.ppr flag or an experimental_ppr segment export. In Next.js 16 there is no flag: under Cache Components, PPR is the rendering mode, so any such flag predates 16.

The two ingredients: 'use cache' and <Suspense>

Section titled “The two ingredients: 'use cache' and <Suspense>”

PPR needs nothing new to author. Its entire surface is the two primitives in the heading, both of which you already know.

The first is 'use cache'. A component marked 'use cache' puts itself into the shell. It is prerendered at build and becomes part of that instant-serving HTML. You met this directive last lesson as the opt-in that marks a piece cacheable; here, treat it as an opaque box labelled “shell.” Where you place it, how the cache key is computed, and what can cross in and out is the next lesson’s job.

The second is <Suspense>. A <Suspense> boundary carves out a hole. Anything dynamic, meaning anything that awaits request data, has to live inside one, and that boundary is the hole PPR streams into. This is the same loading boundary from the streaming chapter; its fallback is the placeholder that ships inside the shell.

Every single piece of a route is therefore one of exactly two things:

  • Cached → it joins the shell.
  • Wrapped in <Suspense> → it becomes a streamed hole.

There is no third bucket. Dynamic work that is neither cached nor wrapped does not quietly fall back to something reasonable; it fails the build, an error we will see shortly. Stop picturing the page as one component: it is a shell and its holes, and for each piece you write, you decide which it is.

Here is the dashboard from the opening. Read it for structure, not syntax, and notice only which pieces are shell and which is a hole.

app/dashboard/page.tsx
export default function DashboardPage() {
return (
<main>
<Header /> {/* 'use cache' → static shell */}
<Suspense fallback={<InvoicesSkeleton />}>
<OrgInvoices /> {/* awaits request data → streamed hole */}
</Suspense>
<FooterAd /> {/* 'use cache' → static shell */}
</main>
);
}

Header and FooterAd are cached, so they prerender into the shell. OrgInvoices awaits a database read keyed on the org, so it is dynamic and lives inside a <Suspense> boundary as the hole. Three pieces, two shell and one streamed: the canonical PPR page.

Scrub through one request to this dashboard and watch each piece arrive. The tree below is the same page.tsx you just read, so every node maps back to a line in the file.

One request to the dashboard

The decisive moment is the jump from shell to stream. At shell, the whole page is on screen, header, footer, and a skeleton where the table will go, with not one byte of the invoices query run. At stream, that query resolves and the real table slides into the placeholder. Shell out first, hole filled second, one response throughout.

One detail separates this from the streaming you already know: every static node, DashboardPage, Header, and FooterAd, was rendered at build time, not when the request arrived. The shape of the timeline is identical to a plain streaming trace; the shell just came from a different place.

The intuition to correct is that the shell waits for the data. It does not. The shell was finished long before the request arrived, and the data catches up to it.

At next build, Next.js renders the route like a static-generation pass, but it stops at every <Suspense> boundary that wraps dynamic content. It cannot resolve the dynamic child, because the data does not exist yet: no request, no org, no user. So it renders everything outside the boundaries, the 'use cache' pieces, into static HTML, drops each Suspense fallback in as a placeholder, and stores that finished shell on the CDN.

At request time, the two kinds of piece are handled differently:

  • The shell is served straight from the edge cache. No render, no function call; it is already HTML sitting near the user.
  • The holes run on the origin (on a platform like Vercel, the serverless function for that route) and stream into the placeholders the shell shipped with.

The cost asymmetry is the reason the feature exists:

Cached pieces are paid for once, at build, and served from the edge. Dynamic pieces are paid for on every request, at the origin.

At next build once
<Header /> prerendered
<OrgInvoices /> — skeleton
<FooterAd /> prerendered
stored on the CDN
At each request every visitor
<Header /> from edge · ~30ms
<OrgInvoices /> from origin · ~400ms
<FooterAd /> from edge · ~30ms
The same route in two time frames. The shell is rendered once at build and served from the edge; only the dynamic hole runs again on each request.

You can check which pieces ended up where. The build log labels each route’s segments as prerendered (static), dynamic, or streamed, and next build --debug-prerender adds a per-component report of what landed in the shell versus what bailed out to dynamic. That flag is also how you will diagnose the build error coming up in a couple of sections.

The previous lesson told you the seam between cached and dynamic content is a Suspense boundary. Here is why that primitive and no other.

Streaming already sends a page as chunks: the shell is one chunk, each Suspense fallback ships inside it, and each boundary’s resolved content streams in as a later chunk. PPR repurposes that exact protocol. All it changes is where the shell comes from: prerendered at build instead of rendered at request.

So the boundary does two jobs with one line of code. It is the cached/dynamic seam from the previous lesson and the streaming hole from the chapter before that, at the same time. There is no separate “PPR boundary” mechanism to learn.

This raises the stakes on one thing. Under plain streaming, a clumsy fallback was a small UX wrinkle. Under PPR the fallback is prerendered into the shell, ships as part of that instant paint, then swaps for the streamed content. You know the rule from the streaming chapter: the skeleton must mirror the resolved content’s footprint. If the skeleton is the wrong size, the swap shifts the layout on a page that otherwise felt immediate, undoing the win PPR bought you. Match the footprint and the swap is invisible. (In the trace above, the skeleton-to-table swap is exactly the shellstream transition.)

When PPR is just static, and when it’s just dynamic

Section titled “When PPR is just static, and when it’s just dynamic”

PPR is one model, and the shell-plus-holes dashboard is just its most interesting case. A route with no holes and a route that is all hole are ordinary outcomes of the same model, so most pages need no special handling.

A pure-static route is one where every component is 'use cache' and nothing awaits request data. It ships entirely from the static cache: /pricing, your /blog/[slug] posts, the public landing page. The pattern worth noticing is that these marketing routes live in the same app/ tree as the product, usually under a (marketing) route group, and the framework decides per route what is static. You do not stand up a separate static site.

A pure-dynamic route has no 'use cache' anywhere. It renders fully at request time: your dashboard, your settings, anything org-scoped. PPR is still active; there is simply no static shell to prerender, so the whole route is one big hole. This is the correct, common shape for content that is genuinely per-user.

So the takeaway is this: PPR is the single rendering model that spans static-only, dynamic-only, and the mix. “Static site” and “dynamic app” are not two modes you pick between per project. They are two ends of one continuum, decided per route by where you put 'use cache' and <Suspense>.

The three tabs below are three points on that spectrum, not three different machines.

Everything cached, nothing dynamic. Ships whole from the edge.

app/pricing/page.tsx
export default function PricingPage() {
return (
<main>
<PricingTable /> {/* 'use cache' → shell */}
<Faq /> {/* 'use cache' → shell */}
</main>
);
}
No holes. The entire route is shell.

Now sort a handful of real surfaces yourself, asking one question of each: is it the same for every user and rarely changing (shell), or per-request (hole)?

Sort each surface into where it belongs in a PPR route. Drag each item into the bucket it belongs to, then press Check.

Ships in the static shell Cache it — same for everyone, rarely changes
Streams as a dynamic hole Wrap in Suspense — different per request
A marketing footer with legal links
The public /pricing page content
A /blog/[slug] post body
An org-scoped invoices table
A personalized “welcome back, {name}” greeting
A per-user unread notification count

Start with the arithmetic. A shell that ships in ~30ms from the edge beats a dynamic render that ships in ~200ms even when that dynamic render is “fast,” because fast is still slower than already-done. So the highest-leverage caching you can do is caching the chrome of the app: the header, the nav, the footer, the marketing surfaces. The chrome is user-agnostic, changes rarely, and sits at the top of the tree where it gates the first paint, so caching it pulls time-to-first-pixel down for every visitor.

The counter-case is where people overreach. Caching a deep child inside an already-dynamic page is often not worth it. Say your dashboard is dynamic and one widget inside it is a little slow. Caching that widget does not change the page’s transport mode: the page is already paying for a request-time render, and shaving one child off it does not make the page static. What it does do is hand you a freshness liability, because that cached widget can now go stale relative to the live data around it. You bought a small, conditional speedup and took on a permanent correctness question, which is usually a bad trade.

So the diagnostic loop is narrow and deliberate:

  1. Profile the route. Find the boundary that is actually slow, not the one you assume is slow.
  2. Ask whether that data would tolerate being a little out of date. Could this value be seconds or minutes stale without misleading anyone?
  3. Only then reach for 'use cache'.

Caching is a targeted instrument, not seasoning you sprinkle over the page. The question in step 2, how stale is acceptable, is its own subject and the job of the Lifetimes and tags lesson; here, just notice that it gates the decision.

This loops back to the reframe from the previous lesson: dynamic is not a problem to fix. A fully-dynamic authenticated dashboard is the correct shape. You cache the chrome around it, not the data inside it, unless one specific, measured boundary has earned the exception.

The build makes you choose: cache or Suspense

Section titled “The build makes you choose: cache or Suspense”

The framework enforces one rule at build, and it tends to surprise people the first time.

If a component awaits request data, such as a database read keyed on the user, cookies(), searchParams, or anything that depends on this request, and it is neither marked 'use cache' nor wrapped in a <Suspense> boundary, the build fails. The message reads, roughly: “Uncached data was accessed outside of a <Suspense> boundary.”

Recall the old model from the previous lesson: a single deep child reaching for request data would silently flip the whole route to dynamic, and nothing in your page.tsx would tell you. The new model refuses to guess. It makes you say, in code, which bucket the work belongs to, and that is the explicit-beats-implicit thread of the chapter showing up as a build error instead of a mystery.

Because the choice is binary, the fix is always one of two moves:

  1. The data is the same for every user → mark it 'use cache'. It joins the shell.
  2. The data is per-request → wrap it in <Suspense>. It becomes a streamed hole.

When the build rejects a component because it cannot prerender request-dependent data, we say it has hit a dynamic bailout . The tabs below show the rejected version that triggers it and the fix.

app/dashboard/page.tsx
export default function DashboardPage() {
return (
<main>
<OrgInvoices /> {/* awaits request data, neither cached nor wrapped */}
</main>
);
}

Build fails: Uncached data was accessed outside of a <Suspense> boundary. OrgInvoices reads request data but sits in neither bucket, so the build refuses to guess.

One more consequence: because the build’s prerender pass actually runs every 'use cache' component, a cached component that throws at build fails the whole build, not one request in production. You catch that class of bug at deploy time, on your machine or in CI, instead of in a user’s browser. And when a component bails to dynamic and you cannot see why, next build --debug-prerender points at the exact component that triggered it.

In the App Router chapter you met parallel routes: the @slot convention, where one URL renders several independent subtrees side by side.

Each @slot is its own subtree, so each one lands independently on the static-to-dynamic spectrum. A cached navigation slot can ship in the shell while a dynamic detail slot streams as a hole, both under the same URL, each with its own loading behavior. You choose static-or-dynamic per subtree, not per page, and slots are subtrees. The list-and-detail surface you build later in this unit leans on exactly this shape: a cached nav slot alongside a dynamic detail slot.

The rest of the chapter fills in the how. The next lesson covers 'use cache' in full, then how long cached pieces live and how they are named, then per-request memoization, then invalidation after a mutation, and finally the async-request APIs those dynamic holes await.