Partial Prerendering in Production: What Breaks When You Ship PPR to a Real App
PPR promises the static shell of a marketing page with the dynamic guts of an app. Here's what actually happens when you flip the flag on a codebase that wasn't designed for it.
Partial Prerendering (PPR) is the most interesting thing Next.js has shipped since the App Router itself, and also the feature most likely to make your production build quietly change behaviour. We turned it on across a mid-sized SaaS dashboard last quarter. Here's what we learned, including the parts the docs don't warn you about.
What PPR actually does (in one paragraph)
PPR lets a single route emit a static HTML shell at build time and stream dynamic holes into it at request time. Anything wrapped in <Suspense> becomes a dynamic hole with a prerendered fallback; everything outside is baked. The response starts flushing immediately with the static shell, then the dynamic parts stream in over the same HTTP connection. From the user's perspective, LCP looks like a static site; from the engineer's perspective, you still get per-request personalization.
The mental model that finally clicked for us: PPR is not SSR with caching. It's SSG with holes. That reframing matters because it changes how you think about every API that touches the request.
The migration isn't a flag flip
The official story is that you enable PPR in next.config.ts and opt routes in with a segment config:
// next.config.ts
import type { NextConfig } from 'next';
const config: NextConfig = {
experimental: {
ppr: 'incremental',
},
};
export default config;
// app/dashboard/page.tsx
export const experimental_ppr = true;
That part works. What doesn't work is the assumption that your existing route will behave the same way afterwards. In our codebase, roughly 40% of routes threw build-time errors on the first try, and another 20% built successfully but served stale data in subtle ways.
The build-time errors are the easy ones
Any call to cookies(), headers(), draftMode(), or searchParams that lives outside a Suspense boundary will fail the prerender. The error is clear:
Error: Route /dashboard used `cookies` inside a component that was
statically generated. Switch to dynamic rendering or wrap it in Suspense.
The fix is mechanical but tedious: move the dynamic read into a child component, wrap that child in <Suspense>, and give it a fallback that matches the static shell's dimensions.
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { UserMenu, UserMenuSkeleton } from './user-menu';
export const experimental_ppr = true;
export default function DashboardPage() {
return (
<div className="grid grid-cols-[240px_1fr]">
<aside>{/* static nav */}</aside>
<main>
<header className="flex justify-end p-4">
<Suspense fallback={<UserMenuSkeleton />}>
<UserMenu />
</Suspense>
</header>
{/* ...rest of static shell */}
</main>
</div>
);
}
// app/dashboard/user-menu.tsx
import { cookies } from 'next/headers';
import { getUserFromSession } from '@/lib/auth';
export async function UserMenu() {
const cookieStore = await cookies();
const session = cookieStore.get('session')?.value;
const user = await getUserFromSession(session);
return <button>{user?.name ?? 'Sign in'}</button>;
}
export function UserMenuSkeleton() {
return <div className="h-9 w-24 rounded bg-neutral-200 animate-pulse" />;
}
Two non-obvious rules we learned the hard way:
- The skeleton must occupy the same box as the real content, or you'll tank CLS the moment you "fixed" LCP.
cookies()andheaders()are async in recent Next.js versions. If you forget theawait, TypeScript will complain, but if you're on an older types package you'll just get a Promise where you expected a value and silentundefinedreads downstream.
The silent staleness problem
The routes that build successfully but behave wrong are more painful. Here's the pattern that bit us:
// Before PPR — worked fine
export default async function BillingPage() {
const org = await getCurrentOrg(); // reads cookies internally
const invoices = await getInvoices(org.id);
return <InvoiceTable invoices={invoices} data-org={org.name} />;
}
Under dynamic SSR, getCurrentOrg() would read the session cookie on every request. Under PPR, if nothing in the render tree explicitly touches a dynamic API, Next.js is free to prerender the whole thing. In our case, getCurrentOrg() was using a wrapped helper that called cookies() inside a try/catch that swallowed the error at build time and returned a fallback org. The page prerendered with the wrong tenant's branding and shipped to every user who hit it in the first 60 seconds after deploy.
The lesson: PPR punishes any code path that hides its dynamism. Audit every utility that reads request state and make sure the dynamic call is reachable at prerender time. If it is, the prerender fails loudly — which is what you want. If a try/catch, a .catch(() => defaultValue), or a type-cast swallows it, you ship a static page with fake data.
A quick audit script
We wrote a one-off grep that catches the worst offenders. It's not comprehensive, but it found 11 real bugs in our codebase:
# Find request-coupled calls wrapped in try/catch or .catch()
rg -n --multiline 'try\s*\{[\s\S]{0,400}(cookies|headers|draftMode)\(\)' src
rg -n '(cookies|headers)\(\)[\s\S]{0,80}\.catch\(' src
For anything that matches, decide: should this really be swallowed, or does swallowing it mean the page silently serves wrong data under PPR?
Suspense boundaries are now a design decision
Before PPR, Suspense boundaries were mostly a UX choice — where do you want a loading state? Under PPR they're an architectural one. Every boundary is literally the seam between "static HTML in the CDN" and "streamed from the origin". That changes how we draw them:
- Push boundaries down the tree, not up. A single
<Suspense>at the page root defeats the point; you've just reinvented SSR with a fallback. We aim for boundaries at the component level — one per widget that reads request state. - Group dynamic reads that share a request. If three sibling components all call
getCurrentUser(), put them under one Suspense boundary so React's request memoization can dedupe. Separate boundaries can still dedupe within a render, but they fragment the fallback UI unnecessarily. - Treat the static shell as a design artifact. Our designers now get a Figma frame explicitly labelled "what every user sees in the first 100ms". It's the layout plus skeletons. If a skeleton looks nothing like the final content, that's a design bug.
fetch caching gets weirder, not simpler
PPR interacts with the Next.js fetch cache in ways that are easy to misread. Three rules we now follow:
- A
fetchwith no cache directive inside a static region is treated as cacheable. If the upstream is actually dynamic, you needcache: 'no-store'or you need to be inside a Suspense boundary. fetch(..., { next: { revalidate: 60 } })inside a dynamic hole still revalidates on its own schedule, independent of the shell's build.- The static shell is only rebuilt when you redeploy or when an ISR revalidation fires for that route. If your "static" nav includes a feature-flagged link, flipping the flag won't update prerendered pages until something invalidates them. We now tag the shell's data sources and
revalidateTagon flag changes.
Measuring whether it actually helped
We shipped PPR expecting LCP wins. We got them — but not evenly. On marketing-adjacent routes (pricing, docs landing, logged-out dashboards) LCP improved substantially because the hero was now in the static shell. On deep app routes, the win was smaller because the LCP element was inside a Suspense boundary and still waited on the origin.
The fix was to promote the LCP element out of the dynamic hole whenever possible. Sometimes that meant rendering a generic headline statically and swapping in the personalised version after hydration. Sometimes it meant restructuring the component so the image or heading could render without request data, and only the metadata below it was dynamic. In our experience, this per-route LCP audit is where most of the real gains hide.
Track these before and after you enable PPR:
- TTFB (should drop sharply — you're serving the shell from the edge)
- LCP (should drop, but only if your LCP element is in the shell)
- CLS (watch this — bad skeletons will regress it)
- Origin request count (should drop; prerendered shells don't hit your server)
Where we'd start
If you're considering PPR on an existing App Router codebase, don't flip it on globally. Pick one route that's mostly static with a small dynamic header — a pricing page with a logged-in state indicator is ideal — and enable experimental_ppr on just that segment. Measure. Then audit your request-state helpers for swallowed errors before you expand. The feature is genuinely good, but it rewards codebases that are honest about which bytes depend on the request and which don't.
If you want a second pair of eyes on a migration, our team does this kind of work regularly — see our web development services or browse more App Router field notes on the blog.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

The `useOptimistic` Rollback Problem: When Server Actions Fail Silently
React 19's useOptimistic is magic until a server action throws and your UI shows the wrong state. Here's how we debugged a silent rollback bug in production and the pattern we now use.

Server Actions Under Load: Why Our Form Submits Queued Behind a Slow Dashboard
A production war story about Next.js server actions, implicit route serialization, and the invisible queue that turned a 200ms form submit into a 4-second wait during peak hours.

The `revalidateTag` Trap: When Next.js Mutations Don't Actually Update Your UI
A server action returns success, the tag gets revalidated, and the UI still shows yesterday's data. Here's why `revalidateTag` lies to you under certain App Router patterns — and how we fixed it.
