All articles
Web DevelopmentJune 6, 2026 7 min read

Partial Prerendering in Production: What Breaks When You Turn It On

Partial Prerendering looked like free performance on the demo slide. Then we shipped it. Here's what actually breaks when you flip the flag on a real Next.js app — and how we'd roll it out now.

Partial Prerendering in Production: What Breaks When You Turn It On

Partial Prerendering (PPR) is the feature that finally makes the "static shell + dynamic holes" pitch real in Next.js. It is also the feature most likely to make a senior engineer file a confused bug report at 2 a.m. on launch night. We've shipped it across a few production App Router codebases now, and the gap between the demo and the reality is wide enough to deserve its own field report.

This isn't a "what is PPR" post — the docs cover that. This is what breaks, what surprises you, and how we'd roll it out today.

The mental model that actually works

The official explainer talks about a static shell with dynamic holes. That's accurate but misleading. The model that helped our team stop making mistakes is this: every route now has two render passes that must both succeed. The first is a build-time render that walks your tree until it hits a dynamic API or a Suspense boundary wrapping one. The second is the runtime render that fills the holes.

If the build-time pass throws — or worse, succeeds when it shouldn't — you get incorrect HTML cached at the edge until the next deploy. There is no soft failure mode. That's the part nobody warns you about.

What counts as "dynamic"

In a PPR route, any of the following triggers a dynamic hole and must sit inside a Suspense boundary:

  • cookies(), headers(), draftMode() from next/headers
  • searchParams on a page
  • fetch() with cache: 'no-store' or a revalidate: 0
  • unstable_noStore() / connection() (depending on your version)
  • Anything reading Date.now() inside a server component that you care about

The last one is the spicy one. We'll come back to it.

Gotcha #1: The Suspense boundary you forgot is the layout

This is the most common bug we see. Engineers wrap their dynamic component in <Suspense> in the page, then wonder why the whole route still renders dynamically.

// app/(shop)/layout.tsx
import { cookies } from 'next/headers';
import { CartBadge } from '@/components/cart-badge';

export default async function ShopLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const cookieStore = await cookies();
  const cartId = cookieStore.get('cart_id')?.value;

  return (
    <>
      <Header>
        <CartBadge cartId={cartId} />
      </Header>
      {children}
    </>
  );
}

That await cookies() in the layout poisons every route underneath it. PPR walks bottom-up looking for the nearest Suspense boundary, and the layout is above your page's boundaries. Result: nothing prerenders.

The fix is to push the dynamic read down into the component that needs it, behind its own boundary:

// app/(shop)/layout.tsx
import { Suspense } from 'react';
import { CartBadge } from '@/components/cart-badge';

export default function ShopLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <>
      <Header>
        <Suspense fallback={<CartBadgeSkeleton />}>
          <CartBadge />
        </Suspense>
      </Header>
      {children}
    </>
  );
}

// components/cart-badge.tsx
import { cookies } from 'next/headers';

export async function CartBadge() {
  const cartId = (await cookies()).get('cart_id')?.value;
  // ...
}

The layout itself is now fully static. The badge is a dynamic hole.

How to actually verify

Run next build and read the route table. Routes marked with the partial-prerender symbol (a hybrid icon, depending on your CLI version) are correctly hybrid. Routes marked purely dynamic (ƒ) are not benefiting from PPR even if you turned it on. We've seen teams ship for weeks thinking PPR was active because the config flag was set.

Gotcha #2: headers() in your auth helper

If you have a shared getSession() helper imported across the app — and you do, everyone does — it almost certainly calls headers() or cookies(). The moment any server component above a Suspense boundary calls getSession(), that subtree goes dynamic.

This cascades fast. A <Nav> that calls getSession() to decide whether to show the login button will pull your entire layout into the dynamic pass. You won't notice until you check build output.

What worked for us:

  1. Split getSession() into getSessionStrict() (throws if missing, async, uses cookies) and getSessionOptional() that takes the cookie value as an argument.
  2. Move auth-dependent UI into leaf components wrapped in Suspense.
  3. Render an optimistic logged-out shell, then swap in the real state.

