Skip to content

Partial Prerendering (PPR)

advanced15 min read

The Static vs Dynamic Dilemma

Every rendering strategy forces a choice. SSG gives you instant responses but stale data. SSR gives you fresh data but slower responses. ISR tries to split the difference but still serves stale content to some users.

What if you didn't have to choose? What if the navigation, layout, and product description loaded instantly from a CDN (static), while the user's cart count, personalized recommendations, and live inventory streamed in from the server (dynamic) — all in the same HTTP response?

That's Partial Prerendering.

Mental Model

Think of a magazine with pull-out inserts. The magazine itself is printed months in advance (static) — cover, articles, images, layout. But there are designated pockets where personalized inserts (dynamic) slide in at the distribution center before it reaches your mailbox. You get the magazine instantly because most of it was ready in advance. The inserts are personalized just for you, but they don't delay delivery because they're filled in at the last mile.

How PPR Works

PPR splits a single page into a static shell and dynamic holes:

  1. At build time: Next.js renders the page and identifies Suspense boundaries. Everything outside Suspense boundaries becomes the static shell. Suspense fallbacks are baked into the shell.

  2. At request time: The static shell is served instantly from the CDN edge. The server simultaneously begins rendering the dynamic parts (the Suspense boundaries that were deferred).

  3. In one response: The static shell arrives first, then dynamic content streams in as it resolves — replacing the Suspense fallbacks.

import { Suspense } from 'react'

export default async function ProductPage({ params }) {
  const product = await getStaticProduct(params.slug)

  return (
    <main>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <StaticImage src={product.image} alt={product.name} />

      <Suspense fallback={<PriceSkeleton />}>
        <LivePrice productId={product.id} />
      </Suspense>

      <Suspense fallback={<CartSkeleton />}>
        <CartStatus />
      </Suspense>

      <Suspense fallback={<RecommendationsSkeleton />}>
        <PersonalizedRecommendations />
      </Suspense>
    </main>
  )
}

The product name, description, and image are static — they're the same for every user and change infrequently. They become part of the static shell, served from the CDN in under 50ms.

LivePrice, CartStatus, and PersonalizedRecommendations are dynamic — they depend on real-time data or the current user. They're wrapped in Suspense, so PPR defers them to request time and streams them in.

Quiz
With PPR, when does the user first see content on screen?

Suspense as the PPR Boundary

In PPR, Suspense boundaries serve double duty. They're both the streaming boundary (where chunks break) and the static/dynamic boundary (what gets prerendered vs deferred).

The rule is straightforward: everything outside Suspense is static. Everything inside Suspense can be dynamic.

export default async function Page() {
  return (
    <div>
      {/* STATIC — prerendered at build time */}
      <Header />
      <Navigation />
      <h1>Product Catalog</h1>

      {/* DYNAMIC — rendered at request time */}
      <Suspense fallback={<FilterSkeleton />}>
        <DynamicFilters />
      </Suspense>

      {/* STATIC */}
      <StaticProductGrid />

      {/* DYNAMIC */}
      <Suspense fallback={<RecsSkeleton />}>
        <PersonalizedBanner userId={cookies().get('uid')} />
      </Suspense>

      {/* STATIC */}
      <Footer />
    </div>
  )
}

What makes a component dynamic? Anything that reads request-specific information:

  • cookies() — reads the request cookies
  • headers() — reads request headers
  • searchParams — reads URL query parameters
  • Uncached fetch() calls
  • connection() from next/server — explicitly opts out of static rendering (replaces the deprecated unstable_noStore())
The postponed state

Under the hood, PPR works through a concept called "postponed state." At build time, Next.js renders the page and serializes the state of each deferred Suspense boundary. At request time, the server "resumes" rendering from this postponed state — it doesn't re-render the static parts, just the dynamic holes. This is more efficient than re-rendering the entire page on every request like traditional SSR.

The Static Shell in Detail

At build time, PPR generates two artifacts per route:

  1. The HTML shell — Complete HTML with static content and Suspense fallbacks baked in. This is cached on the CDN.
  2. The postponed state — Serialized information about which Suspense boundaries need dynamic rendering. Stored alongside the shell.

When a request arrives:

CDN Edge Node:
  1. Find cached HTML shell for this route
  2. Start sending the shell to the browser immediately
  3. Forward the request to the origin server with the postponed state

Origin Server:
  4. Resume rendering from the postponed state
  5. Render only the dynamic Suspense boundaries
  6. Stream the dynamic chunks back

CDN Edge Node:
  7. Concatenate: shell stream + dynamic stream → single response to browser

Browser:
  8. Renders static shell instantly
  9. Dynamic chunks arrive, inline scripts swap skeletons for real content

The browser receives one continuous HTTP response. It doesn't know (or care) that the first part came from a cache and the second part came from the server.

Quiz
At build time, PPR encounters a component that calls cookies(). What happens to that component?

Enabling PPR in Next.js

PPR is experimental in Next.js 15 (available in canary releases). Next.js 16 stabilized PPR with a different configuration approach. Here's how to enable it in Next.js 15:

For incremental adoption (recommended in Next.js 15), use ppr: 'incremental' to opt in per route:

const nextConfig = {
  experimental: {
    ppr: 'incremental'
  }
}

export default nextConfig

