Developer Guide

Next.js 15 Partial Prerendering: Guide

Next.js 15 Partial Prerendering: the static shell / dynamic holes model, Suspense boundaries, streaming, caching, and migration paths.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
38 min read

Partial Prerendering (PPR) is the most interesting rendering primitive Next.js has shipped since the App Router itself. It is also the most misunderstood.

Most tutorials treat it as a "turn on a flag and your site gets faster" feature. That is not the whole picture. PPR is a rendering model, and using it well requires understanding where the shell ends, where the hole begins, and what the streaming contract actually is.

This guide walks through the mental model, the code patterns, the migration path and the production traps, following the official Next.js 15 documentation.

Version status matters here. The official Next.js 15 PPR docs badge the feature Experimental, and the ppr config reference badges the option Canary: the experimental.ppr / experimental_ppr API in this guide targets Next.js 15 canaries, not the stable 15.x releases. Next.js 16 removed both options and moved Partial Prerendering under cacheComponents — see What changes in Next.js 16 below.

The Core Idea in 60 Seconds#

A traditional page in Next.js is either:

  • Static — rendered at build time, served from the CDN, same for everyone
  • Dynamic — rendered at request time, served from the origin, personalized

PPR lets a single page be both:

  • A static shell prerendered at build time (layout, nav, above-the-fold content)
  • One or more dynamic holes rendered at request time (user menu, personalized feed, cart count)

The response streams: the shell arrives instantly from the CDN, and the dynamic holes arrive as they resolve on the server. The user sees the shell paint immediately and the holes fill in shortly after.

plaintext
┌─────────────────────────────────────┐
│  STATIC SHELL (build time, CDN)     │
│  ┌──────────────┐  ┌─────────────┐  │
│  │  Nav         │  │ User menu   │  │
│  │              │  │ [DYNAMIC]   │  │
│  └──────────────┘  └─────────────┘  │
│                                     │
│  Hero content (static)              │
│                                     │
│  ┌────────────────────────────────┐ │
│  │ Personalized feed [DYNAMIC]    │ │
│  └────────────────────────────────┘ │
│                                     │
│  Footer (static)                    │
└─────────────────────────────────────┘

Why PPR Exists#

Before PPR, you had three bad options for a page with both shared and personalized content:

  1. Full SSR: every request renders the whole page on the server. Slow first paint, high origin load. Logo takes as long to render as the personalized part.
  2. Full static + client-side fetch: page is instant but flashes empty placeholders until the client fetches data. Layout shift, worse Core Web Vitals.
  3. ISR + client-side patches: some things cached, others hydrated on the client. Complex to reason about.

PPR gives you the first-paint speed of static with the personalization of SSR, in one response, with the CDN doing the heavy lifting for the shell.

Enabling PPR#

In next.config.js:

javascript
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    ppr: 'incremental',
  },
}
 
export default nextConfig

The 'incremental' value lets you opt in per route: routes without experimental_ppr default to false and are not prerendered with PPR. Migrating route by route keeps each change small enough to measure.

Then in the specific route segment (a page or a layout; per the docs, experimental_ppr applies to all child segments, and a child can set it back to false):

typescript
// app/dashboard/page.tsx
import { Suspense } from 'react'
 
export const experimental_ppr = true
 
export default function DashboardPage() {
  return (
    <div>
      <StaticHero />
      <Suspense fallback={<FeedSkeleton />}>
        <PersonalizedFeed />
      </Suspense>
    </div>
  )
}

That is the whole on-switch. Now we need to understand what happens underneath.

How PPR Actually Works#

At build time#

Next.js walks your component tree. Anything above a <Suspense> boundary is considered part of the static shell and is prerendered to HTML at build time.

Inside a <Suspense> boundary, Next.js checks whether the component can be prerendered. The Next.js 15 docs list the APIs that make a component dynamic; if any of these happen inside the boundary, it becomes a dynamic hole:

  • Reading cookies via cookies()
  • Reading headers via headers()
  • Calling connection()
  • Calling draftMode()
  • Reading search params via the searchParams prop
  • Calling unstable_noStore()
  • A fetch with { cache: 'no-store' }

