Six files in our Next app assert they are static, 41 declare they are not, and 14 of the 41 have no GET to make static
Munchable's website is 516 URLs and one Next.js 16 app. I went looking for how many of those URLs are actually guaranteed to be built once at deploy time, rather than merely happening to be, and the answer is a tidy numb
Munchable's website is 516 URLs and one Next.js 16 app. I went looking for how many of those URLs are actually guaranteed to be built once at deploy time, rather than merely happening to be, and the answer is a tidy number with an untidy gap in it.
Six files in the app directory contain this line:
export const dynamic = 'error';
Forty-one contain the opposite:
export const dynamic = 'force-dynamic';
Those are the only two values in the repo. No force-static, no revalidate on anything public, no ISR anywhere.
The six that assert
dynamic = 'error' is the route segment config that reads least like a setting and most like a test. It does not make a page static. It says: this page had better be static, and if any code inside it reaches for a per-request input, fail the build.
Those six files cover 510 of the site's 516 URLs, because four of them are collections:
| File | URLs |
|---|---|
app/[question]/page.tsx |
461 |
app/recipes/[slug]/page.tsx |
38 |
app/conditions/[slug]/page.tsx |
8 |
app/answers/page.tsx |
1 |
app/recipes/page.tsx |
1 |
app/hidden-gluten-ingredients/page.tsx |
1 |
The reason I care is not build speed. It is that the failure mode this guards against is silent. Read cookies() or headers(), destructure searchParams, or make an uncached fetch inside a page that was previously static, and Next does not complain: it quietly opts that page into per-request rendering. In development everything looks identical. In production you have swapped a file served from the edge for a function invocation, the page drops out of the CDN, and the only evidence is a response header nobody is looking at. I have measured those headers before; a prerendered page on this site had been sitting in the cache for twelve days, which is exactly the thing you lose by accident.
With dynamic = 'error', that accident is a red build instead.
There is a real thing it is protecting here. The homepage resolves the visitor's display currency from the request, so every piece of pricing code on this site is one import away from being per-request. If that helper ever found its way into a guide page, on one of those six files the build stops and names the file.
The sibling line on the biggest of them is worth a mention too: export const dynamicParams = false on the root dynamic segment, which is the difference between 461 pages and an unbounded number of soft 404s at the top level of the domain. I wrote that one up separately.
The five that are static by luck
510 of 516 is not 516. Six URLs are not covered by any of those six files:
- /conditions, the index for the eight guides
- /about
- /privacy
- /terms
- /licenses
- the homepage
The first five are plain prose. They declare nothing at all, and in production they are prerendered:
$ curl -sI https://munchable.app/privacy | grep -i nextjs
x-nextjs-prerender: 1
So the output is correct. The claim is missing. They are static because nothing in them currently touches the request, which is a property of today's code rather than a rule about tomorrow's. Fixing that is five lines, and the reason it has not been done is the ordinary one: the pages that got the assertion are the pages I was nervous about, and nobody is nervous about a privacy policy. The pages nobody is nervous about are exactly the ones a stray import lands in.
The one that may not be cached
The homepage is the sixth, and it is the other value:
/**
* Rendered per request rather than cached.
*
* Localising the price means reading the visitor's country from the request,
* which is incompatible with serving one cached copy of the page to everyone.
*/
export const dynamic = 'force-dynamic';
That is a deliberate trade, and the headers show it plainly. Every other page on the site answers with public, max-age=0, must-revalidate and x-vercel-cache: HIT. The homepage answers with:
cache-control: private, no-cache, no-store, max-age=0, must-revalidate
x-vercel-cache: MISS
The most visited page on the site is the one page that can never be served from the cache, because it is the one page that has to know something about you. It does no database work, so the invocation is cheap, and the alternative (cache a pound figure and correct it in the browser) is a visible price flicker on the first paint.
The 41, and the 14 that are decorative
Of the 41 force-dynamic declarations, 9 are pages, 1 is a layout, and 31 are route handlers.
The 9 pages are the homepage plus the eight screens behind a login: the admin panel and the support threads. Fine, all of those read a session.
The 31 route handlers are where the honesty runs out. I broke them down by which HTTP methods they export:
| Methods exported | Handlers |
|---|---|
| POST only | 13 |
| DELETE and POST | 1 |
| a GET, with or without others | 17 |
For the 17 with a GET, the declaration earns its place: a GET handler that does not read the request is a candidate for prerendering, and several of these would be badly wrong if they got cached. For the other 14 there is nothing to opt out of. A POST handler was never going to be prerendered. Those lines are there because the file next door had them, and they will stay in review forever because they are not wrong, just inert.
That is the pattern I keep finding when I audit my own route config. The useful declarations are the ones that can fail, and the useless ones look exactly the same in a diff.
Worth copying
If you have a Next app with a public content section, the five minute version is:
-
grep -rn "export const dynamic" appand group it by whether each file could even be static. - Put
dynamic = 'error'on every page you believe is static, including the boring prose ones, then build. If the build goes red, you learned something for free. - Check the live headers for
x-nextjs-prerenderon the pages you assumed were prerendered, because the assumption and the output are different claims.
You can run step 3 against this site: an answer page and the recipe index will both say x-nextjs-prerender: 1, and the homepage will not.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.