Next.js Partial Prerendering in Production: The Suspense and Cache Traps Nobody Warns You About
A practical look at Next.js Partial Prerendering — how to structure Suspense boundaries correctly, why cookies() can silently kill your static shell, and how to stop CDNs from leaking one user's data to another.
Next.js Partial Prerendering in Production: The Suspense and Cache Traps Nobody Warns You About
For years, Next.js developers had to pick a side. Static Site Generation gave you a fast TTFB and cheap CDN caching, but no per-user data without a client-side fetch waterfall. Server-Side Rendering gave you real personalization, but every request paid full server compute — no exceptions.
Partial Prerendering doesn't split the difference. It runs both models on the same page at once: a static shell gets built and cached at the edge, and the personalized bits stream in afterward through React Suspense, over a single connection. Nav bar, footer, marketing sections — prebuilt. Cart total, "hi Abhishek," live inventory count — streamed.
It's a genuinely good idea. It's also easy to wire up wrong in ways that don't show up until you're in production with real traffic, which is the part most PPR writeups skip.
What PPR actually changes in your component tree
The rule is simple to state and easy to violate: every dynamic data read has to live inside a component wrapped in <Suspense>. Not near it. Inside it.
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { StaticSidebar } from '@/components/StaticSidebar';
import { StaticHeader } from '@/components/StaticHeader';
import { DynamicUserMetrics } from '@/components/DynamicUserMetrics';
import { DynamicMetricsSkeleton } from '@/components/skeletons';
export const experimental_ppr = true;
export default function DashboardPage() {
return (
<div className="flex h-screen bg-slate-950 text-white">
<StaticSidebar />
<main className="flex-1 flex flex-col">
<StaticHeader title="Workspace Overview" />
<div className="p-6">
<Suspense fallback={<DynamicMetricsSkeleton />}>
<DynamicUserMetrics />
</Suspense>
</div>
</main>
</div>
);
}
StaticSidebar and StaticHeader get prerendered once at build time and served straight from the edge. DynamicUserMetrics streams in on request. The page file itself touches nothing dynamic — no cookies, no headers, no searchParams — so it stays eligible for prerendering.
The dynamic read has to happen down in the leaf:
// components/DynamicUserMetrics.tsx
import { cookies } from 'next/headers';
import { fetchUserMetrics } from '@/lib/api';
export async function DynamicUserMetrics() {
const cookieStore = await cookies();
const sessionToken = cookieStore.get('auth_session')?.value;
if (!sessionToken) {
return <div className="text-amber-400">Please sign in to view analytics.</div>;
}
const metrics = await fetchUserMetrics(sessionToken);
return (
<div className="grid grid-cols-3 gap-4">
<div className="p-4 bg-slate-900 rounded-lg border border-slate-800">
<p className="text-sm text-slate-400">Active API Requests</p>
<p className="text-2xl font-bold">{metrics.activeRequests.toLocaleString()}</p>
</div>
<div className="p-4 bg-slate-900 rounded-lg border border-slate-800">
<p className="text-sm text-slate-400">Monthly Usage</p>
<p className="text-2xl font-bold">${metrics.monthlySpend.toFixed(2)}</p>
</div>
<div className="p-4 bg-slate-900 rounded-lg border border-slate-800">
<p className="text-sm text-slate-400">Success Rate</p>
<p className="text-2xl font-bold">{metrics.successRate}%</p>
</div>
</div>
);
}
That's the whole trick. cookies() inside DynamicUserMetrics only marks that component dynamic — not the page around it.
The bailout nobody notices until it's live
Here's the failure mode that actually bites teams: someone reads cookies() or searchParams one level too high — in the page component, or in a shared layout — because it felt more convenient than threading it down. Locally, on next dev, everything renders correctly. There's no visible error.
In production, that single misplaced read forces the entire route to opt out of static prerendering. Your carefully cached shell disappears, every request falls back to full server rendering, and your TTFB quietly regresses from ~25ms to 200-800ms range typical of pure SSR. Nobody gets paged for this. You just find it three weeks later staring at a Vercel analytics graph wondering why TTFB crept up.
If you're migrating an existing route to PPR, grep for cookies(), headers(), and searchParams across every file in that route segment first. Any hit outside a Suspense-wrapped leaf component needs to move before you flip experimental_ppr on.
Cache poisoning is the scarier bug
Hydration mismatches (React error #418, layout shift when the static shell and the streamed-in client state disagree) are annoying but visible — you'll see it in the console. The one that should actually worry you is edge cache poisoning, because it fails silently and it's a data leak, not a rendering glitch.
Here's the shape of it: your static shell is cached at the CDN. The dynamic chunk streams in per-request. If your edge proxy doesn't vary that cache by session, user B can end up served a cached response that was built for user A's cookie — their cart, their dashboard numbers, their session-specific data, served to a stranger.
Two things fix this:
- Set
Vary: Cookie, RSCon your edge/CDN layer. Cloudflare, Vercel's edge network, and CloudFront all respect this — without it, they have no signal that the streamed portion depends on the request's cookies, and they'll happily serve a cached response across sessions. - Size your skeletons to match the real content. Not for correctness — for CLS. A skeleton that's a different height than the metrics grid it resolves into causes layout shift the moment the stream lands, which Core Web Vitals will ding you for even though the data itself was correct.
I'd treat the Vary header as non-negotiable before shipping PPR behind any CDN. It's a one-line config change against a genuine cross-user data leak — there's no version of "we'll add it later" that makes sense here.
Where PPR actually lands, compared to what you had before
| Strategy | TTFB | Personalization | Build time cost | Edge CDN fit |
|---|---|---|---|---|
| Pure SSG | ~20ms | None — needs a client fetch | High, prerenders every path | Fully cacheable |
| Pure SSR | 200–800ms | Full, server-rendered | Low, nothing prebuilt | Effectively uncacheable |
| PPR | ~25ms | Full, streamed | Minimal, shell only | Static shell + dynamic stream |
The build-time tradeoff is the part people underrate. PPR only prerenders the shell, not every possible path the way full SSG does, so you get SSG-level build costs without SSG's "regenerate everything" tax when content changes.
The actual checklist before you flip the flag
If you're adding experimental_ppr = true to a route this week, do this first:
- Confirm no dynamic read (
cookies(),headers(),searchParams) exists outside a<Suspense>-wrapped leaf. - Size every fallback skeleton to match its resolved content's dimensions, not just "a spinner."
- Set
Vary: Cookie, RSCat your CDN/edge layer if you're behind Cloudflare, Vercel Edge, or CloudFront. - Re-test in a production-like environment, not just
next dev— the bailout behavior doesn't reproduce identically locally.
Get those four right and PPR delivers what it promises: near-static TTFB with real per-user data, on the same route, without you having to choose.
Accelerate your Frontend & Fullstack Development Modernization Roadmap
Need custom architecture auditing, automated OpenAPI contract generation, or zero-downtime microservice migration guidance for your engineering team?
Frequently Asked Questions
Does adding a Suspense boundary automatically make a route eligible for Partial Prerendering?
No. The boundary has to actually wrap the component that reads dynamic data (cookies, headers, searchParams). If that read happens in a parent, layout, or page file outside any Suspense boundary, the whole route bails out to fully dynamic rendering — the Suspense tag alone does nothing.
What's the single most common mistake teams make when adopting PPR?
Reading cookies() or headers() too high in the component tree — in the page or layout file instead of deep inside a leaf component. It renders fine locally and then quietly disables prerendering for the entire route in production.
Subscribe to RenovateAPI
Get weekly architectural guides, API refactoring strategies, and technical SEO updates delivered directly to your inbox.
Discussion (2)
Extremely helpful breakdown of the Strangler Fig pattern! We're currently refactoring a legacy Java monolith at work and the OpenAPI gateway routing tips saved us weeks of experimentation.
The schema JSON-LD and FAQ block structure really helps with indexing. Great technical detail on entity mentions too.
Suggested Related Articles
Self-Hosted ComfyUI vs Replicate: What Actually Breaks in Production
A cost and latency comparison of self-hosted headless ComfyUI against managed APIs like Replicate for Flux and SDXL image generation in web apps.
Custom Slash Commands and Subagents for Multi-File Refactors in Claude Code
A single prompt can't safely refactor a multi-file codebase. Here's how repo-scoped slash commands and staged subagents in Claude Code fix that, with git safety gates included.
Why Your NestJS API Slows Down Under Load (And It's Probably Not the Database)
A field guide to diagnosing V8 memory leaks and event loop lag in high-throughput NestJS APIs — measuring lag with perf_hooks, killing RxJS subscription leaks, and offloading CPU work to worker threads.