The Problem
I turned on Partial Prerendering for a client's listings page last week. Next.js 16.3, App Router, dynamicIO enabled, the whole modern stack. The build output looked promising at first — every other route showed the partial prerender marker. Then I saw the line for the page I actually cared about:
Route (app) Size First Load JS
┌ ƒ /search 3.2 kB 184 kB
├ ◐ /products/[slug] 2.8 kB 182 kB
└ ○ / 1.1 kB 180 kB
ƒ (Dynamic) server-rendered on demand
◐ (Partial Prerender)
○ (Static)
The /search route was fully dynamic. No static shell, no partial prerender, just a server-rendered page every request. CrUX picked up the regression two days later: TTFB on /search had doubled, LCP slipped from 1.6s to 2.4s on mid-tier mobile, and the page was now slower than it had been before I touched it.
The page itself was straightforward — a server component reading searchParams, calling a filter helper, and rendering a list:
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; category?: string }>
}) {
const { q, category } = await searchParams
const results = await searchProducts({ q, category })
return (
<main>
<SearchHeader />
<Filters />
<ResultsList results={results} />
</main>
)
}
Nothing about that page should require a fully dynamic render. The header and filter UI never change. The results depend on the query, but everything around them is static. PPR was supposed to handle exactly this case.
Why It Happens
PPR works by walking the component tree at build time, prerendering everything it can statically resolve, and leaving holes wherever it hits a dynamic API. Each hole has to be wrapped in a <Suspense> boundary so the prerender has something to inline as the fallback and the runtime has something to stream into.
The trap with searchParams is that the promise is read at the page component's top level, before any Suspense boundary in the tree. Next.js sees the await searchParams call and marks the entire page component as dynamic, because everything rendered by that component depends on a value that cannot be resolved at build time. The static shell PPR was supposed to extract has nowhere to live — its parent is dynamic, so it gets pulled into the dynamic render too.
The same thing happens with cookies(), headers(), and any await on the request itself. The PPR documentation calls this out, but the example uses cookies and most people reading it do not connect the dots back to searchParams in their own listings pages.
The fix is structural, not configurational. There is no flag that makes PPR work harder. The dynamic read has to move into a child component that is wrapped in Suspense, so the parent stays statically resolvable.
The Fix
Push the searchParams read into a child component, wrap that child in Suspense, and let everything around it prerender.
import { Suspense } from 'react'
export default function SearchPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; category?: string }>
}) {
return (
<main>
<SearchHeader />
<Filters />
<Suspense fallback={<ResultsSkeleton />}>
<Results searchParams={searchParams} />
</Suspense>
</main>
)
}
async function Results({
searchParams,
}: {
searchParams: Promise<{ q?: string; category?: string }>
}) {
const { q, category } = await searchParams
const results = await searchProducts({ q, category })
return <ResultsList results={results} />
}
Three things changed. The page component is no longer async, because it does not await anything. It passes the searchParams promise down to Results instead of resolving it. The dynamic part of the render — the actual await searchParams — happens inside the Suspense boundary, which gives PPR a clean hole to render around.
Rerun next build and the route flips:
Route (app) Size First Load JS
└ ◐ /search 3.2 kB 184 kB
The ◐ marker means PPR successfully extracted a static shell. The header, filters, and skeleton ship as prerendered HTML on the edge. The dynamic results stream in once the query resolves.
Important: do not await the promise in the parent to "preload" it. I have seen people try this:
// Wrong: this makes the parent dynamic again.
export default async function SearchPage({ searchParams }) {
const params = await searchParams
return (
<Suspense fallback={<ResultsSkeleton />}>
<Results q={params.q} category={params.category} />
</Suspense>
)
}
Same regression. The await in the parent re-poisons the entire component. The promise must flow through the JSX intact and be awaited inside the Suspense child.
Watch for hidden awaits in shared layouts. If your app/layout.tsx or a parent layout reads cookies or headers at its top level, every page underneath it loses PPR. The static shell extraction is a tree-walk, and the highest dynamic read in the ancestor chain wins. Move those reads into Suspense-wrapped children too, or accept that those routes will not partial-prerender. A real example I hit on the same project — a <UserGreeting> in the global header was awaiting cookies() directly, which knocked out PPR on every route, not just /search.
Verify with the experimental Build Activity overlay. Run next dev with experimental.ppr = true in your config and the dev overlay annotates each Suspense boundary with whether its parent prerendered. If a boundary you expected to be static is showing as dynamic, the offending await is above it in the tree.
Field data takes 72 hours to update in CrUX after a PPR fix. Watch your Web Vitals in Vercel Speed Insights or the Chrome User Experience Report — TTFB should drop back to the static-shell baseline within a day of deploy.
The Lesson
PPR cannot save a page if the dynamic read sits above the Suspense boundary. searchParams is a promise specifically so it can flow through static-rendered components and be resolved inside dynamic ones. Push every await searchParams, await cookies(), and await headers() into the smallest possible child wrapped in Suspense, and audit your shared layouts for the same pattern.
If your Next.js 16 upgrade promised PPR speedups and delivered worse field data, that is a project I get paid to fix. See my services. For a related caching gotcha from the same release, read Next.js 16 use cache migration errors.
PPR not working on the routes you needed it on? Get it fixed.