Why the dashboard freezes the moment you ship it
What you saw
A dashboard that's perfectly alive under npm run dev and stone dead in production. The cards never move, the "synced" stamp is frozen, Refresh does nothing — and curl /api/metrics returns byte-for-byte identical JSON every time, even though the function behind it stamps a fresh new Date() on every call.
The data layer is fine. The client is fine. The handler is fine. What's wrong is how often the handler runs, and the answer in production is: exactly once.
What's actually happening
Next.js App Router is static by default. At build time it looks at every route — pages and route handlers — and asks "can I compute this once, now, and serve the same answer to everyone?" If a GET route handler reads nothing request-specific (no request body, no searchParams, no cookies(), headers(), etc.), Next decides the answer is yes. It runs your handler a single time during next build, freezes the response into the Full Route Cache, and from then on every request is served that cached snapshot. Your function is never called again.
You can see the decision in the build output:
└ ○ /api/metrics ← ○ = Static, prerendered at build time
That little ○ is the whole bug. It means "this was rendered once and cached." getMetrics() ran during the build, produced one snapshot with one timestamp, and that snapshot is what every visitor gets until you redeploy.
This is also exactly why it works in dev. next dev never applies the Full Route Cache — it re-runs handlers on every request so you get instant feedback while editing. So the bug is invisible on your machine and only appears in the build, which is the most painful possible place for it to hide. "Works in dev, frozen in prod" is the signature of this whole family of bugs.
The reason it's so easy to write: nothing is wrong. There's no error, no warning at request time, no crash. A handler that returns live data and a handler that returns a build-time fossil look identical in the source — the difference is a caching decision made silently at build time based on what your handler doesn't touch.
The fix
Tell the route it must run on every request. Any one of these works; the first is the most explicit:
// app/api/metrics/route.js
export const dynamic = 'force-dynamic' // opt out of static optimization entirely
export const revalidate = 0 // never cache; recompute each request
Or make the handler genuinely dynamic by reading a request-scoped API, which flips Next's decision automatically:
import { NextResponse } from 'next/server'
import { headers } from 'next/headers'
import { getMetrics } from '@/lib/metrics'
export async function GET() {
headers() // touching a dynamic API forces dynamic rendering
return NextResponse.json(getMetrics())
}
After the fix the build output flips:
└ ƒ /api/metrics ← ƒ = Dynamic, server-rendered on demand
and two requests a couple seconds apart now return different syncedAt values. For data that genuinely changes but not every second, export const revalidate = 5 (or fetching with { next: { revalidate: 5 } }) is the middle ground — cache, but refresh on an interval. For a live dashboard, force-dynamic is the right call.
Don't "fix" this on the client by adding cache-busting query strings or cache: 'no-store' to the fetch. That hides it for one caller and leaves the endpoint itself serving a fossil to everything else — curl, other pages, mobile. The endpoint is what's frozen, so the endpoint is where it gets fixed.
The bug class: static-by-default and build-time caching
This is the build-time caching / static-optimization bug class, and it bites hardest at framework boundaries where caching is implicit. You didn't ask for caching; the framework inferred it from the absence of dynamic inputs. The mental shift App Router demands is that rendering and caching are the same decision — "is this static or dynamic?" is answered once, at build, for every route.
How to recognize it in the wild:
- Works in dev, frozen in production. The single loudest tell. Dev disables these caches; the production build is where they switch on.
- A timestamp or counter that should move but is pinned to one value — often a suspiciously round "first request" moment, because that's literally when the build ran.
curling an endpoint twice gives identical bytes even though the underlying source changes. That rules out the browser and points straight at server-side caching.- The build output is the source of truth.
○ (Static)next to a route that should be live is the bug, in one character. Read that table after every build.
The version footnote that matters: this default flipped. In Next.js 14, GET route handlers are cached (static) by default — this crumb is built on 14, which is why it freezes. In Next.js 15, the default for GET handlers changed to not cached, so this exact handler would stay dynamic without any config. The bug didn't disappear, though — it just moved. The same static-by-default logic still governs pages, generateStaticParams, and fetch caching, and "I didn't opt into caching but I got it anyway" remains one of the most common ways an App Router app surprises you in production. The durable lesson is to read the build's static/dynamic table and decide caching on purpose, per route, instead of letting the absence of a headers() call decide it for you.