Logo
Published on

Partial Prerendering in 2026: Is PPR Finally Production-Ready?

Authors

The pitch

Partial Prerendering is the idea that a single page can be partially static and partially dynamic at the same time. The static parts are prerendered at build time. The dynamic parts are streamed in at request time. You get the speed of a static page with the flexibility of a dynamic page.

I have been watching this feature since it was announced. It was experimental in Next.js 14. It was still experimental in 15. It was behind a flag and it broke a lot. Now in Next.js 16 it is stable and on by default for new apps. The question is whether it actually works in a real app or if it is still a demo feature.

I spent the last week converting a real app to use PPR fully. Here is what I found.

How it actually works

Let me explain the mechanism because the docs use a lot of words to say something simple.

A normal App Router page is either static or dynamic. If it uses cookies(), headers(), searchParams, or any other dynamic API, the whole page becomes dynamic. Every request runs the full page function. If it does not use any of those, it is static and prerendered at build time.

PPR breaks this binary. With PPR, the page is prerendered at build time up to the point where it hits a dynamic API. Everything before that point is static HTML. Everything after is wrapped in a Suspense boundary and streamed in at request time.

Here is a concrete example:

// app/dashboard/page.tsx
import { Suspense } from 'react'
import { cookies } from 'next/headers'

export default function DashboardPage() {
  return (
    <div>
      <header>
        <h1>Dashboard</h1>
        <p>Welcome back</p>
      </header>

      <Suspense fallback={<div>Loading user info...</div>}>
        <UserInfo />
      </Suspense>

      <Suspense fallback={<div>Loading stats...</div>}>
        <Stats />
      </Suspense>

      <footer>
        <p>Built with Next.js</p>
      </footer>
    </div>
  )
}

async function UserInfo() {
  const cookieStore = await cookies()
  const session = cookieStore.get('session')
  const user = await getUser(session)
  return <p>Signed in as {user.name}</p>
}

async function Stats() {
  const stats = await getStats()
  return <p>You have {stats.count} items</p>
}

Without PPR, this whole page is dynamic because UserInfo calls cookies(). The header and footer, which are static, are re-rendered on every request. Wasteful.

With PPR, the header and footer are prerendered at build time. The UserInfo and Stats components are wrapped in Suspense boundaries. When a request comes in, the static HTML is served immediately. The dynamic parts are streamed in as they resolve. The user sees the header and footer instantly, then the user info and stats appear.

This is the core value. You get instant first paint for the static parts and the dynamic parts fill in.

What you need to make it work

PPR does not just happen. You need to structure your page in a way that lets Next.js split it. Here are the rules.

Rule 1: Dynamic parts must be in Suspense boundaries

Next.js uses Suspense to know where to split the static and dynamic parts. If a component uses a dynamic API and is not wrapped in Suspense, the whole page becomes dynamic. PPR only works if the dynamic parts are isolated.

// This does NOT use PPR. The whole page is dynamic.
export default function Page() {
  const userInfo = await getUserInfo() // uses cookies()
  return (
    <div>
      <h1>Dashboard</h1>
      <p>{userInfo.name}</p>
    </div>
  )
}

// This DOES use PPR. The static part is prerendered.
export default function Page() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<p>Loading...</p>}>
        <UserInfo />
      </Suspense>
    </div>
  )
}

Rule 2: The dynamic boundary must be an async component

A regular sync component inside Suspense is just a client component or a static server component. For PPR to stream it, it needs to be async so it can suspend while waiting for data.

// This is async. PPR will stream it.
async function UserInfo() {
  const cookieStore = await cookies()
  return <p>{cookieStore.get('name')?.value}</p>
}

// This is sync. It renders statically even inside Suspense.
function UserInfo() {
  return <p>Static content</p>
}

Rule 3: Do not pass dynamic data to static components

If you pass a dynamic value (like something from cookies) as a prop to a static component, the whole tree becomes dynamic.

// The whole page is dynamic because the name prop comes from cookies
export default function Page() {
  return (
    <div>
      <Header name={getUserName()} />  // dynamic prop
    </div>
  )
}

// Instead, isolate the dynamic part
export default function Page() {
  return (
    <div>
      <Header />  // static, no dynamic props
      <Suspense fallback={<p>Loading...</p>}>
        <UserName />
      </Suspense>
    </div>
  )
}

Where it shines

PPR is great for pages that have a mix of static and dynamic content. Here are the patterns where I saw real benefits.

Dashboard pages. The layout is static. The user-specific data is dynamic. PPR serves the layout instantly and streams the data.

