The Problem
Shipped Partial Prerendering on a marketing site this week after the upgrade to Next.js 16.2. The dynamic holes worked exactly as advertised: the personalisation block streamed in with the right user data, the cart count was correct, the country-detected banner read the right header. The static shell around them was three days out of date.
The pricing on the product cards in the static shell still showed the old launch prices. The hero heading still read the pre-launch copy. A redeploy did not fix it. Hitting the route in an incognito window without any cookies showed the same stale shell, which ruled out a personalisation issue. The dynamic stream inside the page rendered the correct numbers off the same database the static shell was reading. The shell itself was just frozen at whatever the build had captured.
I ran into this on a client project where the dynamic-vs-static boundary moved without anyone noticing. The shell had been generated on a build that read the database before the launch was scheduled, and the only way to invalidate it was a tag we had not added.
Why It Happens
Partial Prerendering treats the app/ tree as a mix of two render passes. At build time, Next walks the tree, stops at the first Suspense boundary that contains a dynamic API (cookies, headers, searchParams, an uncached fetch), and serialises everything above it as the static shell. The shell is plain HTML, written to the build output, and served from the edge without re-running the React tree. Below the boundary, the dynamic hole is rendered per request and streamed into the placeholder slot.
The catch is what counts as static. Anything outside a Suspense boundary that does not use a dynamic API is statically rendered, including data reads. If a server component above the boundary calls a cached helper or a fetch with the default Next 16 cache behaviour, the value is baked into the shell at build time. The shell never re-renders on its own. It only gets a new value when something explicitly invalidates the cache entry the shell depends on.
In my case the pricing component sat above the Suspense boundary because it did not need the user's session. It read from a 'use cache' helper that pulled prices from a Supabase view. On build day three weeks earlier, the prices were the launch numbers. Nothing invalidated the cache entry, so PPR happily served the same shell forever. The dynamic hole below the boundary was untouched by the cache layer because it ran on every request, which is why it looked fresh while the surrounding shell looked frozen.
The PPR rendering model documentation describes the boundary, but the implication for cached server reads above it is buried in the caching docs, not the PPR overview. Teams flip PPR on, see it work, and only notice the staleness when the marketing team complains a week later.
The Fix
Three things to do. Tag the cache entries the shell depends on, wire up invalidation on the writes, and force a one-time rebuild of the shells you already shipped.
Step 1: Tag the cached reads the shell uses. Any 'use cache' helper that participates in the static shell needs an explicit cacheTag so you can invalidate it on demand. The default behaviour is "cache forever until rebuild":
import { unstable_cacheTag as cacheTag, unstable_cacheLife as cacheLife } from 'next/cache'
export async function getFeaturedPricing() {
'use cache'
cacheTag('pricing:featured')
cacheLife('hours')
return db.from('featured_pricing').select('*').then(r => r.data)
}
cacheLife('hours') gives the shell a backstop so it cannot drift further than an hour even if nobody invalidates it. The tag is what you fire when the data actually changes.
Step 2: Invalidate on the write path. Wherever pricing is updated (admin form, Stripe webhook, scheduled job), call revalidateTag so the next request rebuilds the shell:
import { revalidateTag } from 'next/cache'
export async function updateFeaturedPrice(productId: string, price: number) {
await db.from('featured_pricing').update({ price }).eq('product_id', productId)
revalidateTag('pricing:featured')
}
The next request to a route that depends on pricing:featured will skip the cache, re-render the shell with the new value, and write the new static HTML for subsequent visitors. PPR is still doing its job: the shell is still static, the dynamic hole is still streamed. The only thing that changed is the shell now refreshes when the data does.
Step 3: Move data into the dynamic hole if it should never be cached. If the value above the boundary genuinely needs to be live on every render, the fix is not to cache it. Move the read inside a Suspense boundary and use a dynamic API or noStore:
// Page component.
export default function Page() {
return (
<main>
<StaticHeader />
<Suspense fallback={<PricingSkeleton />}>
<LivePricing />
</Suspense>
<StaticFooter />
</main>
)
}
// LivePricing is a server component, no 'use cache'.
async function LivePricing() {
const prices = await db.from('featured_pricing').select('*')
return <PricingGrid prices={prices.data} />
}
The shell now renders the header and footer statically, and the pricing grid streams in per request without any cache layer. Slower than a fully static shell but always correct, which is the right trade-off for anything price-sensitive.
Step 4: Verify before declaring victory. Hit the route with curl and check the headers Next returns:
curl -I https://example.com/products
A PPR-rendered route returns x-nextjs-cache: HIT for the shell on a warm edge and the response body contains a streamed dynamic suffix. If you redeploy after wiring up the tag and the response still shows the old data, the shell has not been regenerated yet. Hit the API endpoint that triggers revalidateTag once, then re-curl. If the second request shows fresh data, the invalidation pipeline is working. If it does not, either the tag string is misspelled or the helper is being inlined into the shell without participating in the cache layer.
The Lesson
PPR draws a hard line between the static shell and the dynamic hole, and every cached server read above that line is baked into the shell until something explicitly invalidates it. Tag the helpers, fire revalidateTag on the writes, and move data that should never be cached below the Suspense boundary. The dynamic stream feels like magic; the shell needs the same cache discipline as any other static page.
If your PPR rollout is serving last week's data and you need somebody to draw the boundary correctly, that is a job I do. See my services. For a related Next.js 16 caching gotcha, read Next.js 16 unstable_cache migration errors.
PPR shipped stale shells on a production site? I can fix it.
