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.

Abhishek
Full-Stack & AI Product Engineer
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.
Related Articles
View All Articles ↗Next.js Server Components + TanStack Query: Fixing Double-Fetches and Cache Drift
How to pair Next.js App Router Server Components with TanStack Query v5 without double-fetching, leaking data across requests, or serving stale caches after mutations.
Stop Double-Fetching in Next.js App Router with TanStack Query
How to prefetch on the server, hydrate on the client, and keep your cache in sync after Server Actions — without the double-fetch penalty or leaking data across requests.
Module Federation Between Vite and Next.js Without Breaking SSR
How to wire React Vite microfrontends into a Next.js host with Module Federation — avoiding duplicate React crashes, hydration mismatches, and shared dependency conflicts.