An article page on a Next.js site I was building for a client rendered perfectly in local. I ran pnpm dev, opened the route, and got a 200 with the full render: title, body, and a coin price chart embedded mid-article. All green. I pushed, Vercel built successfully, then I opened the same production URL and got a blank page with plain text: Internal Server Error. HTTP 500.
What confused me: not every article 500'd. Its sibling routes — articles that did not embed a chart — stayed 200 and rendered fine. Only the article that triggered the chart went down. And here is the most misleading part: the problem survived every cache purge I tried. I redeployed with an empty cache, I invalidated the CDN, same result every time. When a bug survives a cache purge, it is not a cache problem. It is deterministic.
Tracing it
Since dev was clean and only production failed, I knew this was the "dev and build behave differently" class of bug. Next.js dev mode is permissive; it renders on demand and rarely exercises the static-optimization path that next build uses. So the first thing I did was not guess — it was reproduce the production conditions locally:
pnpm build && pnpm startAnd sure enough, that is where the 500 surfaced on my own machine. Now I could read the actual stderr instead of guessing from the client-facing error page. In the logs, the error digest was unambiguous: DYNAMIC_SERVER_USAGE. That was the clue I needed. Something in that route was touching a dynamic API at the exact moment Next.js was trying to statically optimize the route.
I traced the call chain. The article page renders a nested chart component, and that chart has its own fetch chain:
ArticleCoinChart -> fetchCoinChart -> getApiSettings -> sanityFetch -> draftMode()At the end of that chain sits draftMode(). That is a dynamic API: calling it marks the render as request-dependent. All along, sanityFetch was reading draftMode() to decide whether to serve draft or published content. Nothing was wrong with that code in isolation. What was wrong was the context it got called in.
Why production 500s while dev 200s
This article route declares a generateStaticParams() that returns an empty array:
export async function generateStaticParams() {
return [];
}My intent at the time: "don't pre-render anything at build, render everything on demand." But returning [] is not the same as turning off static optimization. Next.js 16 still treats this route as a candidate for static optimization and tries to render it in a request-less context. It is inside that context that the draftMode() buried deep in the chart component gets touched, and that trips DYNAMIC_SERVER_USAGE at request time.
Dev never surfaced this because dev does not run that static optimization. Every request in dev is rendered fully in a dynamic context, so draftMode() always has a request to lean on. That is why local was green and production was red for the exact same code.
The fix
There are two clean routes, and both are correct depending on your architectural taste.
The first: lift the fetch to page level. The article page already lives in a dynamic context, so if draftMode() gets touched there, no problem. I fetch the chart result in the page, then pass it down as a prop to ArticleCoinChart instead of letting the nested component fetch on its own.
The second, and the one I ended up using because it is the smallest change: force the route dynamic explicitly.
export const dynamic = "force-dynamic";That one line tells Next.js to never attempt to statically optimize this route. There is no more request-less render, so the nested draftMode() always has a valid context. The 500 is gone.
One gotcha worth recording: export const revalidate = N alone is NOT enough here. When generateStaticParams() returns [], revalidate does not prevent the static-optimization attempt that triggers the problem. If there is a dynamic API anywhere in the render path, you need force-dynamic, not just revalidation.
The takeaway
This bug has two lessons that reinforce each other. First, never trust pnpm dev to validate production behavior on anything touching static versus dynamic rendering. Dev mode is too permissive; it hides an entire class of bugs that only surface under next build. Now, for every fetch I add to a nested server component, I run pnpm build && pnpm start before I push, not after.
Second, when production looks "stuck" or serves an error that survives cache purges, suspect a deterministic render crash, not a cache. Reading the real production stderr — DYNAMIC_SERVER_USAGE in this case — cut hours of guessing. A dynamic API buried deep in a nested component chain still counts; it does not care how deep it is hidden. If a route with an empty generateStaticParams touches draftMode(), cookies(), or headers() anywhere in its tree, declare force-dynamic and stop fighting the optimizer.