Product pages. The product info is static (from the database at build time). The "add to cart" button state and the price (if it varies by user) are dynamic.

Blog posts. The post content is static. The comments section is dynamic. The post loads instantly, comments stream in.

News sites. The article is static. The related articles and ad slots are dynamic.

Where it does not help

PPR is not a silver bullet. Here is where it does not add value.

Fully static pages. If your page has no dynamic content, PPR does nothing. It is already prerendered. A marketing page or a docs page gets no benefit.

Fully dynamic pages. If everything on the page is dynamic, PPR cannot split it. Every Suspense boundary wraps dynamic content and you end up with a bunch of streaming chunks that could have been a single render. It is actually slower because of the overhead of managing the stream.

Pages with dynamic data above the fold. If the most important content on the page is dynamic, PPR serves the static shell instantly but the user is staring at loading skeletons for the part they actually care about. The time to interactive is the same. The perceived speed is worse because they see a loading spinner instead of content.

The gotchas I hit

Gotcha 1: Suspense boundaries change your layout

When a Suspense fallback is showing, the rest of the page is already rendered. If your dynamic component has a different height than the fallback, the page jumps when the content loads.

// The fallback is one line. The actual content might be 50 lines.
// The page will jump when UserInfo loads.
<Suspense fallback={<p>Loading...</p>}>
  <UserInfo />  // renders a big profile card
</Suspense>

Fix: Make your fallback the same size as your content. Use a skeleton that matches the dimensions.

<Suspense fallback={<div className="h-64 animate-pulse bg-gray-200 rounded" />}>
  <UserInfo />
</Suspense>

Gotcha 2: You cannot use PPR with force-static

If you set export const dynamic = 'force-static' on a page, it cannot use PPR. The page is forced to be fully static. If it has dynamic content, it breaks.

// This page cannot use PPR
export const dynamic = 'force-static'

export default function Page() {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <UserInfo />  // uses cookies(), but page is force-static
    </Suspense>
  )
}

Fix: Remove force-static. Let PPR do its thing. Or restructure so the page is genuinely static.

Gotcha 3: Metadata and PPR interact weirdly

If your generateMetadata function uses dynamic APIs, it forces the whole page to be dynamic. PPR does not apply.

// This makes the whole page dynamic, no PPR
export async function generateMetadata() {
  const cookieStore = await cookies()
  const user = await getUser(cookieStore)
  return { title: `${user.name}'s Dashboard` }
}

Fix: Use static metadata where possible. If you need dynamic metadata, accept that the page is fully dynamic.

// Static metadata, PPR works
export const metadata = {
  title: 'Dashboard'
}

Gotcha 4: Streaming order is not guaranteed

If you have multiple Suspense boundaries, they resolve in the order their data arrives. You cannot control which one loads first. If your user info is fast but your stats are slow, the user info appears first. If it is the other way around, the stats appear first.

This is usually fine but it can look janky if the layout depends on the order. For example if you have a sidebar and a main content area and the sidebar loads before the main content, the layout might shift.

Fix: Design your layout to be resilient to any load order. Use consistent heights and avoid layout that depends on which piece loads first.

Is it production ready?

Yes, with caveats. It works. It does not crash. The build handles it correctly. The streaming is smooth in modern browsers.

The caveats are about developer experience, not runtime. You have to think about your page structure differently. You have to know which parts are static and which are dynamic and make sure the dynamic parts are isolated. If you are not careful, you accidentally make the whole page dynamic by passing a dynamic prop to a static component.

The tooling to debug this is also not great. There is no easy way to see which parts of your page are static and which are dynamic. You sort of have to reason about it and check the build output. Next.js shows you which pages are static (○) and which are dynamic (ƒ) in the build output, but it does not show you which parts of a PPR page are static and which are streaming.

My recommendation: try it on a page that has a clear static/dynamic split. A dashboard, a product page, a blog post with comments. Do not try to convert your whole app at once. Convert one page, see how it feels, then decide.

Should you use it

If you are starting a new app in Next.js 16, yes. Structure your pages with Suspense boundaries from the start. It is the default and it works.

If you have an existing app, pick the pages where it matters most. Pages that are currently dynamic but have a lot of static content. Those are the ones where PPR gives you the biggest win with the least effort.

Do not bother with pages that are already static. Do not bother with pages that are fully dynamic and have no static content. PPR helps the middle ground and that middle ground is bigger than you think.

I fix Next.js production issues for a living. If you want help adopting PPR in your app, email me.