The last one is uncomfortable for product folks because there's a visible flicker on slow networks. The honest answer is: PPR is a performance feature, and that flicker is the trade. If product can't accept it for the navbar, exclude the navbar from PPR by leaving it dynamic in the layout — but then accept that the route can't be hybrid.

Gotcha #3: Caches that don't survive the prerender

The build-time pass runs in a different environment than runtime. Your in-memory caches (module-level Maps, React.cache results, anything memoized in a singleton) are populated at build, serialized into the prerender, and then empty at runtime.

This breaks anything that assumed warm caches. Specifically: a feature flag client that lazily fetches on first call will fetch during build (using whatever env vars are present in CI) and then bake those flag values into the static shell. Forever. Until your next deploy.

We got bit by this with a LaunchDarkly-style flag. The static shell shipped with newCheckout: false because that's what the build environment saw. Users with the flag enabled at runtime saw the dynamic hole load correctly, but the surrounding shell was wrong.

The rule we now enforce: anything that varies per-request must live inside a Suspense boundary, even if it feels static. Feature flags, A/B assignments, geolocation banners, currency formatting — all of it.

Gotcha #4: generateMetadata is its own beast

generateMetadata runs in the static pass when possible. If it calls a dynamic API, the whole page falls back to dynamic. We've seen teams accidentally pull in headers() for analytics in generateMetadata and lose PPR on every route.

If you need request-scoped metadata (canonical URLs based on hostname, for example), accept that those routes can't be hybrid and mark them explicitly dynamic. Don't fight it.

Gotcha #5: Error boundaries and the prerender pass

If a server component throws during the build-time pass, the build fails. Loud, obvious, fine.

If it throws inside a Suspense boundary, PPR catches it and the runtime pass takes over for that hole. Also fine — that's the design.

But: if your dynamic component depends on a downstream service that's down at build time (a third-party API, an internal microservice during a deploy), the prerender pass will fail the build even though that component is inside a Suspense boundary, because the build pass tries to render it first to see if it can.

The escape hatch is connection() (or the older unstable_noStore()) at the top of the dynamic component, which tells the prerender pass to skip it entirely:

import { connection } from 'next/server';

export async function PersonalizedFeed() {
  await connection(); // bail out of static rendering immediately
  const data = await fetch('https://api.example.com/feed', {
    cache: 'no-store',
  });
  // ...
}

This is also how you protect yourself against the feature-flag-baked-into-shell problem above when you can't restructure the tree.

What the wins actually look like

With PPR working correctly on a content-heavy marketing route, our TTFB drops to whatever the CDN serves it in — single-digit milliseconds at the edge. LCP improvements depend entirely on what's in the static shell vs. the holes. In our experience, moving the above-the-fold hero into the static shell and pushing personalization below the fold yields the biggest measurable LCP gains. Routes where the hero itself is personalized see smaller gains, sometimes none.

The routes that benefit least: authenticated dashboards where almost everything is per-user. PPR isn't magic there. Don't force it.

Rollout strategy we'd use again

  1. Turn PPR on per-route, not globally. Use the segment config export const experimental_ppr = true and start with one marketing route.
  2. Add a CI check that diffs the build output. Track which routes are static, hybrid, and dynamic. Fail the build if a route regresses from hybrid to dynamic without an explicit allowlist entry.
  3. Audit shared helpers for hidden dynamic APIs. getSession, feature flags, analytics, i18n helpers. Make the dynamic dependency explicit in the type signature.
  4. Set a Suspense fallback budget. If a fallback is visible for more than ~200ms on a median connection, the boundary is too high in the tree.
  5. Monitor real-user LCP per route, not aggregate. PPR can improve some routes while regressing others (usually because the fallback shifts layout).

Where we'd start: pick one high-traffic, mostly-static route — a product detail page, a blog post, a pricing page. Turn on experimental_ppr for that segment only. Run next build and read the route table carefully. If it's not hybrid, walk up the tree finding the dynamic API that poisoned it, and either push it down into a Suspense boundary or accept that the route stays dynamic. Ship that one route, watch RUM for a week, then expand. The teams that try to enable PPR everywhere on day one are the ones who roll it back on day three.

If you'd rather have someone else do the audit, our web team does this for a living.

#Next.js#React#Performance#App Router

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project