Siddhant Deval
Siddhant Deval
frontend5 min read

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.

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.

TYPESCRIPT
// ISR — the staleness window
export const revalidate = 60 // Seconds

export default async function ProductPage({ params }) {
  const product = await fetch(`https://api.example.com/products/${params.id}`, {
    next: { revalidate: 60 },
  }).then(r => r.json())

  return <ProductDetail product={product} />
}

The hidden costs:

  1. 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.
  2. 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.
  3. 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.
TYPESCRIPT
// ❌ ISR problem — same cached HTML for all users, no personalization
// ❌ Price update takes up to 60s to reach users
// ❌ Cannot show user-specific data (cart count, name, preferences)

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.

TYPESCRIPT
// PPR page — shell and slots defined by <Suspense> boundaries
import { Suspense } from 'react'

// PPR is currently enabled per-route in next.config.ts:
// experimental: { ppr: 'incremental' }
// Then opt in per-layout/page:
export const experimental_ppr = true

export default function ProductPage({ params }) {
  return (
    <div>
      {/* STATIC SHELL — rendered at build time, CDN-cached */}
      <SiteHeader />        {/* No dynamic APIs — becomes part of the shell */}
      <ProductHero id={params.id} />  {/* Static product title and image */}

      {/* DYNAMIC SLOTS — generated per-request, streamed from origin */}
      <Suspense fallback={<PriceSkeleton />}>
        <LivePrice productId={params.id} />   {/* Calls cookies() for currency pref */}
      </Suspense>

      <Suspense fallback={<CartButtonSkeleton />}>
        <AddToCartButton productId={params.id} />  {/* Calls cookies() for cart session */}
      </Suspense>

      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews productId={params.id} />    {/* Slow DB query — independent stream */}
      </Suspense>
    </div>
  )
}
Crucial Requirement

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.

TYPESCRIPT
// What components make a slot dynamic:

// ✅ Static — stays in shell
async function StaticComponent() {
  const data = await fetch('https://api/static', { cache: 'force-cache' }) // Cached fetch
  return <div>{data.title}</div>
}

// ❌ Dynamic — becomes a slot (must be inside <Suspense>)
async function DynamicComponent() {
  const cookieStore = await cookies()      // Dynamic API → cannot be pre-rendered
  const session = cookieStore.get('sid')
  return <div>Hello {session?.value}</div>
}

// ❌ Dynamic — uncached fetch is per-request
async function LivePriceComponent({ id }) {
  const price = await fetch(`/api/price/${id}`, { cache: 'no-store' }) // Per-request
  return <span>{price}</span>
}

[!CAUTION] Calling cookies(), headers(), or searchParams outside 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.

Pro Tip & Optimization

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

Crucial Requirement

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:

TYPESCRIPT
// next.config.ts (Next.js 15)
import type { NextConfig } from 'next'

const config: NextConfig = {
  experimental: {
    ppr: 'incremental', // Enable incremental adoption — opt in per route
  },
}
export default config

// In any layout.tsx or page.tsx:
export const experimental_ppr = true // Route-level opt-in

Next.js 16 — Stable API (October 2025)

Next.js 16 removed the experimental flags entirely. PPR is now part of the stable caching model:

TYPESCRIPT
// next.config.ts (Next.js 16+)
import type { NextConfig } from 'next'

const config: NextConfig = {
  cacheComponents: true, // Enables static shell + streaming slots (replaces experimental.ppr)
}
export default config
TYPESCRIPT
// In a Server Component — use the 'use cache' directive to mark static subtrees
// next.js 16 dynamic slots are still wrapped in <Suspense>
// The static shell is determined by 'use cache' directives on data functions, not route-level flags
import { unstable_cacheTag as cacheTag } from 'next/cache'

async function getProductData(id: string) {
  'use cache'
  cacheTag('product-data')
  return db.products.findUnique({ where: { id } })
}
Architectural Note

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.

TYPESCRIPT
// The difference in blast radius

// revalidatePath — invalidates an entire route's cached data
revalidatePath('/products/[id]', 'page') // Busts cache for EVERY product page
// Use case: when you genuinely need to invalidate all product pages (e.g., sitewide discount applied)

// revalidateTag — invalidates all data tagged with a specific cache key
revalidateTag('product-123-price') // Busts cache only for data tagged with this key
// Use case: when one product's price changes — don't bust every other product's cache
TYPESCRIPT
// ✅ Tag-based caching — surgical invalidation
async function getProductPrice(id: string) {
  const res = await fetch(`/api/products/${id}/price`, {
    next: {
      tags: [`product-${id}-price`], // Cache tag for this specific resource
      revalidate: false,             // Never auto-revalidate — wait for explicit invalidation
    },
  })
  return res.json()
}

// Server Action that mutates price
async function updatePrice(productId: string, newPrice: number) {
  'use server'
  await db.products.update({ where: { id: productId }, data: { price: newPrice } })
  revalidateTag(`product-${productId}-price`) // ✅ Surgical: only this product's price cache
  // NOT: revalidatePath('/products') — this would bust every product's cache
}

[!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 prefer revalidateTag with 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:

Browser timeline with 3 dynamic slots:

t=0ms   CDN returns static shell (SiteHeader + ProductHero + 3 <Suspense> fallbacks)
t=45ms  LivePrice slot resolves (fast cache hit) → Flight chunk arrives → fallback replaced
t=120ms CartButton slot resolves → Flight chunk arrives → fallback replaced
t=380ms Reviews slot resolves (slow DB query) → Flight chunk arrives → fallback replaced

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, the onShellReady vs. onAllReady choice, selective hydration, and why the browser prioritizes hydrating the boundary the user is clicking first. Part 4: Streaming SSR & Selective Hydration →

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Next.js#Partial Prerendering#PPR#ISR#Cache Invalidation#revalidateTag
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.