Next.js 16 PPR Dynamic Holes Re-rendering Every Request

Next.js 16 Partial Prerendering ships a static shell, but the dynamic holes re-render every visit instead of caching. Here is why and the exact fix.
Next.jsPPRCaching
June 1, 20266 min read1008 words

The Problem

Turned on experimental.ppr on a marketing site running Next.js 16.2. The build output looked right: the route showed the dynamic-island markers in .next/server/app-paths-manifest.json and the static shell shipped with the header, footer, and hero already rendered. The Suspense boundary around the personalised "Recommended for you" widget was the only dynamic hole. So far so good.

But the dynamic part re-fetched and re-rendered on every request from every user, even when the underlying recommendation source had not changed and even when the same user hit refresh five seconds later. Server logs showed the recommendations API getting hit one-for-one with page views. The Cache-Status header read MISS end to end. Vercel's observability tab showed function invocations matching request count exactly.

PPR was supposed to give us a static shell plus cached dynamic holes. We got a static shell plus a dynamic hole that was, in practice, fully dynamic.

Why It Happens

PPR ships two render paths inside the same response. The static shell is prerendered at build time and served from the edge. Each dynamic hole streams in on first paint and, under PPR's intended model, is cached per route segment with whatever caching policy you put on the data sources inside.

The trap is that "dynamic" in PPR means "this part can read request-scoped data and is excluded from the static shell". It does not mean "this part is cached separately by Next.js". Caching inside the hole still goes through the same primitives as anywhere else in the App Router: 'use cache' on the function that fetches data, cacheTag for invalidation, fetch(url, { next: { revalidate } }) if you are still on the data cache.

If the function inside the Suspense boundary just awaits fetch(url) with no caching directive, the dynamic hole will fetch on every request. PPR will happily serve the static shell from the edge in single-digit milliseconds and then sit there waiting for an uncached fetch to come back from a downstream service. The whole point of the dynamic hole was that it could read request-scoped data, but that does not bypass the fact that the read also needs to be cached for the cases where the data is the same.

The behaviour is documented but easy to miss. From the Partial Prerendering docs: the static shell is cached, the dynamic content is rendered on demand, and dynamic content that does not change per request needs to be cached explicitly.

The Fix

Two changes. First, wrap the data fetch in a 'use cache' function with a real cache key. Second, mark the cached entry with a tag so you can invalidate it on the write side instead of relying on time-based revalidation alone.

// app/_data/recommendations.ts
import { unstable_cacheTag as cacheTag, unstable_cacheLife as cacheLife } from 'next/cache'

export async function getRecommendationsForUser(userId: string) {
  'use cache'
  cacheTag(`recommendations:${userId}`)
  cacheLife('minutes')

  const res = await fetch(
    `${process.env.RECS_API}/recs?user=${userId}`,
    { headers: { Authorization: `Bearer ${process.env.RECS_TOKEN}` } },
  )
  if (!res.ok) throw new Error('recs fetch failed')
  return res.json() as Promise<Recommendation[]>
}

Inside the Suspense boundary, resolve the user id outside the cached function and pass it in. This is the same pattern 'use cache' enforces everywhere — the cached body cannot read cookies() or headers() directly.

// app/page.tsx
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { getRecommendationsForUser } from './_data/recommendations'
import { RecommendationList } from './_components/recommendation-list'

export const experimental_ppr = true

export default function Page() {
  return (
    <main>
      <Hero />
      <Suspense fallback={<RecommendationSkeleton />}>
        <Recommendations />
      </Suspense>
    </main>
  )
}

async function Recommendations() {
  const userId = (await cookies()).get('uid')?.value ?? 'anon'
  const recs = await getRecommendationsForUser(userId)
  return <RecommendationList items={recs} />
}

With this in place, the dynamic hole becomes one cache entry per user id. Hot users hit a warm cache, the recommendation API takes a fraction of the traffic it used to, and the streamed-in HTML lands in tens of milliseconds instead of hundreds.

Three things to verify after the change ships:

# First load fills the cache.
curl -s -H 'Cookie: uid=test-user' https://yourapp.com/ -o /dev/null -w '%{time_total}\n'

# Second load should be roughly an order of magnitude faster.
curl -s -H 'Cookie: uid=test-user' https://yourapp.com/ -o /dev/null -w '%{time_total}\n'

On Vercel, open Observability → Functions and confirm that invocations drop after the first request per user id. If they do not drop, either the directive is missing somewhere in the call chain or you are passing a value that varies per request (a timestamp, a request id, anything generated inside the Recommendations component itself).

When the underlying data changes, invalidate the tag instead of waiting for cacheLife:

import { revalidateTag } from 'next/cache'

export async function recalcRecommendations(userId: string) {
  await runRecommendationJob(userId)
  revalidateTag(`recommendations:${userId}`)
}

That keeps the cache fresh on writes and lets the cacheLife window be long enough to actually matter on reads.

The Lesson

PPR splits the response into static and dynamic. It does not magically cache the dynamic part. Wrap the data fetches inside Suspense boundaries with 'use cache', give them a stable cache tag built from the user-facing key, and resolve request-scoped values outside the cached body. The static shell ships from the edge, the dynamic hole hits a warm cache, and the request graph looks the way the PPR docs promised.

If your Next.js 16 PPR rollout is shipping shells fast but holes slow, that is a project I untangle for clients. See my services. For a related migration gotcha, read Next.js 16 unstable_cache migration errors.

Stuck on a PPR rollout? Get it shipped.

Back to blogStart a project