When a dynamic hole is detected, Next.js prerenders only the fallback at build time. The actual component is postponed until the route is requested, then rendered and streamed into the shell. Wrapping a component in Suspense does not make it dynamic by itself; the API usage does.

At request time#

A request comes in. The static shell is sent first (it was already prerendered), while the server renders the dynamic holes in parallel and streams their HTML into the same response. The docs stress that the static HTML and the streamed parts travel in a single HTTP request, so there is no extra round trip. The browser receives:

plaintext
[static shell opening tags]
[static shell content]
[<Suspense fallback>]
[<!-- dynamic hole placeholder -->]
[static shell closing tags]
 
... then later, streamed into the same response:
 
[<script>$RC('B:0', 'S:0')</script>]  <-- React swap suspense fallback with real content
[rendered dynamic hole content]

React uses a small runtime to swap the fallback with the real content as the stream delivers it. From the user's point of view, the shell renders instantly and the dynamic parts fill in without a layout shift.

The Suspense Contract#

A <Suspense> boundary is not a stylistic choice — it is a declaration that this subtree is allowed to be dynamic. Without a Suspense boundary, a dynamic component anywhere in the tree makes the whole page dynamic.

Correct: dynamic wrapped in Suspense#

typescript
export const experimental_ppr = true
 
export default function Page() {
  return (
    <div>
      <StaticHero /> {/* built at build time */}
      <Suspense fallback={<Skeleton />}>
        <UserMenu /> {/* rendered at request time */}
      </Suspense>
      <StaticFooter /> {/* built at build time */}
    </div>
  )
}

Wrong: dynamic outside Suspense#

typescript
export const experimental_ppr = true
 
export default async function Page() {
  const cookieStore = await cookies() // dynamic call at page top
  const theme = cookieStore.get('theme')?.value
 
  return (
    <div data-theme={theme}>
      <StaticHero />
      <StaticFooter />
    </div>
  )
}

This page has no Suspense boundary, but calls cookies() at the top. Per the docs, using a Dynamic API makes Next.js opt the route into dynamic rendering; to keep PPR you wrap the component that reads it in Suspense. Here nothing is wrapped, so the whole page renders at request time and you get none of the benefits of the static shell.

The rule: if any part of a PPR page needs dynamic data, wrap that part in Suspense. Never call cookies(), headers(), or read searchParams at the page's root.

The Async Component Pattern#

The cleanest PPR code pushes dynamic work into small async components:

typescript
// app/dashboard/page.tsx
export const experimental_ppr = true
 
export default function Page() {
  return (
    <div className="dashboard">
      <h1>Dashboard</h1>
      <Suspense fallback={<MenuSkeleton />}>
        <UserMenu />
      </Suspense>
      <Suspense fallback={<FeedSkeleton />}>
        <PersonalizedFeed />
      </Suspense>
      <Suspense fallback={<StatsSkeleton />}>
        <UsageStats />
      </Suspense>
    </div>
  )
}
 
// components/UserMenu.tsx — async, reads cookies
async function UserMenu() {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) return <SignInButton />
  return <AuthenticatedMenu user={user} />
}

Every dynamic component is self-contained, async, and wrapped in its own Suspense. They all stream in parallel — UserMenu, PersonalizedFeed, and UsageStats render concurrently on the server.

Streaming Order and UX#

When you have multiple Suspense boundaries, they resolve in whatever order their data arrives. This is usually what you want:

  • The fast query (cached user menu) arrives first and paints
  • The slower query (personalized feed with joins) arrives second

But it also means a big section can pop in before a smaller above-the-fold one. Order is controlled by how you nest Suspense boundaries, not by an API. What you can do is keep side effects such as analytics out of the render path with after, which schedules work to run after the response (or prerender) has finished. It was introduced as unstable_after in 15.0.0-rc and became stable as after in Next.js 15.1.0:

