Back/Module A-4 Partial Prerendering — Static Shell, Dynamic Holes
Module A-4·26 min read

PPR compilation model, how Next.js analyses the Suspense tree, static shell generation at build time, dynamic slot streaming at request time, dynamicIO and use cache as modern PPR primitives, and the PPR vs ISR vs SSR decision matrix.

A-4 — Partial Prerendering: Static Shell, Dynamic Holes

Who this is for: Architects who need to understand PPR at the implementation level — not just "static parts serve from CDN, dynamic parts stream from server" but how Next.js analyses the component tree at build time, what the PPR payload looks like, and when PPR is the right architectural choice versus ISR or SSR.


The Problem PPR Solves

Before PPR existed, every page in a Next.js application had to choose a single rendering strategy. A product page with mostly static content (images, description, specs) but one dynamic element (personalised price, inventory count) had three bad options:

  • SSG: Fast but stale. The inventory count is wrong the moment the build finishes.
  • SSR: Always fresh but every request hits the server. A product page with 100k monthly views at 200ms server render time = 5.5 server-hours per day just to render that one page.
  • ISR: Better, but the stale window means inventory can be wrong for up to N seconds. And if the product goes out of stock, users might still see "In Stock" for up to the revalidation period.

PPR's answer: split the page. The static shell — everything that doesn't change per-user or per-request — is pre-rendered at build time and served from the CDN instantly. The dynamic holes — inventory count, personalised price, "you've viewed this before" message — are streamed from the server immediately after, in parallel with the CDN response.

The user receives the static shell in ~5ms (from CDN). The dynamic slots arrive ~50-150ms later (from the server). The perceived TTFB is the CDN response time; the personalised data arrives almost as fast as full SSR but without the CDN bypass.


How PPR Works at Build Time

PPR is enabled per-page with an experimental flag:

ts
// next.config.ts const config: NextConfig = { experimental: { ppr: true, // enable PPR globally // or ppr: 'incremental' for per-page opt-in }, };

Per-page opt-in (with ppr: 'incremental'):

tsx
// app/products/[id]/page.tsx export const experimental_ppr = true; export default async function ProductPage({ params }) { // ... }

At build time, Next.js renders the page statically — following the same path as generateStaticParams. It encounters Suspense boundaries during this render. For each Suspense boundary, it asks: "does the content inside this boundary use any dynamic APIs (cookies, headers, connection, dynamic fetch)?"

  • Content inside Suspense with only static data → baked into the static shell
  • Content inside Suspense that uses dynamic APIs → becomes a dynamic hole placeholder

The static shell is stored in the Full Route Cache. The dynamic hole positions are marked with placeholder nodes.


What a PPR Response Looks Like

When a user requests a PPR page:

1. CDN serves the static shell immediately (~5ms)
   → Contains: layout, navigation, product images, description, specs
   → Contains: Suspense placeholders (<!--$?-->...) where dynamic holes will go
   → Browser starts rendering immediately

2. Simultaneously: Next.js server begins computing dynamic holes
   → Reads cookies to determine user session
   → Queries inventory API
   → Queries personalised pricing service

3. Dynamic content streams in (~50-150ms after shell)
   → React's streaming protocol fills the Suspense placeholders
   → Browser renders personalised price and inventory inline

The CDN response for the static shell includes a preconnect hint to the origin server so the browser opens a connection to the server while rendering the shell — minimising the gap between shell arrival and dynamic content arrival.


dynamicIO — Enforcing the PPR Contract

dynamicIO is an experimental flag (Next.js 15) that enforces correct use of Suspense with async data access:

ts
// next.config.ts const config: NextConfig = { experimental: { ppr: true, dynamicIO: true, }, };

With dynamicIO enabled, any async function that accesses dynamic data (cookies, headers, connection, or uncached fetch) must be inside a Suspense boundary. If it isn't, Next.js throws a build error.

