DEV Community

Daniel Pertu
Daniel Pertu

Posted on

We wrote force-dynamic 81 times, and the one route that forgot it behaves identically

CogniPrep has 409 page.tsx files, 375 of them public enough to be in the sitemap, and 76 route handlers. I went looking for every route segment config export in the app, expecting a mess of considered decisions. What I found was one value, repeated:

export const dynamic       81 occurrences, all 'force-dynamic'
export const revalidate    80 occurrences: 74 zeros, two 300s, one 3600
export const maxDuration    7 occurrences
export const runtime        1 occurrence
Enter fullscreen mode Exit fullscreen mode

75 files declare both dynamic = 'force-dynamic' and revalidate = 0. The second line cannot change anything the first has not already decided, so 75 of those lines are decoration.

That left the question of how many of the remaining declarations are doing work. The answer is five.

The 13 pages

Of the 81, 13 are in .tsx files: a layout and seven pages under /admin, two report pages under /dashboard, an affiliate join page, and then the two that matter, because they are public:

Both read the visitor's region to pick a display currency, and the comment above the declaration on the pricing page says exactly why that is incompatible with a cache:

/**
 * Rendered per request rather than cached.
 *
 * This page previously carried `revalidate = 300`. Localising the price means
 * reading the visitor's region from the request, which is incompatible with
 * serving one cached copy to everyone. The alternative, caching GBP and
 * correcting it in the browser, is what produced a visible price flicker, so
 * the cache is what gives way. The page does no database work, so per-request
 * rendering is cheap.
 */
export const dynamic = 'force-dynamic';
Enter fullscreen mode Exit fullscreen mode

That is a real trade made on purpose: a price that is right on first paint, paid for with a cache miss on every visit.

The ten signed-in pages are a different story. They read a session cookie, which already makes them dynamic. The declaration is belt and braces.

The three comments that record the same bug

The interesting part of this census is not the pages that are dynamic. It is three public pages that used to be, and the comment each one now carries. From the landing page:

// Enable static generation with revalidation for landing page
export const revalidate = 3600; // Revalidate every hour

export default async function LandingPage() {
  // The former signed-in bounce to /dashboard has been removed (public pages no
  // longer redirect signed-in users).
  //
  // Reading auth here made the landing page, the single most requested and most
  // SEO-critical route in the app, render per request, uncacheable by the CDN,
  // with a Supabase auth round trip on every cold visit. It also silently
  // disabled the `export const revalidate = 3600` below.
Enter fullscreen mode Exit fullscreen mode

/interview and /pricing carry near-identical notes. The failure mode is worth saying plainly, because nothing warns you about it: an export const revalidate at the top of a file is a request, not a guarantee. One call that reads cookies anywhere in that page's render tree downgrades the whole route to dynamic, and the export stays in the file looking authoritative.

In our case the auth read existed only to bounce a signed-in visitor to their dashboard. Removing that redirect took three public pages back to static rendering, and that part is checkable from outside:

curl -sI https://cogniprep.app/ | grep -i "x-nextjs-prerender\|x-vercel-cache\|^age"
curl -sI https://cogniprep.app/pricing | grep -i "x-nextjs-prerender\|cache-control"
Enter fullscreen mode Exit fullscreen mode

What comes back right now:

URL prerender x-vercel-cache cache-control
/ 1 HIT, age 223 public, max-age=0, must-revalidate
/interview 1 HIT, age 195 public, max-age=0, must-revalidate
/assessment-centre-practice 1 HIT, age 195 public, max-age=0, must-revalidate
/games/shl 1 REVALIDATED public, max-age=0, must-revalidate
/pricing absent MISS, age 0 private, no-cache, no-store
/comparison absent MISS, age 0 private, no-cache, no-store

The x-nextjs-prerender: 1 header is the one to read. Either a route produced HTML at build time or it did not, and no amount of config comment changes which.

The 68 route handlers

The other 68 declarations are in route.ts files, which is 68 of our 76 route handlers. I checked how many of those files do something that cannot be cached regardless: export a POST, PUT, PATCH or DELETE, read cookies() or headers(), read the request body, or construct a Supabase client.

61 of 68. For those, the declaration is a comment with syntax.

Seven are left, and six of them read a request header through a shared helper, so they are dynamic too. The genuinely load-bearing one is /api/health, which exports a GET with no request argument at all:

// Force dynamic rendering - health checks must be real-time
export const dynamic = 'force-dynamic';
export const revalidate = 0;

export async function GET() {
Enter fullscreen mode Exit fullscreen mode

A handler shaped like that is the one shape where a framework could reasonably decide to run it once and serve the answer forever, which for a health check would be worse than useless.

Here is the part that surprised me. Of the 76 route handlers, eight carry no dynamic export, and one of them is our RSS feed, which stamps new Date().toUTCString() into lastBuildDate at render time. If the missing declaration mattered, that timestamp would be frozen at build time:

curl -s https://cogniprep.app/feed.xml | grep -o "<lastBuildDate>[^<]*"
date -u
Enter fullscreen mode Exit fullscreen mode

The two agree to within seconds, every time. Route handlers in Next 16 are not cached by default, so the declaration our feed route forgot is the declaration the other 68 did not need.

What we are doing about it

Not deleting 75 lines in one commit, because a redundant line is cheap and a wrong deletion is not. The rule we wrote down instead:

  1. revalidate = 0 never appears next to force-dynamic. Pick the stronger statement.
  2. force-dynamic on a page goes with a comment naming the per-request value it reads. Both of ours now do.
  3. force-dynamic on a route handler is only worth writing when the handler takes no request argument and must not be frozen. That is one file out of 76.
  4. An export const revalidate is only true if nothing in the render tree reads the request. The way to confirm it is x-nextjs-prerender on the deployed URL, not the file.

Run the two curl commands above against cogniprep.app and you can see every claim in this post for yourself, which is the other reason to prefer headers to config files: the header is what actually happened.

Top comments (0)