The Problem
I flipped on Partial Prerendering for a client's marketing site this week. The toggle is one line in next.config.ts, the docs make it sound like a free win, and the dev server compiled without complaint. The production build did not:
Failed to compile.
./app/(marketing)/blog/[slug]/page.tsx
Error: Route "/blog/[slug]" is using "export const dynamic = 'force-dynamic'"
which is an unsupported segment config when Partial Prerendering is enabled.
Either remove the segment config and let the cached and uncached boundaries
infer the behaviour, or move the dynamic work into a child component wrapped
in <Suspense>.
3 | export const revalidate = 60
4 | export const dynamic = 'force-dynamic'
| ^^^^^^^^
That was the first route. The build then printed twelve more identical errors across pages, layouts, and route handlers, most of them inherited from a base layout the previous developer had pasted dynamic = 'force-dynamic' into a year ago to "fix" a caching issue that no longer existed. Some of them had revalidate = 0 instead, which produces a slightly different but equally fatal variant of the same error.
The fix is not to delete every config line and hope for the best. PPR replaces the static-vs-dynamic switch with a per-component model, and any route that opts the whole tree into one mode is incompatible by design. Migrating means classifying every offending route and rewriting it.
Why It Happens
PPR works by splitting a route's render output into a static shell and dynamic holes. The shell renders at build time and ships with the HTML. The holes are the children of <Suspense> boundaries that read from request-scoped APIs like cookies, headers, searchParams, or any uncached fetch. Those holes stream in at request time after the shell has been served.
Per-route segment configs predate this model. dynamic = 'force-dynamic' tells the build to skip prerendering the whole route and treat every render as dynamic. revalidate = 0 does the same thing in older syntax. dynamic = 'force-static' does the opposite, refusing any dynamic data. Both of those are incompatible with PPR because PPR needs to decide per-component, not per-route. The build error is the compiler refusing to silently override your intent.
There is a second flavour. runtime = 'edge' plus PPR on the same route currently errors during build for routes that try to prerender, because the static shell generation runs on the Node build worker and cannot probe the edge function. Routes with runtime = 'nodejs' are fine.
The PPR docs cover the basic case but skip the migration mechanics. Most of the routes in a real codebase will hit one of the rules above.
The Fix
Three patterns. Pick per route.
Pattern 1: Remove the segment config and wrap dynamic work in Suspense. This is the right answer for 80% of routes. The marketing pages I was migrating fetched a CMS payload (cached, fast) and the user's session header (uncached, dynamic). Before:
// app/(marketing)/blog/[slug]/page.tsx
export const dynamic = 'force-dynamic'
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await getPost(slug)
const session = await getSession()
return (
<article>
<h1>{post.title}</h1>
<UserBadge session={session} />
<PostBody html={post.content} />
</article>
)
}
The whole route was marked dynamic just so getSession() could read cookies. After the migration, the cookie read moves inside a Suspense boundary and the segment config disappears:
// app/(marketing)/blog/[slug]/page.tsx
import { Suspense } from 'react'
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await getPost(slug)
return (
<article>
<h1>{post.title}</h1>
<Suspense fallback={<UserBadgeSkeleton />}>
<UserBadgeAsync />
</Suspense>
<PostBody html={post.content} />
</article>
)
}
async function UserBadgeAsync() {
const session = await getSession()
return <UserBadge session={session} />
}
The static shell now includes the title and post body, prerendered at build. The badge streams in at request time. Lighthouse LCP drops to the shell render time, which is whatever your CDN edge can hit.
Pattern 2: Hoist the layout-level config into the routes that need it. Inherited segment configs are the worst case. A layout in app/(dashboard)/layout.tsx setting dynamic = 'force-dynamic' poisons every child route, including ones that could be partially prerendered. Delete the layout-level config and add a per-route opt-out only where needed:
// next.config.ts
import type { NextConfig } from 'next'
const config: NextConfig = {
experimental: {
ppr: 'incremental',
},
}
export default config
The 'incremental' mode lets you opt routes in one at a time with export const experimental_ppr = true. Existing routes stay on the classic behaviour until you mark them, which gives you a migration runway. If the build still errors, search for any remaining force-dynamic in the marked routes' parents:
grep -r "force-dynamic\|force-static\|revalidate = 0" app/
Pattern 3: Drop the edge runtime on routes you want to prerender. Remove export const runtime = 'edge' from the file. If the route genuinely needs edge for low TTFB on the dynamic part, keep it on a route handler that the page calls into, and let the page itself run on Node so the shell can prerender:
// app/api/user/route.ts — still edge for the dynamic call
export const runtime = 'edge'
export async function GET() {
return Response.json({ name: 'Qasim' })
}
Verify the migration. Run next build and watch the output. Each PPR-eligible route now logs as ◐ (PPR) next to its size, instead of λ (Dynamic) or ○ (Static). The half-circle means a static shell exists. To confirm the dynamic holes are actually streaming, hit the route with curl and look for Transfer-Encoding: chunked and a partial response that completes after a short delay:
curl -i -N https://your-site.vercel.app/blog/example-slug
The static shell bytes hit immediately, the dynamic chunks arrive after the cookie read resolves. If the whole response blocks until everything is ready, a Suspense boundary is missing somewhere above the dynamic component.
The Lesson
PPR is incompatible with the per-route static-or-dynamic switch by design, because the new model decides per-component. Delete the segment configs, wrap request-scoped reads in Suspense, and use ppr: 'incremental' so you can migrate one route at a time. The build errors are the migration checklist.
If your Next.js 16 PPR rollout is stuck on cascading config errors across a real codebase, that is the kind of upgrade I get paid to finish. See my services. For a related caching migration, read Next.js 16 unstable_cache migration build errors.
Stuck on a Next.js 16 PPR migration? Get it shipped.