tsx
// ❌ dynamicIO error: dynamic data outside Suspense export default async function Page() { const session = await cookies(); // dynamic — must be in Suspense return <Dashboard session={session} />; } // ✅ Correct: dynamic data inside Suspense boundary export default function Page() { return ( <> <StaticShell /> <Suspense fallback={<DashboardSkeleton />}> <DynamicDashboard /> {/* this component reads cookies */} </Suspense> </> ); }

dynamicIO turns PPR correctness from a convention into a compiler-enforced contract. Enable it on new projects from the start — retrofitting it onto an existing codebase requires systematically wrapping all dynamic data access in Suspense, which is a significant refactor.


use cache as a PPR Primitive

The interaction between use cache and PPR is the most powerful pattern in the Architect toolkit. A component wrapped in Suspense with use cache becomes statically generated at build time:

tsx
// This Suspense boundary becomes STATIC (baked into the shell) <Suspense fallback={<ReviewsSkeleton />}> <ProductReviews productId={id} /> {/* uses 'use cache' internally */} </Suspense> // This Suspense boundary becomes DYNAMIC (streamed from server) <Suspense fallback={<InventorySkeleton />}> <InventoryStatus productId={id} /> {/* reads cookies() for user's region */} </Suspense>

Same <Suspense> element, different runtime behaviour, based purely on whether the content inside uses dynamic APIs or cached data. PPR uses this distinction to automatically determine what goes in the shell and what gets streamed.

The practical consequence: using use cache on a component inside a Suspense boundary "promotes" that boundary from dynamic to static, giving it CDN delivery instead of server-side streaming. Aggressive use of use cache inside Suspense boundaries is the main lever for improving PPR performance.


The PPR Decision Matrix

Page typeBest strategyWhy
Fully static (docs, marketing)SSG ()No server needed at all
Mostly static + small dynamic (e-commerce product)PPRStatic shell from CDN + dynamic holes streamed
Mostly dynamic + small static (dashboard)SSR (λ) + layout cachingDynamic data dominates; PPR overhead isn't worth it
Fully personalised (user feed, auth dashboard)SSR with use cache: 'private'Static shell is too thin to benefit
Large catalog with stale tolerance (blog, docs)ISRSimple, well-understood, no PPR complexity

PPR is the right choice when the ratio of static to dynamic content is high — roughly 80%+ of the page is the same for every user. For pages that are substantially different per user, the "static shell" is thin enough that PPR's complexity isn't justified.


PPR and Personalisation at Scale

A real-world pattern for an e-commerce product page with PPR:

tsx
// app/products/[id]/page.tsx export const experimental_ppr = true; export async function generateStaticParams() { const products = await getTopProducts(10000); // pre-render top 10k products return products.map(p => ({ id: p.id })); } export default async function ProductPage({ params }) { const { id } = await params; const product = await getProductCached(id); // use cache — goes into shell if (!product) notFound(); return ( <article> {/* Static shell: served from CDN */} <ProductGallery images={product.images} /> <ProductDescription product={product} /> <ProductSpecs specs={product.specs} /> {/* Dynamic holes: streamed from server */} <Suspense fallback={<PriceSkeleton />}> <PersonalisedPrice productId={id} /> {/* reads user cookie for pricing tier */} </Suspense> <Suspense fallback={<InventorySkeleton />}> <InventoryStatus productId={id} /> {/* real-time inventory */} </Suspense> <Suspense fallback={<ReviewsSkeleton />}> <ProductReviews productId={id} /> {/* use cache + cacheTag — also static! */} </Suspense> </article> ); }

In this architecture, ProductReviews uses use cache — it's cached, so it becomes part of the static shell despite being in a Suspense boundary. The only truly dynamic elements are PersonalisedPrice (reads a user cookie) and InventoryStatus (real-time data).

The static shell is rich: gallery, description, specs, and reviews. The server only does meaningful work for the two dynamic slots. CDN cache hit rate for the shell is effectively 100% for hot products.


Where We Go From Here

A-5 goes into the streaming SSR internals that power the dynamic holes in PPR — React 18/19's concurrent rendering, chunked transfer encoding, selective hydration, and the full lifecycle of a Suspense boundary from server to client. With A-4's understanding of when and why PPR is used, A-5 explains the mechanism that makes it work.

Discussion