The Problem
Shipped a Next.js 16.2 build for a client this week and the dynamic blog routes were rendering on demand instead of being prerendered. generateStaticParams was returning the right slugs, no errors, the Vercel summary said "23 static pages generated", yet /blog/[slug] was missing from that count and every page hit was showing up in runtime traces. First request was 800ms cold, every subsequent request was a fresh server render.
If your generateStaticParams looks correct, returns data, and the build succeeds but the routes are not in the static output, you are hitting one of four issues that changed silently in the 15 to 16 upgrade.
Why It Happens
generateStaticParams does not force a route to be static. It tells Next.js which params to prerender if the route is being prerendered. The static-or-dynamic verdict is decided by what the route uses: cookies(), headers(), searchParams, uncached fetches, and dynamic = 'force-dynamic'.
In Next.js 16, three things changed that make this trip more often.
Change 1: searchParams is now async and signals dynamic rendering. If your page accepts searchParams as a prop and reads it anywhere, the compiler marks the whole route dynamic. generateStaticParams then does nothing.
Change 2: fetch no longer caches by default. In 14 and earlier, fetch() was cached unless you opted out. 16 made the opposite strict. Any uncached fetch in the page tree opts the route into dynamic rendering. Wrap the call in "use cache" or pass cache: 'force-cache'.
Change 3: dynamicParams defaults. When dynamicParams = true (the default) and generateStaticParams returns an empty array on a slow data source, the route silently falls back to fully dynamic rendering instead of erroring. A flaky CMS call during build turns the whole route into SSR.
Change 4: output: 'export' interactions. Static export throws on missing params. Vercel or Node deploys silently SSR. Same code, different behavior.
The Fix
Step 1: Force the route static. At the top of your dynamic page file:
// app/blog/[slug]/page.tsx
export const dynamic = 'force-static'
export const dynamicParams = false
export const revalidate = 3600
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((post) => ({ slug: post.slug }))
}
dynamic = 'force-static' makes the build error if anything in the route opts into dynamic rendering. dynamicParams = false makes any param not in generateStaticParams return a 404 at build instead of being lazily generated. The build will now scream if you have a problem.
Step 2: Audit every fetch. Any uncached call in the page tree disables static generation. Put the fetcher in a separate module and use "use cache":
// lib/posts.ts
'use cache'
import { cacheLife, cacheTag } from 'next/cache'
export async function getPost(slug: string) {
cacheLife('hours')
cacheTag(`post:${slug}`)
const res = await fetch(`${process.env.CMS_URL}/posts/${slug}`)
if (!res.ok) throw new Error('Failed to fetch post')
return res.json()
}
export async function getAllPosts() {
cacheLife('hours')
cacheTag('posts:all')
const res = await fetch(`${process.env.CMS_URL}/posts`)
if (!res.ok) throw new Error('Failed to fetch posts')
return res.json()
}
Every fetch inside "use cache" is cached, so the route can be statically generated. When the CMS updates, call revalidateTag('post:my-slug') from a Server Action or webhook.
Step 3: Type params correctly. In 16 they are Promises:
type Props = {
params: Promise<{ slug: string }>
}
export default async function Page({ params }: Props) {
const { slug } = await params
const post = await getPost(slug)
return <article>{post.title}</article>
}
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((post) => ({ slug: post.slug }))
}
export async function generateMetadata({ params }: Props) {
const { slug } = await params
const post = await getPost(slug)
return { title: post.title }
}
If you need searchParams on a route that should be static, read them only inside a child component wrapped in <Suspense>. The boundary lets the rest of the page stay static and only the searchParam-aware child becomes dynamic.
Step 4: Verify the build output. Run npm run build locally and read the route table. Your dynamic route should be marked with a filled circle (●) for "Static at build time" with a list of generated paths underneath:
○ / 1.2 kB
● /blog/[slug] 5.8 kB
├ /blog/post-one
├ /blog/post-two
└ /blog/post-three
ƒ /api/contact 0 B
A hollow circle (○) or ƒ means dynamic. To find the exact disqualifier:
NEXT_PRIVATE_DEBUG_CACHE=1 npm run build 2>&1 | grep -i 'dynamic'
You get a line per route explaining the trigger, like cookies() called at app/blog/[slug]/page.tsx:14:5. That is your fix target. The Next.js caching docs cover every signal that flips the verdict.
The Lesson
generateStaticParams only kicks in if the route is statically renderable. In Next.js 16 the default is dynamic for almost anything that touches a fetch or a request-time API. Force dynamic = 'force-static', lock dynamicParams = false, wrap fetchers in "use cache", and the build will fail loudly when something opts you out — which is what you want.
If your Next.js routes are silently SSRing in production and tanking TTFB, that is the kind of migration work I do. See my services, or read Next.js 16 revalidateTag not working if your tags fire but pages stay stale.