Then opt specific routes into PPR:

export const experimental_ppr = true

export default async function Page() {
  return (
    <div>
      <StaticContent />
      <Suspense fallback={<DynamicSkeleton />}>
        <DynamicContent />
      </Suspense>
    </div>
  )
}

You can also enable PPR for all routes with ppr: true, which tells Next.js to automatically analyze every route. Routes with both static and dynamic parts get the PPR treatment. Fully static routes continue to be fully prerendered. Fully dynamic routes continue to be fully SSR'd.

const nextConfig = {
  experimental: {
    ppr: true
  }
}

export default nextConfig

In Next.js 16, PPR moved out of experimental and the configuration changed — check the Next.js 16 migration guide for updated config.


## PPR vs ISR vs SSR

<ComparisonTable
  headers={["Aspect", "SSR", "ISR", "PPR"]}
  rows={[
    ["Initial response", "Server renders full page", "CDN serves cached full page", "CDN serves static shell, server streams dynamic"],
    ["TTFB", "Slow (server work first)", "Fast (CDN cache)", "Fast (CDN cache for shell)"],
    ["Data freshness", "Always fresh", "Stale within revalidate window", "Static is cached, dynamic is always fresh"],
    ["Personalization", "Full page", "None (same page for all)", "Only within Suspense boundaries"],
    ["CDN cacheability", "Not cacheable", "Fully cacheable", "Shell is cacheable, dynamic streams from origin"],
    ["Server cost", "Every request hits server", "Only on revalidation", "Every request hits server (but only for dynamic parts)"],
    ["Build output", "Nothing prerendered", "Full HTML per page", "Static shell + postponed state per page"]
  ]}
/>

The key insight: PPR doesn't replace ISR or SSR. It combines their strengths. The static shell gives you ISR-level performance. The dynamic streaming gives you SSR-level freshness. But only the dynamic parts hit your server — the static shell is pure CDN.

<SneakyTrap>
PPR doesn't magically make your page faster if the entire page is dynamic. If every section reads cookies, accesses the database, or depends on request data, there's no static shell to cache. PPR shines when you have a clear mix — static layout and content with dynamic personalization, pricing, or user-specific elements.
</SneakyTrap>

<Quiz
  question="An e-commerce product page has static product info and a dynamic live price. With PPR, what is the TTFB for this page?"
  options={[
    "Whatever the live price API takes (it blocks the full response)",
    "Near-instant — the static shell (with a price skeleton) is served from the CDN edge",
    "The average of static and dynamic render times",
    "It depends on the user's location relative to the origin server"
  ]}
  answer={1}
  explanation="With PPR, the TTFB is determined by the CDN edge, not the origin server. The static shell (product name, description, image, and a price skeleton) is served from the nearest edge node — typically under 50ms worldwide. The live price streams in later when the origin server resolves it. The user sees the product instantly and watches the price appear a moment later. Without PPR, the user would wait for the price API before seeing anything."
/>

<CommonMistakes mistakes={[
  { wrong: "Assume PPR makes fully dynamic pages faster", right: "PPR requires a meaningful static shell to deliver its benefit", why: "If the entire page is wrapped in Suspense boundaries reading cookies and databases, there's no cacheable shell. PPR can't help when there's nothing to prerender." },
  { wrong: "Wrap every component in Suspense to make everything dynamic", right: "Keep static content outside Suspense boundaries so it becomes part of the cacheable shell", why: "The more content in the static shell, the faster the initial render. Only wrap truly dynamic, request-dependent content in Suspense." },
  { wrong: "Think PPR replaces ISR", right: "PPR and ISR solve different problems — PPR handles mixed static/dynamic pages, ISR handles fully static pages with periodic refresh", why: "A blog post with no dynamic content benefits from ISR (revalidate when content changes). A product page with both static descriptions and live prices benefits from PPR." },
  { wrong: "Ignore Suspense fallback design for PPR", right: "Design fallbacks that closely match the dynamic content layout to prevent CLS", why: "The fallback is part of the static shell — it's the first thing every user sees. A badly sized skeleton causes layout shift when the dynamic content streams in." }
]} />

<KeyRules rules={[
  "PPR serves a static shell from the CDN edge instantly, then streams dynamic content from the origin server.",
  "Suspense boundaries define the static/dynamic split — everything outside is static, everything inside can be dynamic.",
  "Dynamic triggers: cookies(), headers(), searchParams, uncached fetch calls.",
  "The browser receives one HTTP response — shell first, dynamic chunks streamed after.",
  "PPR combines SSG-level TTFB with SSR-level data freshness for the dynamic parts.",
  "Design Suspense fallbacks carefully — they are part of the cached static shell and affect CLS."
]} />

<TopicConnections links={[
  { slug: "frontend-engineering/server-rendering-and-hydration/streaming-ssr-with-suspense", label: "Streaming SSR with Suspense" },
  { slug: "frontend-engineering/server-rendering-and-hydration/react-server-components", label: "React Server Components" },
  { slug: "frontend-engineering/server-rendering-and-hydration/csr-ssr-ssg-isr-compared", label: "CSR vs SSR vs SSG vs ISR Compared" },
  { slug: "frontend-engineering/server-rendering-and-hydration/edge-rendering", label: "Edge Rendering" }
]} />