typescript
import { after } from 'next/server'
 
async function PersonalizedFeed() {
  const data = await getFeedData()
  after(() => trackFeedView(data))
  return <FeedList data={data} />
}

The after() callback runs after the response finishes. Your user does not wait for analytics before seeing their feed. after is not a Dynamic API, so calling it does not make a route dynamic; inside Server Components you cannot read cookies() or headers() from within the callback.

Data Fetching Under PPR#

Next.js 15's fetch extensions integrate tightly with PPR. Understanding the four cases (behaviour per the Next.js 15 fetch reference):

Case 1: fetch(url) — static unless the route is dynamic#

The default in Next.js 15 is "auto no cache": the response is not put in the Data Cache, but Next.js fetches it once during next build because the route is statically prerendered, so the result lands in the static shell. If a Dynamic API is detected on the route, the fetch runs on every request instead.

Case 2: fetch(url, { next: { revalidate: 60 } }) — ISR-ish#

Cached at build, revalidates every 60s. Still part of the static shell if not inside a dynamic boundary.

Case 3: fetch(url, { cache: 'no-store' }) — dynamic#

Always fetches fresh. Forces the component into a dynamic hole. Must be wrapped in Suspense.

Case 4: fetch(url, { next: { tags: ['posts'] } }) — tag-based invalidation#

Cached until revalidateTag('posts') is called. Static shell compatible. Great for PPR.

Supabase specifically#

Supabase queries go through the supabase-js query builder rather than fetch calls you write yourself, so there is no per-query place to pass next: { revalidate, tags }. You have two options:

Option A — unstable_cache:

The unstable_cache docs state that reading headers or cookies inside a cache scope is not supported, so use a plain client that does not touch cookies for public data:

typescript
import { unstable_cache } from 'next/cache'
import { createClient } from '@supabase/supabase-js'
 
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
 
export const getPublicPosts = unstable_cache(
  async () => {
    const { data } = await supabase.from('posts').select('*').eq('published', true)
    return data
  },
  ['public-posts'],
  { revalidate: 60, tags: ['posts'] }
)

The cached result is part of the static shell. Fast, invalidatable via revalidateTag.

Option B — keep it dynamic:

typescript
async function PublicPosts() {
  const supabase = await createClient()
  const { data } = await supabase.from('posts').select('*').eq('published', true)
  return <PostList posts={data} />
}

This component is dynamic because a cookie-based Supabase server client (createServerClient from @supabase/ssr) calls cookies(). Wrap in Suspense. Slower but always fresh.

For most apps, mix the two: unstable_cache for public content, direct queries for per-user content.

The dynamic Segment Config and PPR#

Before PPR, you would use export const dynamic = 'force-dynamic' to opt a route out of static. Under PPR this is still possible — it disables PPR for that route and renders everything at request time.

typescript
// app/admin/page.tsx
export const dynamic = 'force-dynamic' // no PPR, full SSR

Use this only when you have a specific reason — debugging a PPR issue, or a route that is 100% personalized with no shell worth caching.

Migration: ISR to PPR#

If you are on ISR today, the migration is usually small:

Before (ISR)#

typescript
// app/blog/[slug]/page.tsx
export const revalidate = 300
 
export default async function Page({ params }) {
  const { slug } = await params // params is a Promise in Next.js 15
  const post = await getPost(slug)
  const relatedPosts = await getRelatedPosts(slug)
  const { user } = await getCurrentUser() // makes whole page dynamic
 
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
      <RelatedList posts={relatedPosts} />
      {user && <SaveButton userId={user.id} postId={post.id} />}
    </article>
  )
}

The getCurrentUser() call is a problem. It forces the page to be dynamic. The revalidate: 300 is effectively ignored.

After (PPR)#

typescript
// app/blog/[slug]/page.tsx
export const experimental_ppr = true
export const revalidate = 300
 
