A client site built on Next.js 16 had coin detail pages at /coins/[slug]. Every coin got its own page: price, stats, and a chart with a range switcher, buttons for 24h, 7d, 30d. Locally everything was smooth. I ran pnpm dev, opened /coins/bitcoin, and got a 200 with a full render. I pushed to Railway, the build succeeded, I opened the exact same production URL, and got Internal Server Error. HTTP 500. Not once, but consistently, and always after about five seconds of loading.
What stood out: it was not one coin. Every /coins/[slug] route 500'd in production. Meanwhile every other route, the homepage, the list page, the about page, stayed 200 and healthy. So something specific to this coin detail template was going down, and only in a production build.
The first dead end
My first guess was wrong, and I burned time on it. Since the detail page reads a lot of fields from an API, I suspected a null deref. One suspicious field was athDate, the all-time-high date that is sometimes missing for new coins. I figured the render was blowing up on a property access against null, so I patched it with optional chaining everywhere.
const ath = coin.athDate?.toLocaleDateString();Pushed again. Still 500. Of course, because that null deref was never actually happening. If there were a real TypeError, dev would blow up too, and dev was clean. I was shooting at the wrong symptom because I had not read the actual error yet. The classic lesson: do not patch before you know the error.
Reading the real error
Dev green, production red, the exact same code on both. I had already taken this pattern apart in an earlier write-up about a DYNAMIC_SERVER_USAGE triggered by draftMode() inside a nested component fetch chain: the difference lives in the static-optimization path that only next build runs. So I stopped guessing and reproduced production conditions on my own machine:
pnpm build && pnpm startThe 500 surfaced immediately in local. Now I could read the real stderr instead of guessing from the client error page. The digest was blunt:
Error: digest 'DYNAMIC_SERVER_USAGE'Same digest, different route. All that was left was finding which dynamic API got touched this time.
Root cause: searchParams plus an empty generateStaticParams
The coin detail route had two things that, on their own, looked harmless.
First, its generateStaticParams() returned an empty array:
export async function generateStaticParams() {
return [];
}Same intent as last time, do not pre-render any coin at build. Same trap too: an empty array is not how you turn off static optimization, and Next.js 16 still counts the route as a static candidate.
Second, the page read searchParams to know which chart range was active:
export default async function CoinPage({ searchParams }) {
const range = searchParams.range ?? "24h";
const chart = await getCoinChart(slug, range);
// ...
}searchParams is a dynamic API. Reading it marks the render as request-dependent. And there is the conflict: Next.js tries to statically optimize the route because generateStaticParams is empty, but the render touches searchParams, which demands a request context. Static versus dynamic collide, and that collision explodes into DYNAMIC_SERVER_USAGE at request time, in production builds only. In dev the collision never happens, because searchParams always has a request to lean on.
The difference from that draftMode() case: nothing here is nested. searchParams is read right at the top of the page. Precisely because it looks ordinary, it is easy to miss.
The fix
One line. Drop the empty generateStaticParams and replace it with an explicit dynamic declaration:
export const dynamic = "force-dynamic";searchParams had a valid request context again. The 500 was gone, and the chart range buttons worked as they should.
Takeaways
- An empty
generateStaticParamsis safe ONLY when the render touchesparamsalone. The moment it readssearchParams,cookies(),headers(), ordraftMode(), you needforce-dynamic. - Do not patch before reading the error. The optional chaining on
athDatewas a dead end because the null deref never happened. - Reproduce production with
pnpm build && pnpm start, then read the real stderr. That is where theDYNAMIC_SERVER_USAGEdigest shows up plainly instead of as a client error page.