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
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';
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.
/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"
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() {
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
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:
-
revalidate = 0never appears next toforce-dynamic. Pick the stronger statement. -
force-dynamicon a page goes with a comment naming the per-request value it reads. Both of ours now do. -
force-dynamicon 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. - An
export const revalidateis only true if nothing in the render tree reads the request. The way to confirm it isx-nextjs-prerenderon 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)