Shells and holes with PPR
Partial Prerendering, the Next.js rendering model that ships one route as a build-time static shell served from the edge plus dynamic holes that stream into Suspense boundaries, and the judgment of what to cache.
Picture the dashboard you are building. At the top sits a header: the logo, the nav, the same pixels for every user who ever signs in. It never changes between requests. Below it sits the reason anyone opened the page in the first place: an org-scoped invoices table that is different for every org, recomputed on every request, and takes around 400ms to query because it joins a few tables and does some math.
Those two pieces have completely different costs. The header could be baked once and served from a cache near the user in about 30ms. The table cannot: it has to run a fresh database read keyed on which org is asking. So here is the question this lesson answers. The header and the table are one page, at one URL. How do you make that one 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 the page into two pages or two requests?
The answer is Partial Prerendering , and you already have most of the pieces. Two lessons ago, in the chapter on loading and streaming, you streamed a dashboard: the server sent the non-suspended shell first and streamed the slow boundaries in afterward, all over one response. In the previous lesson you learned that the seam between cached and dynamic content is a Suspense boundary. This lesson joins those two facts. The shell is the same idea you already have, except now it is prerendered at build time and served from the edge, and the 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 the line between them should fall.
What PPR is: one route, two transport modes
Section titled “What PPR is: one route, two transport modes”Here is the whole model, stated before we draw any picture of it. Under Cache Components, a route ships as two things over one HTTP response:
- A static shell: everything backed by
'use cache'. This is HTML that was prerendered ahead of time and is flushed to the browser immediately. - Dynamic holes: everything inside a
<Suspense>boundary that reads request data. Each one streams in as it resolves.
Next.js itself frames it this way: it prerenders a static HTML shell that serves immediately, then streams the dynamic content in when it is ready, which lets you mix static and dynamic content in a single route. The user sees the shell in tens of milliseconds. The holes arrive chunk by chunk over the same response, and that chunk-by-chunk transport is exactly the one you already met in the loading-and-streaming chapter, unchanged.
So what is new here? Exactly one thing, and it is the whole of what this lesson adds, so it is worth stating carefully.
Hold onto that difference, because the rest of the lesson follows from it.
One bit of housekeeping before we go on, since it will save you confusion when you read older material. PPR used to be an experimental opt-in, so you may run into an experimental.ppr flag or an experimental_ppr segment export in tutorials and blog posts written a year or two ago. In Next.js 16 it is neither experimental nor optional: under Cache Components, PPR is the rendering mode. There is no flag to turn it on, so any flag you see in someone else’s code predates Next.js 16.
The two ingredients: 'use cache' and <Suspense>
Section titled “The two ingredients: 'use cache' and <Suspense>”The useful thing about authoring for PPR is that there is nothing new to author. The entire surface is the two primitives in the heading above, 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 in the previous lesson as the opt-in that marks a piece cacheable; here, think of it simply as the marker that means “this belongs in the shell.” Its full anatomy, where exactly you place it, how the cache key is computed, and what can cross in and out, is the next lesson’s job. For now, treat it as an opaque box labelled “shell.”
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.
That gives us the rule the rest of this lesson builds on. Every single piece of a route is 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. So the habit to develop is to stop thinking of the page as one component and start thinking of it as two collaborating things: a shell and its holes. For each piece you write, you decide which one it is.
Here is the shape of the dashboard from the opening. Read it for structure, not syntax, and notice only which pieces are shell and which is a hole.
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, which means it lives inside a <Suspense> boundary as the hole. Three pieces, two of them shell, one of them streamed. That is the canonical PPR page, and you built it from primitives you already had.
Walking one request: shell out, holes in
Section titled “Walking one request: shell out, holes in”Reading the rule is one thing; watching the timeline is another. Scrub through one request to this dashboard and watch each piece arrive. The tree below is the same page.tsx you just read, so you can map every node back to a line in the file.
A request hits /dashboard. Nothing has run yet for this request, but the static nodes
were already prerendered back at build time and are sitting on the CDN.
Only the dynamic piece does work now. OrgInvoices hits its await on the org-scoped
query and suspends; the shell pieces are already done.
The response serializes and crosses the network: the prerendered shell first, with the skeleton standing in for the suspended hole.
The prerendered shell paints instantly. OrgInvoices shows its skeleton placeholder, since no
database query has run for it yet.
The invoices query resolves on the origin and streams into the hole over the same response. The skeleton is swapped out in place, exactly where it stood.
There is nothing to hydrate here: every node is a Server Component, so the page shipped zero client JS for this tree.
The decisive moment is the jump from the shell phase to the stream phase. At shell, the whole page is already on screen: the header, the footer, and a skeleton sitting where the table will go, with not one byte of the invoices query run yet. At stream, that query finally resolves and the real table slides into the placeholder it was holding. Shell out first, hole filled in second, one response throughout.
One detail separates this from the streaming you already know: every static node in that trace, meaning DashboardPage, Header, and FooterAd, was rendered at build time, not when the request arrived. That is the only line that differs from a plain streaming trace. The shape of the timeline is identical; the shell just came from a different place.
The exercise below helps the order stick. Drag the five steps of a PPR request into the sequence they actually happen in.
Order the steps a PPR request goes through, from build to a filled-in page. Drag the items into the correct order, then press Check.
<Suspense> boundary. 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.
Build vs request: where each piece actually runs
Section titled “Build vs request: where each piece actually runs”The “shell comes from build” idea is the new part, so let us make the two-time split fully concrete.
At next build, Next.js renders the route much like a static-generation pass, but it stops at every <Suspense> boundary that wraps dynamic content. It does not try to resolve the dynamic child; it cannot, because the data does not exist yet (there is 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, two completely different things happen to the two kinds of piece:
- The shell is served straight from the edge cache. No render, no function invocation; 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.
Here is the cost asymmetry in one line, and it is the reason the whole 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.
That gap, once-at-build versus every-single-request, is what makes pushing your chrome into the shell such a high-leverage move. We will spend a section on that judgment shortly.
next build once You do not have to take the framework’s word for which pieces ended up where; you can check. The build log already labels each route’s segments: which are prerendered (static), which are dynamic, and which stream. And when you need more detail, next build --debug-prerender produces a per-component report of what landed in the shell versus what bailed out to dynamic. That flag is the tool you reach for to confirm what actually shipped where, and it is the same one you will use to diagnose the build error coming up in a couple of sections.
Why the seam is a Suspense boundary, specifically
Section titled “Why the seam is a Suspense boundary, specifically”In the previous lesson you were told, a little on faith, that the seam between cached and dynamic content is a Suspense boundary. Now you can see 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. The only thing it changes is where the shell came from: prerendered at build instead of rendered at request. It did not need to invent a transport, because Suspense already had one.
So the boundary is doing two jobs with one line of code. It is the cached/dynamic seam from the previous lesson, and it is the streaming hole from the chapter before that, at the same time. There is no separate “PPR boundary” mechanism to learn, and that is not an accident. PPR was buildable precisely because Suspense already drew the line in exactly the place PPR needed it.
This has one consequence worth dwelling on. Under plain streaming, a clumsy fallback was a small UX wrinkle. Under PPR it matters more, because the fallback is now prerendered into the shell and ships as part of that instant paint, then gets swapped for the streamed content. You already know the rule from the streaming chapter: the skeleton must mirror the resolved content’s footprint. The advice is the same; the stakes are just higher now. If the skeleton is the wrong size, the swap shifts the layout on a page that otherwise felt immediate, which undoes the very win PPR bought you. Match the footprint, and the swap is invisible. (Look back at the trace above: the skeleton-to-table swap is exactly the shell → stream 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”It would be easy to walk away thinking every page now needs a deliberate shell-and-holes split, and that PPR is something you set up. It is neither. 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 at all.
A pure-static route is one where every component is 'use cache' and nothing awaits request data. It ships entirely from the static cache: your /pricing page, your /blog/[slug] posts, the public landing page, all of them. There is no <Suspense> because there is no dynamic work to wrap, and PPR simply reduces to “fully static.” 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 is one with 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 correct, and it is the common case for authenticated surfaces. You should feel no pressure to manufacture a shell for content that is genuinely per-user.
So the statement to take away 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 that the same route model covers, decided per route by where you put 'use cache' and <Suspense>.
The three tabs below are the same idea drawn out: three points on one spectrum, not three different machines.
Everything cached, nothing dynamic. Ships whole from the edge.
export default function PricingPage() { return ( <main> <PricingTable /> {/* 'use cache' → shell */} <Faq /> {/* 'use cache' → shell */} </main> );}Cached chrome around a streamed dynamic hole, the dashboard.
export default function DashboardPage() { return ( <main> <Header /> {/* 'use cache' → shell */} <Suspense fallback={<InvoicesSkeleton />}> <OrgInvoices /> {/* awaits request data → hole */} </Suspense> <FooterAd /> {/* 'use cache' → shell */} </main> );}No 'use cache' anywhere. The whole route renders at request time.
export default function SettingsPage() { return ( <main> <OrgSettings /> {/* awaits request data → hole */} </main> );}Now sort a handful of real surfaces yourself. For each item below, decide whether it ships in the static shell or streams as a dynamic hole. The only question you ever need to ask is this: is this the same for every user and rarely changing (shell), or is it 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.
/pricing page content/blog/[slug] post bodyDeciding what to cache: the cost ledger
Section titled “Deciding what to cache: the cost ledger”You now know the mechanism. The harder, more valuable skill is the judgment: of all the pieces on a page, which ones earn a place in the shell? Knowing the API is the easy half; knowing what to do with it is the half that takes practice.
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, it changes rarely, and it sits at the top of the tree where it gates the very first paint. Caching it pulls your time-to-first-pixel down for every single visitor.
Now for the counter-case, which is where people tend to overreach. Caching a deep child inside an already-dynamic page is often not worth it. Suppose 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 that render 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, and you have to reason about how stale is acceptable. You bought a small, conditional speedup and took on a permanent correctness question, which is frequently a bad trade.
So the diagnostic loop an experienced engineer actually runs is narrow and deliberate:
- Profile the route. Find the boundary that is actually slow, not the one you assume is slow.
- Ask whether the data inside it would tolerate being a little out of date. Could this value be a few seconds or minutes stale without anyone being misled?
- Only then reach for
'use cache'.
Caching is a targeted instrument, not seasoning you sprinkle over the page. That question in step 2, how stale is acceptable, is its own real subject, and it is the next lesson’s job. Here, just notice that the question exists and that it gates the decision.
And this loops straight 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 guardrail: dynamic work must pick a bucket
Section titled “The guardrail: dynamic work must pick a bucket”There is one rule the framework enforces at build, and it tends to surprise people the first time. It helps to meet it as what it is: a guardrail, not an obstacle.
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.”
Your instinct might be that this is the framework being difficult. It is the opposite. 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 it happened. The new model refuses to guess. It makes you say, out loud and in code, which bucket the work belongs to, and that legibility is the entire point. This is the explicit-beats-implicit thread of the chapter showing up as a build error instead of a mystery.
And because the choice is binary, the fix is always one of exactly two moves, and it is worth learning to name them on sight:
- The data is the same for every user → mark it
'use cache'. It joins the shell. - 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.
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.
export default function DashboardPage() { return ( <main> <Suspense fallback={<InvoicesSkeleton />}> <OrgInvoices /> </Suspense> </main> );}Wrapped in <Suspense>, OrgInvoices lands in the dynamic-hole bucket, so it streams in while the shell ships instantly. This is the per-request choice.
One more consequence, carried over from the previous lesson and worth restating here: because the build’s prerender pass actually runs every 'use cache' component, a cached component that throws an error at build fails the whole build, not one request in production. You find that class of bug at deploy time, on your machine or in CI, instead of in a user’s browser. And when a component does bail to dynamic and you cannot see why, next build --debug-prerender points at the exact component that triggered it.
PPR across parallel routes
Section titled “PPR across parallel routes”One last connection, because it shows that PPR is not a special case you opt into but the substrate everything else sits on. In the chapter on the App Router you met parallel routes: the @slot convention, where one URL renders several independent subtrees side by side.
Each @slot is its own subtree, which means each one independently lands somewhere 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. The list-and-detail surface you will build later in this unit leans on exactly this shape: a cached nav slot alongside a dynamic detail slot. You do not choose static-or-dynamic per page; you choose it per subtree, and slots are subtrees. Picture the trace from earlier in this lesson, two of them side by side, one per slot, and that is all parallel routes under PPR are.
What’s next
Section titled “What’s next”That is the rendering picture for the whole chapter: one route, a build-time shell served from the edge plus dynamic holes streamed in, the seam being a Suspense boundary doing double duty, and the judgment of what to cache, which is the chrome and the public pages, not the authenticated data. The rest of the chapter fills in the how you have been promised along the way: 'use cache' in full in the next lesson, 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. Each deferral was deliberate; you have the model now, and the syntax slots into it cleanly.
External resources
Section titled “External resources”The canonical reference: how the static shell, 'use cache', and streaming holes fit together — including the 'How rendering works' section that mirrors this lesson.
The one flag that makes PPR the default rendering mode, and why the old experimental.ppr flag is gone.
The team's architectural essay on why the static-or-dynamic, all-or-nothing choice had to go — the 'why' behind this lesson's opening problem.
A runnable template where you can watch the shell paint instantly and the dynamic holes stream in, then read the source.