Next.js 16 PPR Static Shell Flashing on Dynamic Routes

Next.js 16 PPR shell flashing skeleton before dynamic content paints? Here is why the prerender slot leaks and the Suspense boundary fix that stops it.
Next.jsReactPerformance
May 30, 20266 min read1028 words

The Problem

Enabled Partial Prerendering on a client e-commerce site after upgrading to Next.js 16.2. The product detail page was supposed to render the static layout shell instantly and stream in the live stock count from the database. What actually happened on slow 3G: the shell rendered, then the entire page replaced itself with a skeleton, then the skeleton replaced itself with the live data. Three paints instead of two.

Real Web Vitals data the week after the rollout:

LCP shifted from 1.8s to 2.4s on p75 mobile
CLS jumped from 0.04 to 0.11
INP unchanged

The shell flash was visible in Lighthouse Performance traces. The "Largest Contentful Paint" element kept changing as PPR's prerendered tree got blown away and re-rendered. The build log carried the warning:

[PPR] Hydration replaced prerendered subtree at <ProductDetail>
[PPR] Suspense boundary "/product/[slug]" resolved with different content

Rolling PPR back made the warnings disappear and brought CLS back to 0.04. But that defeats the upgrade. The fix is in how the Suspense boundary is placed, not in disabling PPR.

Why It Happens

PPR splits a route into two halves. Everything outside a <Suspense> boundary is prerendered at build time into a static shell. Everything inside is held back, marked dynamic, and streamed in at request time. Next.js sends the static HTML first, then the dynamic chunks arrive over the same response.

The flash comes from a mismatch between the fallback and the resolved tree. If the Suspense fallback renders a 200px skeleton and the resolved component renders 240px of content, the layout shifts. If the fallback renders a completely different DOM shape (a div with a spinner vs a grid of product cards) React unmounts the fallback and mounts the resolved tree from scratch. That is the third paint.

There is a more subtle cause. When the dynamic component reads from cookies() or headers() and the request is uncached, PPR cannot decide which slot the data belongs to. It conservatively re-renders parents to keep the tree consistent. The static shell paints, the dynamic boundary resolves, React reconciles, and the shell briefly re-renders with different keys. From the user's perspective: flash.

The PPR documentation calls this out indirectly when it says fallbacks should match the resolved layout. In practice, most teams ship their existing skeleton loader as the fallback and assume it is good enough.

The Fix

Two changes. Pin the fallback to the exact dimensions of the resolved component, and move request-scoped reads as deep into the tree as they can go.

1. Skeleton must match the resolved height and DOM shape. Wrong:

<Suspense fallback={<div className="h-20 w-full bg-gray-100" />}>
  <ProductStock sku={sku} />
</Suspense>

The fallback is 80px tall. The resolved <ProductStock> renders a stock badge, a quantity input, and a CTA button, closer to 160px. CLS happens the moment the dynamic chunk arrives.

Right:

<Suspense fallback={<ProductStockSkeleton />}>
  <ProductStock sku={sku} />
</Suspense>

function ProductStockSkeleton() {
  return (
    <div className="space-y-2">
      <div className="h-6 w-24 rounded bg-gray-100" />
      <div className="h-10 w-32 rounded bg-gray-100" />
      <div className="h-12 w-full rounded bg-gray-100" />
    </div>
  )
}

The skeleton mirrors the resolved DOM exactly. Same number of elements, same dimensions. React reconciles without unmounting. CLS drops back to baseline.

2. Move dynamic reads into the leaf component. Wrong:

export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const headersList = await headers()
  const tenant = headersList.get('x-tenant-id')

  return (
    <ProductLayout>
      <Suspense fallback={<ProductStockSkeleton />}>
        <ProductStock sku={slug} tenant={tenant} />
      </Suspense>
    </ProductLayout>
  )
}

Reading headers() in the page body marks the whole page dynamic. PPR cannot prerender any of it. The static shell PPR was supposed to deliver does not exist.

Right:

export default function Page({ params }: { params: Promise<{ slug: string }> }) {
  return (
    <ProductLayout>
      <Suspense fallback={<ProductStockSkeleton />}>
        <DynamicStock params={params} />
      </Suspense>
    </ProductLayout>
  )
}

async function DynamicStock({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const tenant = (await headers()).get('x-tenant-id')
  return <ProductStock sku={slug} tenant={tenant} />
}

The page is now a synchronous server component. <ProductLayout> and the Suspense boundary itself prerender at build time. Only <DynamicStock> defers, and it is the only thing that reads request data. PPR has a clean static/dynamic boundary.

3. Verify with the prerender report. Run next build and look at the route summary:

next build

You want each route marked (Partial Prerender), with the dynamic boundary listed as the only deferred segment. If you see (Dynamic) instead, something above your Suspense boundary is reading request data and forcing the whole route dynamic. Walk the tree from the leaf back to the page until you find the offending headers(), cookies(), or searchParams read and push it down.

After the fix on the same client, the deploy logs showed all 12 product routes flipped from (Dynamic) to (Partial Prerender). Field LCP recovered to 1.7s on p75 mobile within a week, CLS to 0.03.

The Lesson

PPR is not a free upgrade. The static shell only pays off if the Suspense fallback matches the resolved DOM and if request-scoped reads happen inside the boundary, not above it. Mismatched fallbacks cause CLS. Reads in the page body cancel prerendering entirely. Move the dynamic work into the leaf and pin the skeleton to the resolved dimensions.

If your Next.js 16 site is losing Web Vitals after enabling PPR, that is a tune-up I run weekly. See my services. For a related caching pitfall after the upgrade, read Next.js 16 unstable_cache migration errors.

Stuck on Web Vitals after a Next.js 16 upgrade? Get it shipped.

Back to blogStart a project