Partial Prerendering in Production: What Actually Ships and What Still Bites
Partial Prerendering promises a static shell with dynamic holes streamed in. Here's what that means for your Core Web Vitals, your cache bill, and the bugs you'll only see under real traffic.
Partial Prerendering (PPR) has been the headline Next.js feature long enough that most teams have opinions before they've shipped it. We've now run it on a few production apps — commerce, a logged-in dashboard, a content site — and the summary is: the mental model is right, the defaults are mostly fine, and the failure modes are subtle enough to burn a Friday if you don't know what to look for.
This is the field guide we wish we'd had.
What PPR actually does at request time
The pitch is simple: at build time, Next.js prerenders a static shell of your route. Anything wrapped in <Suspense> with a dynamic data source becomes a hole. At request time, the CDN serves the shell immediately and streams the holes as the server resolves them.
What this means in practice:
- Your TTFB is effectively CDN latency, because the shell is static.
- Your LCP depends entirely on whether the LCP element is in the shell or in a hole.
- Your INP is unaffected by PPR directly, but the streaming interleaves with hydration in ways that can make it worse if you're careless.
The interesting part is that PPR is not "static or dynamic per route" anymore. It's per-component, decided by where you draw Suspense boundaries and where you touch dynamic APIs like cookies(), headers(), or searchParams.
The rule that trips people up
Any dynamic API call outside a Suspense boundary opts the entire route back into full dynamic rendering. PPR silently degrades to SSR for that page. You won't get a build error. You'll get a slower page and a confused Vercel dashboard.
We've seen this happen three ways:
- A shared layout reads
cookies()for a feature flag and forgets to wrap the consumer in Suspense. - An analytics helper imported into the root layout calls
headers()to grab the user agent. - A
searchParamsprop is destructured at the top of the page component instead of inside a child.
All three look innocent in code review. All three quietly kill PPR.
Drawing the boundaries: a real example
Here's a product page. Header, hero image, price, reviews, recommendations. The header depends on the session, reviews come from a slow third party, recommendations are personalized.
// app/products/[slug]/page.tsx
import { Suspense } from 'react';
import { ProductHero } from './product-hero';
import { PriceBlock } from './price-block';
import { Reviews, ReviewsSkeleton } from './reviews';
import { Recommendations, RecsSkeleton } from './recommendations';
import { SessionHeader, HeaderSkeleton } from './session-header';
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, static
return (
<>
<Suspense fallback={<HeaderSkeleton />}>
<SessionHeader />
</Suspense>
<ProductHero product={product} />
<PriceBlock product={product} />
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={product.id} />
</Suspense>
<Suspense fallback={<RecsSkeleton />}>
<Recommendations productId={product.id} />
</Suspense>
</>
);
}
The hero image and price are in the static shell, which is exactly what you want for LCP. The session header, reviews, and recommendations stream in. If getProduct is cached (via unstable_cache or a fetch with revalidate), the shell is fully static.
Note what's not in the file: no cookies(), no headers(), no direct searchParams access at the top level. Anything dynamic is pushed down into components that are already inside Suspense.
Skeletons are part of the contract
The fallback isn't a nice-to-have. It's what the user sees for the first paint. Two rules we enforce in code review:
- Fallbacks must match the final layout's dimensions. If the reviews block is 480px tall, the skeleton is 480px tall. Otherwise you've built a CLS generator.
- Fallbacks must be pure — no
useEffect, no client state. They render once, in the shell.
The cache interaction nobody explains clearly
PPR sits on top of the same data cache and full route cache you already know. The shell is cached at build time and revalidated by the usual mechanisms — revalidateTag, revalidatePath, revalidate timings on fetches. The dynamic holes are not cached by default; they run on every request.
This is where costs surprise people. If your dynamic hole hits a slow database on every page load, PPR doesn't save you — it just makes the shell feel fast while your server bill climbs.
What works well:
- Cache the dynamic hole's data with a short TTL and a tag, if the data is user-agnostic (e.g. "trending products").
- For per-user data, use a per-user cache key with
unstable_cacheand a sensible TTL. Even 30 seconds destroys 90% of the load from a hot user hitting refresh. - Keep truly per-request work (session validation, A/B assignment) fast — under 50ms server-side — because it's on the critical path for the hole.
A pattern we like
import { unstable_cache } from 'next/cache';
import { cookies } from 'next/headers';
async function getRecommendations(productId: string, userId: string) {
return unstable_cache(
async () => fetchRecs(productId, userId),
['recs', productId, userId],
{ revalidate: 60, tags: [`recs:${userId}`] },
)();
}
export async function Recommendations({ productId }: { productId: string }) {
const cookieStore = await cookies();
const userId = cookieStore.get('uid')?.value ?? 'anon';
const recs = await getRecommendations(productId, userId);
return <RecsList items={recs} />;
}
The cookies() call is inside the Suspense boundary, so it doesn't opt the route out of PPR. The per-user cache absorbs bursts.
The three production gotchas we've actually hit
1. Middleware rewrites break the shell cache
If your middleware rewrites based on geo, locale, or an A/B cookie, every variant needs its own cached shell. Without care you either serve the wrong variant to the wrong user, or you fragment the cache so heavily that hit rates collapse.
What we do: keep the number of shell variants explicit and small. Locale is fine. Country is usually fine. Per-user cohorts belong in a dynamic hole, not in a rewrite.
2. Auth redirects need to happen before the shell
A logged-out user hitting a logged-in page should get a redirect, not a shell with a broken hole. Do this in middleware, not in the page. If you redirect() from a server component inside a Suspense boundary, the user has already seen the shell for a beat — jarring, and it makes protected pages feel slow.
3. Streaming and third-party scripts fight
Analytics and tag managers loaded via next/script with strategy="afterInteractive" will start executing while holes are still streaming. If those scripts trigger layout — think consent banners that push content down — you get CLS on a page that looked fine in Lighthouse. Use strategy="lazyOnload" for anything visual, and reserve space for any injected UI.
When PPR is not the right tool
We don't reach for PPR on:
- Fully authenticated dashboards where nothing is safely cacheable. The shell has no meaningful static content, so you're paying complexity for no LCP win. Standard SSR is fine.
- Highly personalized landing pages where even the hero varies per visitor. Same reason.
- Routes with fewer than a few thousand daily views. The build-time cost of prerendering the shell isn't worth the ops overhead.
PPR shines on catalog pages, article pages, marketing sites with authenticated headers, and search results where the query is in a hole and the chrome is static.
Where we'd start
If you're introducing PPR to an existing App Router app, don't flip it on globally. Pick one high-traffic route — a product page, an article, a listing — and enable experimental_ppr on that route only. Instrument LCP and TTFB before and after with real user monitoring, not just Lighthouse. Then look at your server cost per thousand requests; that's the number that tells you whether your Suspense boundaries and cache TTLs are drawn in the right places.
Get one route right, codify the pattern in a review checklist, then roll it out. If you'd like a second pair of eyes on your App Router architecture, our web development team does this work day in and day out.
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 Mutations: When Each One Actually Wins
Server Actions look like the obvious choice for every mutation in the App Router. They aren't. Here's how we decide between actions and route handlers on real projects, with the failure modes we've hit.
Cache Tags in the App Router: The Invalidation Story That Finally Works
Cache tags in Next.js finally give us a real invalidation model, but they punish sloppy tagging with stale data and surprise revalidations. Here's how we tag, invalidate, and debug them in production.
Streaming SSR and Suspense Boundaries: Where to Draw the Line
Streaming SSR is not a free win. Put Suspense in the wrong place and you'll ship a page that feels slower than the blocking version. Here's how we decide where the boundaries go.
