Partial Prerendering in Production: What Actually Ships and What Doesn't
Partial Prerendering promised the best of static and dynamic. After shipping it across three production apps, here's what works, what breaks, and where the seams still show.

Partial Prerendering (PPR) has been the headline feature of Next.js for two release cycles now, and the pitch is genuinely compelling: ship a static shell instantly, stream the dynamic bits, stop choosing between force-static and force-dynamic. We've now run PPR in production on three apps — an e-commerce storefront, a B2B dashboard, and a marketing site with authenticated states. Here's the honest breakdown.
What PPR Actually Does
The mental model matters, because most of the bugs we hit came from getting it wrong.
At build time, Next.js walks your route and renders everything it can statically. When it hits a <Suspense> boundary whose children use a dynamic API — cookies(), headers(), searchParams, or an uncached fetch — it renders the fallback into the static shell and marks that subtree as a dynamic hole. At request time, the server streams the dynamic content into those holes.
The result is a single response: HTML starts flowing immediately from the edge (or CDN), and the dynamic parts arrive over the same connection as they resolve. There's no client-side waterfall, no separate API call, no loading.tsx flash on navigation.
That's the pitch. In practice, the seams matter.
The prerender is stricter than you think
The static portion of a PPR route is prerendered at build time, not at first request. That means:
- Environment variables read at module scope get baked in.
- Any
fetchwithout explicit dynamic behaviour is cached in the build. - Feature flags evaluated outside a Suspense boundary become build-time constants.
We learned this the hard way on the storefront. A promo banner that read from LaunchDarkly at the layout level got frozen to whatever value existed at build. The fix was obvious in hindsight — move the flag read inside a Suspense boundary — but the failure mode was silent. No warning, no error. Just a banner that never updated.
The Suspense Boundary Is the Contract
Under PPR, <Suspense> stops being a loading UX primitive and becomes a rendering contract. Everything outside a boundary must be statically renderable. Everything inside one becomes a dynamic hole if it touches request-time data.
Here's the pattern we settled on:
// app/product/[slug]/page.tsx
import { Suspense } from 'react';
import { ProductShell } from './product-shell';
import { PriceAndStock } from './price-and-stock';
import { Recommendations } from './recommendations';
export const experimental_ppr = true;
export default async function ProductPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const product = await getProduct(slug); // cached, safe in shell
return (
<ProductShell product={product}>
<Suspense fallback={<PriceSkeleton />}>
<PriceAndStock slug={slug} />
</Suspense>
<Suspense fallback={<RecoSkeleton />}>
<Recommendations slug={slug} />
</Suspense>
</ProductShell>
);
}
getProduct is cached and returns the same shape for every request, so it renders into the static shell. PriceAndStock reads live inventory (uncached fetch), so it becomes a dynamic hole. Recommendations reads a user cookie, so it's also dynamic.
The shell — layout, product images, description, breadcrumbs — is served instantly from the CDN. The dynamic holes stream in.
Params are dynamic. Sort of.
In Next.js 15+, params and searchParams are Promises. Awaiting params inside a component that renders into the static shell works because the route is prerendered per generateStaticParams entry — each slug gets its own prerender. searchParams, however, forces the entire route to opt out of the shell unless you access it inside a Suspense boundary.
This is the single biggest footgun we've seen. A ?ref= tracking parameter, read at the page level to pass to analytics, silently disables PPR for the whole route. You get no build warning. You just lose the static shell benefit and don't notice until you check x-nextjs-prerender headers.
Rule of thumb: if you access
searchParams, do it inside a Suspense boundary, and treat that subtree as fully dynamic.
Caching Under PPR Gets Weird
The interaction between PPR and the Next.js Data Cache is where we spent the most debugging time.
A fetch inside a dynamic hole is not cached by default in Next.js 15, matching the general default. But you can opt in with cache: 'force-cache' or a revalidate value — and when you do, you're essentially saying "this dynamic hole should still hit the data cache." That works, but it introduces a subtle question: if all the data in a dynamic hole is cached, is the hole actually dynamic?
The answer is yes — the render still happens per request, even if every fetch returns from cache. This means you pay compute cost for what could have been static. On the B2B dashboard, we had a widget that hit three cached endpoints inside a Suspense boundary. Because one of them read a cookie for tenant scoping, the whole subtree was dynamic. Moving the cookie read up and passing the tenant ID as a prop let us serve the widget statically per tenant slug via generateStaticParams.
The revalidate footgun
revalidateTag and revalidatePath work under PPR, but the semantics changed. Revalidating a tag invalidates the cached fetch responses. It does not rebuild the static shell. If your shell depends on tagged data (via a cached fetch outside any Suspense boundary), you need to rebuild the route entry, not just revalidate the tag.
We ended up with two mental buckets:
- Shell data — content that changes on publish (product copy, page structure). Rebuild via ISR or on-demand revalidation of the path.
- Hole data — content that changes per request or per user (prices, stock, personalization). Cache with tags, revalidate with
revalidateTag.
Mixing the two in the same subtree is where things go sideways.
Performance: What We Actually Measured
We won't quote benchmarks we can't reproduce, but the pattern across our three apps was consistent:
- TTFB on shell-heavy routes dropped noticeably — the static shell serves from the CDN edge cache, so it's basically instant.
- LCP improved when the LCP element was in the static shell. When we accidentally moved a hero image behind a Suspense boundary (because it read a user preference for the variant), LCP got worse than the pre-PPR baseline. The fallback rendered first, then swapped.
- CLS got worse before it got better. Suspense fallbacks that didn't reserve exact space caused layout shifts when dynamic content streamed in. We now treat fallback sizing as a hard requirement, not a nice-to-have.
- INP was largely unaffected. PPR is a rendering strategy; it doesn't change your client-side JavaScript cost.
The headline: PPR helps when your LCP is in the shell and your fallbacks are correctly sized. It can hurt otherwise.
When We Chose Not to Use PPR
PPR isn't free. It adds mental overhead, it constrains where you can put dynamic code, and it makes debugging streaming issues harder because everything looks like a hydration mismatch until it doesn't.
We skipped PPR on:
- Fully authenticated dashboards where every route needs the user's session before rendering anything. The shell has nothing useful to prerender — it's all chrome around dynamic content.
force-dynamicwas simpler and had equivalent perceived performance once we got streaming right. - Admin CRUD pages with heavy form state. The interactive parts dominate; a static shell adds no value.
- Routes with truly dynamic layouts — where the sidebar, header, or navigation depends on the user. PPR works best when the shell is meaningful to an anonymous visitor.
For a rough decision framework:
| Route type | PPR? |
|---|---|
| Marketing / content pages | Yes |
| Product / listing pages | Yes, with careful boundaries |
| Authenticated dashboard | Usually no |
| Admin tools | No |
| Checkout / forms | Case by case |
Debugging the Seams
A few things that saved us hours:
- Check the
x-nextjs-prerenderandx-nextjs-cacheresponse headers on every route in staging. If PPR is silently off, they'll tell you. - Use
next buildoutput religiously. Routes marked with the Partial Prerendering symbol (◐) confirm the shell was built. - When a route unexpectedly goes fully dynamic, bisect: comment out Suspense boundaries one at a time and check the build output.
- Log inside
generateStaticParamsand confirm what actually gets prerendered. The build sometimes silently skips entries when data fetches fail.
Where we'd start
If you're considering PPR on an existing App Router app, don't flip it on globally. Enable it per route with export const experimental_ppr = true, starting with your most visited content pages. Audit every fetch, every cookie read, every searchParams access, and decide consciously whether it belongs in the shell or a hole. Size your Suspense fallbacks to the exact dimensions of their real content. Then check the headers and the build output before you ship. If the tradeoff of that audit work outweighs the perceived-performance gain for a given route, skip PPR there — plain streaming or force-dynamic is often the honest answer. If you'd like a hand auditing a Next.js app for this kind of work, that's the sort of thing we do on our web engineering engagements.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Route Handlers vs Server Actions for Public APIs: Pick the Right Door
Server actions look tempting for every mutation, but they're the wrong front door for public APIs. Here's the rule we use in production, and the failure modes that taught us the difference.
Server Actions at Scale: Why We Stopped Using Them for Everything
Server Actions in Next.js feel like magic until your app grows up. Here's where they shine, where they hurt, and the rules we now apply before reaching for them in production.
useOptimistic in Anger: Why Our Optimistic UI Felt Worse Than No UI
React 19's useOptimistic looks like a free win until you ship it. Here's what broke for us in production — race conditions, ghost rows, and reconciliation traps — and the patterns we now use instead.
