Partial Prerendering: Static Shell, Streaming Slots & Cache Invalidation
Partial Prerendering is not ISR — it renders a static shell at build time and fills streaming dynamic slots per request, creating a rendering mode that combines CDN-cached HTML with on-demand personalization without a full SSR cost.
Modern Web Rendering & Performance
Partial Prerendering: Static Shell, Streaming Slots & Cache Invalidation
Every page in a web application is a bet about how often its content changes. Static pages bet "rarely — cache forever." SSR pages bet "always — generate fresh." ISR pages bet "sometimes — regenerate every N seconds." Each bet has a loser: static pages serve stale personalized content; SSR pages pay origin latency on every request; ISR pages serve content that may be up to the revalidation window out of date, regardless of whether it actually changed.
Partial Prerendering (PPR) makes a different wager. Instead of betting on the page, it bets on the component. The static header, navigation, and hero are cached at the CDN edge and served instantly. The personalized cart, the live stock count, the user's notifications — those stream from the origin per request. One URL. Two rendering strategies. The CDN caches what can be cached; the origin generates only what cannot.
1. The Broken Pattern: The ISR Staleness Window
ISR (Incremental Static Regeneration) promised a middle ground between SSG and SSR. Set revalidate = 60 and your page is static for 60 seconds, then regenerated in the background on the next request after the window expires.
The hidden costs:
- Stale data window: A product price changes at second 1 of the 60-second window. Every visitor for the next 59 seconds sees the wrong price — including the user who is about to purchase it.
- Personalization is impossible: The same cached HTML is served to all users. You cannot show "Welcome back, Sid" in a statically cached page without JavaScript hydration tricks that defeat the SSG latency advantage.
- ISR does not invalidate on demand by default: Without
revalidateTag, the only way to bust the cache is to wait for the window to expire. A product going out of stock still shows "In Stock" for up to 60 seconds.
2. PPR Defined: Static Shell + Streaming Slots
Partial Prerendering splits a single route into two separately rendered parts:
The static shell: Server Components that do not call any dynamic APIs (cookies(), headers(), searchParams, per-request fetch calls without caching). Built once at deploy time. CDN-cacheable. Served from the edge with zero origin latency.
Streaming slots: Server Components wrapped in <Suspense> that call dynamic APIs. Generated per-request at the origin. Streamed to the browser as their data resolves. Never cached by the CDN.
The <Suspense> boundary is the PPR boundary. Any Server Component wrapped in <Suspense> that calls a dynamic API (cookies(), headers(), searchParams, or uncached fetch) becomes a dynamic slot. Components outside <Suspense> that do the same will cause Next.js to fall back to full SSR for the entire route — the static shell is lost.
3. <Suspense> as the PPR Boundary
How does Next.js know which components belong to the static shell and which are dynamic slots? It performs static analysis at build time.
The static shell contains placeholder elements where the <Suspense> fallbacks render. The CDN serves this shell instantly. Then the origin generates each dynamic slot's React Flight chunk and streams it to the browser, which replaces each placeholder as the stream arrives.
[!CAUTION] Calling
cookies(),headers(), orsearchParamsoutside a<Suspense>boundary in a PPR route opts the entire route into full SSR — the static shell is discarded and the CDN cannot cache the response. Always gate dynamic API calls inside<Suspense>.
4. The CDN Caching Model
The TTFB arithmetic of PPR is what makes it valuable:
| Rendering Mode | TTFB Source | Dynamic Content Latency |
|---|---|---|
| SSG | CDN edge cache | Impossible (baked at build) |
| ISR | CDN edge cache (until revalidate) | Up to revalidate window old |
| SSR | Origin server | Per-request, blocking TTFB |
| PPR | CDN edge cache (shell) | Per-request, streamed after shell |
With PPR, the browser receives the static shell from the CDN in under 50ms (for a well-located edge node). The dynamic slots then stream in — but the user already sees the page structure, navigation, and the LCP element (which should be in the static shell) before a single origin request completes.
Place the LCP element in the static shell, not inside a <Suspense> boundary. If the hero image is inside a dynamic slot, it will not render until after the origin stream arrives — defeating the LCP advantage of CDN-caching the shell. See Part 6 (LCP) for the intersection with fetchpriority="high".
5. PPR Status and Versions
Version-Specific API: PPR's configuration API changed significantly between Next.js 15 and 16. Use the appropriate path for your version.
Next.js 15 — Experimental Flags
PPR in Next.js 15 uses two experimental opt-in markers:
Next.js 16 — Stable API (October 2025)
Next.js 16 removed the experimental flags entirely. PPR is now part of the stable caching model:
If targeting Next.js 15 production deployments, use the experimental_ppr path. For Next.js 16+, use cacheComponents: true — the experimental flags are removed and will cause a build error if present.
Known Limitations (both versions)
Known limitations to engineer around:
| Limitation | Impact | Mitigation |
|---|---|---|
cookies() in shell = full SSR fallback |
Shell cacheability lost | Always gate cookies() inside <Suspense> |
searchParams in shell = full SSR fallback |
URL-specific pages lose PPR benefit | Read searchParams inside a dynamic slot |
| Dynamic slots are never CDN-cached | High-traffic dynamic slots still hit origin | Use Next.js data cache with revalidateTag |
| PPR + Middleware can interact unexpectedly | Middleware that reads cookies may rewrite the route | Keep Middleware logic out of the PPR static shell rendering pass |
6. revalidatePath vs. revalidateTag
When a Server Action mutates data, it must invalidate the cache so subsequent requests reflect the change. Two APIs exist for this — and they have very different blast radii.
[!CAUTION]
revalidatePath('/', 'layout')invalidates every route that uses the root layout — the broadest possible bust. Use it only for sitewide changes (e.g., global announcement banners). For data-specific changes, always preferrevalidateTagwith granular tag strings.
7. PPR vs. ISR vs. SSR vs. SSG: Decision Matrix
| Criterion | SSG | ISR | SSR | PPR |
|---|---|---|---|---|
| Data freshness | Build-time only | Stale up to revalidate window | Always fresh | Shell: build-time · Slots: always fresh |
| Personalization | ❌ None | ❌ None | ✅ Full | ✅ In dynamic slots |
| TTFB | Fastest (CDN) | Fast (CDN) | Slow (origin) | Fastest for shell (CDN) · Slot streams after |
| CDN cacheability | ✅ Full page | ✅ Full page | ❌ None | ✅ Static shell only |
| On-demand invalidation | ❌ Rebuild required | ✅ revalidateTag |
N/A | ✅ revalidateTag for slot data |
| Infrastructure cost | Lowest | Low | Highest | Low (shell) + medium (slots) |
Rule of thumb:
- Pure content sites with no personalization → SSG or ISR
- Fully personalized dashboards or real-time apps → SSR
- Marketing pages with a user greeting, live price, or cart → PPR ← the sweet spot
8. Composing PPR with Streaming SSR
PPR and Streaming SSR are complementary. When the browser receives the PPR static shell, the dynamic slots are already streaming from the origin via React's renderToPipeableStream. Each <Suspense> slot streams independently — a slow DB query in the Reviews slot does not block the LivePrice slot from resolving first:
The user sees the page structure at t=0ms. The price updates at t=45ms. The full page is interactive at t=380ms. A pure SSR implementation would have blocked the response until t=380ms.
Summary
| Concept | Rule |
|---|---|
| PPR vs. ISR | PPR renders a static shell at build time and dynamic slots per request. ISR regenerates the full page on a timer. PPR enables per-component freshness control. |
<Suspense> as boundary |
Wrapping a component in <Suspense> makes its subtree a dynamic slot — provided it calls a dynamic API. Without the dynamic API call, it remains in the static shell. |
| Dynamic API rule | cookies(), headers(), searchParams, or cache: 'no-store' fetch outside <Suspense> = full SSR fallback. Always gate these inside <Suspense>. |
| LCP in PPR | Place the LCP element in the static shell, not a dynamic slot. The CDN-cached shell is what makes PPR's TTFB advantage real. |
revalidateTag |
Surgical cache invalidation by data tag. Prefer it over revalidatePath for all data-specific mutations. |
What's Next
In Part 4, we go deep into the mechanics of Streaming SSR itself —
renderToPipeableStream, theonShellReadyvs.onAllReadychoice, selective hydration, and why the browser prioritizes hydrating the boundary the user is clicking first. Part 4: Streaming SSR & Selective Hydration →
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.