Partial Prerendering in Production: What Breaks When Your Shell Isn't Really Static
Partial Prerendering promises a static shell with dynamic holes. In production, the line between the two is blurrier than the docs suggest. Here's what tripped us up and how we fixed it.

Partial Prerendering (PPR) sounds like the best of both worlds: a static shell served instantly from the edge, with dynamic holes streamed in as needed. On paper, it collapses the old static-vs-dynamic debate into a single mental model. In practice, the line between "static" and "dynamic" is a lot thinner than the marketing suggests, and most of the pain we've seen in production comes from assuming the shell is more static than it really is.
This is a field guide to the PPR traps we've hit shipping App Router apps on Next.js 15 with React 19 — what breaks, why, and the patterns we now default to.
How PPR actually works (the version that matters for debugging)
At build time, Next.js walks your route and renders everything it can statically. Anywhere it hits a dynamic API — cookies(), headers(), searchParams, connection(), an uncached fetch — it needs that work to be wrapped in <Suspense>. The fallback for that Suspense boundary becomes part of the static shell. The children become a dynamic hole that's streamed in on request.
Two consequences follow, and they're the root of almost every PPR bug we've debugged:
- The shell is cached per route, not per user. If a "static" component accidentally reads request-scoped data, you either get a build error or — worse — leaked data.
- Suspense boundaries are no longer just a UX tool. They are the physical boundary between what gets prerendered and what doesn't. Where you put them changes your TTFB, your LCP, and your cache hit rate.
Once you internalise that, the rest of this post is mostly corollaries.
Enabling it
// next.config.ts
import type { NextConfig } from 'next';
const config: NextConfig = {
experimental: {
ppr: 'incremental',
},
};
export default config;
We strongly recommend 'incremental' over true for any app that didn't start life with PPR. You then opt routes in with export const experimental_ppr = true so you can migrate one route tree at a time.
Gotcha 1: Your layout is more dynamic than you think
The most common failure mode: a root or section layout that quietly reads cookies() for a theme preference, a feature flag, or an auth check. The moment you enable PPR on a child route, the entire route becomes dynamic because the layout can't be prerendered.
You'll see this in next build output as routes that stubbornly show the dynamic symbol (ƒ) even after you've carefully audited the page.
The fix is to push the dynamic read down into its own component behind a Suspense boundary:
// app/(marketing)/layout.tsx
import { Suspense } from 'react';
import { ThemeProviderShell } from './theme-provider-shell';
import { UserThemeSlot } from './user-theme-slot';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<ThemeProviderShell defaultTheme="light">
<Suspense fallback={null}>
<UserThemeSlot />
</Suspense>
{children}
</ThemeProviderShell>
);
}
// app/(marketing)/user-theme-slot.tsx
import { cookies } from 'next/headers';
export async function UserThemeSlot() {
const theme = (await cookies()).get('theme')?.value ?? 'light';
// Apply via a client component that updates a context
return <script dangerouslySetInnerHTML={{ __html: `document.documentElement.dataset.theme=${JSON.stringify(theme)}` }} />;
}
The layout itself is now statically prerendered. The cookie read is isolated to a dynamic hole with a null fallback, so the shell doesn't shift.
Gotcha 2: searchParams turns your page dynamic — but not uniformly
searchParams is a Promise in Next.js 15, and awaiting it anywhere in your page triggers dynamic rendering for that subtree. If your page uses search params for a filter sidebar but the hero and footer don't care, don't let the whole page go dynamic.
// app/products/page.tsx
import { Suspense } from 'react';
import { Hero } from './hero';
import { ProductGrid } from './product-grid';
import { GridSkeleton } from './grid-skeleton';
export const experimental_ppr = true;
export default function Page({
searchParams,
}: {
searchParams: Promise<{ sort?: string; category?: string }>;
}) {
return (
<>
<Hero />
<Suspense fallback={<GridSkeleton />}>
<ProductGrid searchParams={searchParams} />
</Suspense>
</>
);
}
ProductGrid is the only thing that awaits searchParams. The hero, the nav, the metadata — all prerendered. You get a static HTML response for the shell on every URL variant, and the grid streams in.
A subtle one: don't destructure in the page
If you write const { sort } = await searchParams in the page component itself, that await happens before any Suspense boundary and the whole page becomes dynamic. Pass the unawaited Promise down and let the Suspense child await it. We've reviewed PRs where someone "cleaned up" the prop drilling and silently killed PPR for the route.
Gotcha 3: The fallback IS the shell
Suspense fallbacks under PPR aren't throwaway loading states. They're the HTML that ships in your static shell and renders before hydration. Three implications:
- A
nullfallback is a layout shift waiting to happen. If the dynamic child has real height, reserve that space in the fallback. We measure the rendered height in dev and setmin-heighton the fallback container. - Skeletons need to match real content dimensions. Not just vibes — actual pixel heights. We bake this into our design system with a
<Skeleton as="ProductCard" />style API so skeletons and real components share size tokens. - The fallback can't read request data. It's part of the static shell, so no
cookies(), noheaders(), no personalisation. If you need per-user fallbacks, you need the parent to be dynamic too, which usually defeats the point.
Gotcha 4: fetch caching changed, and PPR amplifies the mistake
In Next.js 15, fetch is uncached by default. Under PPR, this matters more than before: an uncached fetch in what you thought was a static component will quietly force dynamic rendering of its subtree.
Be explicit:
// Static — participates in the shell
const res = await fetch('https://api.example.com/categories', {
cache: 'force-cache',
next: { tags: ['categories'] },
});
// Dynamic — must be inside a Suspense boundary
const res = await fetch('https://api.example.com/user/cart', {
cache: 'no-store',
});
We lint for bare fetch( calls in server components and require an explicit cache option. It's one of the highest-ROI lint rules we've added in the last year.
Gotcha 5: Middleware and the "shell" aren't friends
Middleware runs on every request, including requests for the static shell. If your middleware sets personalised headers (geo, A/B bucket, auth state) and your shell reads them via headers(), the shell is no longer static — and if you don't realise it, you'll see cache misses you can't explain.
Two patterns we use:
- Route-level isolation. Keep PPR routes off any middleware path that mutates request-scoped state. Match middleware narrowly.
- Push personalisation into dynamic holes. The shell stays identical for everyone; the holes read the bucket and render the variant. This is also friendlier to CDN caching upstream of Next.js.
Gotcha 6: Error boundaries, not just Suspense boundaries
A dynamic hole can fail — the API behind it times out, the DB is down, auth throws. Without an error boundary, the failure bubbles up past your beautiful shell and the user sees the route-level error.tsx. The shell was for nothing.
Pair every meaningful Suspense boundary with an error boundary:
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
<ErrorBoundary fallback={<GridError />}>
<Suspense fallback={<GridSkeleton />}>
<ProductGrid searchParams={searchParams} />
</Suspense>
</ErrorBoundary>;
The shell survives. The user sees a scoped error in the hole, not a full-page crash.
What PPR is actually good for
After a year of shipping it, the routes where PPR earns its keep are:
- Marketing and content pages with a small personalised strip (cart count, logged-in nav)
- Product listing pages where the chrome is static and only the grid depends on filters
- Dashboards where the layout, nav, and empty states are static but the widgets are per-user
It's less compelling for routes that are overwhelmingly dynamic end-to-end. There, the shell is so thin that you're paying coordination cost for a marginal TTFB win. Measure before you adopt.
Where we'd start
If you're bringing PPR into an existing App Router codebase: turn on experimental.ppr: 'incremental', pick your highest-traffic marketing route, and opt it in. Then run next build and read the output carefully — the ƒ vs ○ symbols will tell you exactly where your dynamic reads are hiding. Fix them one Suspense boundary at a time, measure LCP and TTFB on real devices before and after, and only then move to the next route. If you want a hand auditing a route tree, our web development team does this kind of migration work regularly.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Font Subsetting in Next.js: How We Cut CLS to Near Zero on a Content-Heavy Site
A war story about chasing a stubborn 0.18 CLS score on a publishing site, and the font subsetting and fallback metrics work that finally got us to 0.02.
Server Actions Under Load: What We Learned Rate-Limiting Them at the Edge
Server Actions look like plain function calls, but every one is a POST to your origin. Here's what happened when ours got hammered — and the edge rate-limiting pattern we now ship by default.

The `useOptimistic` Trap: When Optimistic UI Lies to Your Users
React 19's useOptimistic hook is easy to reach for and hard to get right. Here's the pattern we settled on after shipping — and rolling back — three variants in production.
