Partial Prerendering in Production: What Actually Ships and What Breaks
Partial Prerendering promised the best of static and dynamic. After shipping it on a few real apps, here's what actually works, what silently degrades, and where the sharp edges hide.

Partial Prerendering (PPR) is the feature that finally makes the static-vs-dynamic argument feel outdated. You get a prerendered shell served instantly from the edge, with dynamic holes streamed in from the runtime. On paper, it's the answer. In practice, we've shipped it on three production apps in the last year, and the story is more interesting than the marketing suggests.
This is a field report: what PPR actually does when you turn it on, where the Suspense boundaries end up mattering more than you'd expect, and the failure modes we've hit that don't show up in the docs.
What PPR is actually doing under the hood
When you enable PPR, Next.js does a build-time render of your route. Anywhere it hits a dynamic API — cookies(), headers(), searchParams, an uncached fetch — it needs a Suspense boundary above the call site. The prerender captures everything outside those boundaries as static HTML, and the boundaries themselves become holes with their fallback UI baked in. At request time, the static shell ships immediately and the dynamic holes stream in.
The mental model that finally clicked for our team: PPR is not "the page is static or dynamic". PPR is "the page is static, and Suspense boundaries are the dynamic parts". Everything hinges on where you draw those boundaries.
The config that trips people up
PPR is still incremental as of Next.js 15.x. You opt in per-route:
// next.config.ts
import type { NextConfig } from 'next';
const config: NextConfig = {
experimental: {
ppr: 'incremental',
},
};
export default config;
// app/product/[slug]/page.tsx
export const experimental_ppr = true;
export default async function ProductPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
// ...
}
The gotcha: setting experimental_ppr = true on a route with no Suspense boundaries and dynamic APIs at the top just falls back to a fully dynamic render. No error, no warning in production builds. You'll swear PPR is on. It isn't doing anything for you.
The Suspense boundary is a product decision, not a technical one
This is the biggest shift. Where you place <Suspense> decides what your users see in the first 100ms. Put it too high, and half the page is a skeleton. Put it too low, and you're prerendering data that changes per user, which forces the parent to become dynamic anyway.
Here's the pattern that's worked for us on e-commerce product pages:
// app/product/[slug]/page.tsx
import { Suspense } from 'react';
import { ProductDetails } from './product-details';
import { PriceAndStock } from './price-and-stock';
import { Recommendations } from './recommendations';
import { PriceSkeleton } from './skeletons';
export const experimental_ppr = true;
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
return (
<article>
{/* Static: product copy, images, specs. Prerendered. */}
<ProductDetails slug={slug} />
{/* Dynamic: personalised price, live stock. Streamed. */}
<Suspense fallback={<PriceSkeleton />}>
<PriceAndStock slug={slug} />
</Suspense>
{/* Dynamic: recommendations based on cookies. Streamed. */}
<Suspense fallback={null}>
<Recommendations />
</Suspense>
</article>
);
}
Product copy and imagery are cached at build (or ISR-refreshed). Price and stock read cookies for the visitor's currency and warehouse region — those go behind a boundary. Recommendations read a session cookie — same treatment.
The result on our staging environments: TTFB drops close to the CDN's round trip because the shell is static, and LCP tends to hit before the dynamic holes resolve, provided your LCP element sits in the static shell. Which brings us to the sharpest edge.
If your LCP element is dynamic, PPR won't save you
We learned this the hard way. A team put the hero image inside a Suspense boundary because the image URL was personalised. LCP got worse than the fully-dynamic baseline, because now there was a skeleton flashing before the image, and the browser couldn't preload it from the initial HTML.
Rule we now follow: the LCP candidate must be in the static shell. If personalisation forces it dynamic, either denormalise the data so a static variant can render first, or accept that PPR isn't the right pattern for that page.
The dynamic API leak
PPR relies on the compiler catching every dynamic API and demanding a Suspense boundary above it. Most of the time it does. But we've hit two cases where it silently propagated dynamism up the tree:
- A third-party library calling
headers()internally. An analytics helper we wrapped in a server component was reading the user-agent header. It wasn't wrapped in Suspense because we didn't know it was dynamic. The whole route quietly rendered dynamically in production. Caught it only when TTFB regressed on a routine deploy. - A shared layout using
cookies()for feature flags. Layouts are shared across routes. Onecookies()call in a layout makes every child route dynamic above its own boundaries.
The fix for both: audit your server components with a lint rule or a grep pass before enabling PPR on a route. Anything reading cookies, headers, draftMode, or performing an uncached fetch needs a boundary above it — including transitively through libraries.
# quick audit
grep -rE "(cookies|headers|draftMode)\(\)" app/ lib/ components/
Data fetching: the use cache interplay
With the 'use cache' directive (still experimental at time of writing), the story gets cleaner. You can mark a function or component as cacheable, and PPR will happily prerender it:
import { unstable_cacheLife as cacheLife } from 'next/cache';
async function getProduct(slug: string) {
'use cache';
cacheLife('hours');
const res = await fetch(`${process.env.API}/products/${slug}`);
if (!res.ok) throw new Error('Product fetch failed');
return res.json();
}
What we didn't expect: mixing 'use cache' and uncached fetches in the same server component silently makes the whole component dynamic. The cached call gives you nothing. If you want the cacheable part prerendered, factor it into its own component and keep the uncached call behind a Suspense boundary.
The revalidation story
PPR plays nicely with revalidateTag and revalidatePath. When you revalidate, the static shell gets regenerated on the next request; the dynamic holes were never cached to begin with, so they don't need touching. This is genuinely the cleanest revalidation model we've worked with — but only if you're disciplined about tagging. Untagged fetches become invisible to your invalidation code, and you'll end up rebuilding the whole route to bust one product's cache.
Observability: how do you even know PPR is working?
This is the part nobody talks about. In production, a PPR route and a dynamic route look identical from the outside. Same URL, same HTML shape, same status code. You need to instrument it yourself.
What we do:
- Log the render mode. Add a header in middleware or a debug endpoint that reports whether the response came from prerender or dynamic render. Next.js emits
x-nextjs-cacheand related headers — pipe them into your log aggregator. - Track TTFB per route, not per app. A single dynamic leak can regress one route while the app-wide average looks fine. Split your RUM data by pathname.
- Alert on Suspense fallback duration. If a dynamic hole takes 2 seconds to stream, users see the skeleton for 2 seconds. That's a UX bug even if the LCP number looks fine.
When PPR isn't the right answer
We've stopped reaching for PPR on:
- Auth-gated dashboards where nearly the entire page is per-user. There's no meaningful static shell. A traditional dynamic render with aggressive caching wins.
- Highly interactive apps (editors, canvases) where the shell is essentially a client-rendered app anyway. PPR adds config surface for no benefit.
- Routes with fewer than ~1k views per day. The engineering cost of getting the boundaries right isn't worth it below that volume. Ship dynamic, revisit later.
PPR shines on marketing pages, product detail pages, listing pages, and content pages with a personalised sidebar or price. The shape that pays off is: mostly-static content, a few user-specific holes.
Where we'd start
If you're picking this up on an existing App Router codebase:
- Pick one high-traffic route with clear static and dynamic zones. Not your dashboard. A product page or a marketing landing page.
- Grep for
cookies,headers,draftMode, and uncachedfetchcalls in the route's subtree, including shared layouts and libraries. Wrap each in Suspense at the right level. - Confirm your LCP element sits in the static shell. If it doesn't, fix that before enabling PPR.
- Turn on
experimental_pprfor that single route. Deploy to staging. Diff TTFB and LCP against the baseline. - Only then roll it out further.
PPR is one of the few Next.js features where the performance win is real and measurable — but only when the Suspense boundaries reflect an actual product decision about what users need to see first. Get that right and it's the best rendering model we've shipped on. Get it wrong and it's a slower, more complicated dynamic render. If you want a hand structuring your App Router routes for this, our web development team does this work with clients regularly.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Streaming Suspense Boundaries: Why Your LCP Got Worse After the Refactor
We wrapped everything in Suspense expecting faster pages. Instead LCP got worse. Here's what streaming actually does to Core Web Vitals, and how to place boundaries so the numbers move the right way.

The React 19 useActionState Migration That Broke Our Forms
We swapped useFormState for useActionState across a mid-sized Next.js app. Here's what silently broke, what the docs don't tell you, and the patterns we ended up standardising on.

React Server Components and the Serialization Boundary: Debugging the Errors Nobody Warned Us About
Server Components look magical until you hit a serialization wall. Here's how the boundary actually works, why 'functions cannot be passed to client components' keeps appearing, and how to design around it.