export default async function Page({ params }) {
  const { slug } = await params // params is a Promise in Next.js 15
  const post = await getPost(slug)
  const relatedPosts = await getRelatedPosts(slug)
 
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
      <RelatedList posts={relatedPosts} />
      <Suspense fallback={null}>
        <SaveButtonMaybe postId={post.id} />
      </Suspense>
    </article>
  )
}
 
async function SaveButtonMaybe({ postId }) {
  const { user } = await getCurrentUser()
  if (!user) return null
  return <SaveButton userId={user.id} postId={postId} />
}

Changes:

  1. Added experimental_ppr = true
  2. Moved the user-specific button into its own async component
  3. Wrapped it in <Suspense>

Now the entire article is in the static shell. Only the save button is dynamic. The shell serves from the CDN; only the save button touches the origin.

Production Traps#

Trap 1: A cookies() call in a layout poisons everything#

app/layout.tsx is a parent of every page. If you call cookies() at the top of the layout, every route becomes dynamic, regardless of experimental_ppr = true on the page.

Move cookie-dependent logic into an async component wrapped in Suspense:

typescript
// app/layout.tsx
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Suspense fallback={<HeaderSkeleton />}>
          <AuthenticatedHeader />
        </Suspense>
        {children}
      </body>
    </html>
  )
}
 
async function AuthenticatedHeader() {
  const cookieStore = await cookies()
  // ...
}

Trap 2: searchParams is dynamic#

Reading searchParams at the top of the page makes the whole page dynamic (and in Next.js 15 searchParams is a Promise, so it has to be awaited):

typescript
export default async function Page({ searchParams }) {
  const { q } = await searchParams // ← this makes the page dynamic
  return <SearchResults query={q} />
}

The docs' fix is to pass the Promise down unread and await it inside a Suspense-wrapped component. A component only opts into dynamic rendering when the value is accessed, so the rest of the page stays in the shell:

typescript
import { Suspense } from 'react'
 
export const experimental_ppr = true
 
export default function Page({ searchParams }) {
  return (
    <section>
      <h1>Search</h1>
      <Suspense fallback={<ResultsSkeleton />}>
        <SearchResults searchParams={searchParams} />
      </Suspense>
    </section>
  )
}
 
async function SearchResults({ searchParams }) {
  const { q } = await searchParams // dynamic hole only
  // ...
}

Trap 3: Cache headers fight the CDN#

Vercel's CDN respects Cache-Control headers. If your middleware sets Cache-Control: no-store for all requests (common in auth-heavy apps), you defeat the static shell cache.

Let PPR manage cache headers. Only set your own when you have a specific reason, and only on the routes that need them.

Trap 4: Client components do not break PPR#

A client component ('use client') inside the shell is fine — it is prerendered to HTML at build time like any server component, then hydrated on the client. No Suspense needed.

But a client component that fetches data via useEffect is not dynamic in the PPR sense. It is a separate client-side fetch after hydration, with all the usual drawbacks (loading flash, hydration shift). Prefer moving the data fetch to a server component if you can.

Trap 5: Streaming breaks middleware response headers#

Middleware runs before the response streams. Once a stream is in flight, middleware cannot change the headers. If you were setting Set-Cookie in middleware based on the response, that pattern may not work the same way under PPR. Test carefully.

What Changes, Directionally#

PPR's performance claim is structural, not a fixed percentage: a request for an uncached page under ISR waits for a full origin render before any byte reaches the browser, while under PPR the static shell streams immediately and only the dynamic hole waits on the origin. The size of the improvement depends entirely on how much of a given page is static shell versus dynamic hole, and on your own origin's render time — there is no single number that transfers across projects, so measure your own before/after with your existing monitoring (Vercel Analytics, or your own RUM) rather than trusting a borrowed benchmark.

The operational side is more predictable: fewer full-page origin renders on repeat/uncached traffic means fewer database round-trips per request, which is the lever worth watching in your own metrics (origin function invocations, database connection count) before and after adopting PPR on a given route.

Debugging PPR#

The hardest thing about PPR is figuring out why a page went fully dynamic when you expected a static shell.

Check the build output#

next build prints a route list with a symbol per route and a legend underneath. The Next.js 15 CLI reference shows ○ (Static) for prerendered routes and ƒ (Dynamic) for routes server-rendered on demand; a route prerendered with PPR gets its own partial-prerender marker in the legend.

If a route you opted into PPR shows up as ƒ, something in the route tree is forcing full dynamic rendering.

Use generateStaticParams strictly#

If the route has a dynamic segment ([slug]), only the params listed in generateStaticParams get the static shell prerendered at build time. New slugs rendered on-demand are fully dynamic on first request.

typescript
export async function generateStaticParams() {
  const slugs = await getAllSlugs()
  return slugs.map(slug => ({ slug }))
}

Use the documented debugging tools#

Two documented aids help locate the culprit:

  • In development, the Static Route Indicator (added in Next.js 15) shows whether the current route is static or dynamic.
  • If next build fails with prerender errors, next build --debug-prerender (added in Next.js 15.4.0) disables server minification, generates server source maps and keeps building after the first error, so the stack traces point at readable code. The docs warn not to deploy a build made with this flag.

Beyond that, search the route tree (layouts included) for the Dynamic APIs listed above that sit outside a Suspense boundary.

When NOT to Use PPR#

PPR is not universally better. Skip it when:

  • The page is 100% personalized (account settings, admin dashboards)
  • You have no above-the-fold shell to cache (pure data tables)
  • The page changes on every request anyway (live dashboards, real-time feeds)
  • You are on stable Next.js 15 and cannot run a canary — the experimental.ppr option is canary-only
  • You are still on Next.js 14 or earlier — stick with the rendering model you have

What changes in Next.js 16#

The Next.js 16 upgrade guide is explicit: Next.js 16 removes the experimental PPR flag and its configuration options, including the experimental_ppr route segment config. Partial Prerendering is enabled through the top-level cacheComponents option instead:

javascript
/** @type {import('next').NextConfig} */
const nextConfig = {
  cacheComponents: true,
}
 
export default nextConfig

What that means for the code in this guide:

  • Remove experimental.ppr and every experimental_ppr export. The version 16 upgrade codemod removes the segment config from pages and layouts.
  • It is not a drop-in rename. The upgrade guide says PPR in Next.js 16 works differently from the Next.js 15 canaries, and advises staying on your current 15 canary if you rely on PPR today until you migrate.
  • Route segment configs go away. Per Migrating to Cache Components, segments that still export dynamic, revalidate or fetchCache error once cacheComponents is on; you replace them with the use cache directive plus cacheLife and cacheTag. So the export const revalidate = 300 in the migration example above becomes a use cache scope.
  • Data is dynamic by default. With Cache Components, nothing is cached unless you add use cache, and unstable_noStore() is no longer needed. unstable_cache keeps working and can be migrated later.
  • revalidateTag takes a second argument, a cacheLife profile such as revalidateTag('posts', 'max'); the single-argument form is deprecated.
  • The Suspense rule survives. Reading cookies(), headers() or searchParams outside a Suspense boundary now surfaces a blocking-prerender insight in development, and the fix is the same pattern this guide uses.
  • middleware is renamed proxy in Next.js 16, which matters for Trap 5 above if you upgrade.

If you hit errors after switching the flag on, Next.js 16 "use cache" errors: why Cache Components break walks through the cookies/headers-inside-cache and missing cacheLife failures.

The cost of PPR is complexity. The benefit is speed and lower origin load. If there is no origin load to reduce, there is no benefit.

Further Reading#

Closing Thoughts#

PPR is not a spin on SSR or ISR but a third option: it combines a static shell and dynamic holes at the page level instead of the site level.

On a Next.js 15 canary, pick one content-heavy route, enable it, and measure before and after with your own monitoring. On Next.js 16, the same experiment goes through cacheComponents.

And remember the single rule: never call cookies() or headers() above a Suspense boundary. Everything else follows from there.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.