Munchable's marketing site has about 430 pages. Roughly 420 of them are files on a CDN with no server involved. The homepage is rendered from scratch on every single request. Some of the rest are rendered per request and told never to be indexed.
None of that is a performance strategy. Each mode is a consequence of what the page owes its reader, and the interesting part is that Next.js will let you get every one of them wrong without saying anything.
The site is munchable.app if you want to poke at the headers while you read.
The homepage will not be cached, and that is the feature
The pricing section shows the price in the visitor's own currency. Resolving it means reading a geolocation header off the request, and a page that reads the request cannot be one cached copy served to everybody.
/**
* 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.
* The alternative, caching GBP and correcting it in the browser, is what
* produces a visible price flicker, so the cache is what gives way.
*/
export const dynamic = 'force-dynamic';
The alternative is the one everybody reaches for first: cache the page with a base price and fix it up in the browser. Every version of that either flickers or fails silently. React state paints the wrong number then swaps it. A pre-paint inline script avoids the flicker and now you have a render-blocking script whose failure mode is a wrong price rather than no price. CSS variants ship every currency to everybody.
So the cache gives way instead. The page does no database work, so per-request rendering costs a few milliseconds of template assembly, and the price is correct in the first frame of the first paint with no JavaScript involved. A page that renders fast and shows the wrong number is worse than one that renders correctly.
Two details fall out of that decision and are worth stealing.
Crawlers are pinned to one currency. Googlebot crawls from a datacentre, which geolocates somewhere, which means a site doing this naively gets its dollar prices indexed for a business that charges in pounds. Bots get the base currency, which is also the currency the page's structured data declares and the one an unlisted country is charged:
if (isBotUserAgent(headerList.get('user-agent'))) {
return BASE_CURRENCY;
}
There is no country cookie, and the reason is not technical. Our sister product mirrors the country into a cookie because its pricing page is cached and a cached page cannot read the request. Here every surface that shows a price renders per request, so the header is always available and the cookie would add nothing. It would also cost something real: the privacy policy states that the site sets strictly necessary cookies only, and that this is why there is no cookie banner. A currency-preference cookie is not strictly necessary when the header already answers the question, so writing one would turn a true statement into a false one. That is a big price for a convenience you do not need.
The content pages fail the build if they try to read a request
The other 420 pages are the opposite. Ingredient answers, recipes, condition guides: all generated from data in the repo, all identical for every visitor, all built once.
Making that true is not export const dynamic = 'force-static'. It is:
export const dynamic = 'error';
This is a tripwire rather than a mode. It does not make the page static, it makes the build fail if anything anywhere in that page's tree does something that would force it dynamic. Read headers(), read cookies(), touch searchParams, and instead of quietly getting a per-request page you get a build error naming the route.
That matters because the thing you are defending against is not a developer typing force-dynamic by mistake. It is a shared helper three levels down acquiring a headers() call for a good reason on some other page. Without the tripwire, 373 CDN files silently become 373 server renders, everything still works, and you find out from a bill or a latency chart weeks later.
I would put this on every page that has no business being dynamic. It costs one line and it converts an invisible regression into a red build.
The line that stops a catch-all indexing your typos
The answer pages live at app/[question]/page.tsx. A dynamic segment at the site root, because the URL should be the question and nothing else: /is-onion-low-fodmap, not /answers/questions/onion/low-fodmap.
A root-level dynamic segment is a catch-all for every unmatched top-level path. Which means, by default, it answers them:
/**
* A dynamic segment at the site root, so `dynamicParams = false` matters: without
* it this route would answer ANY unmatched top-level path, turning every typo into
* a soft 404 that a crawler would happily index.
*/
export const dynamic = 'error';
export const dynamicParams = false;
export function generateStaticParams() {
return ANSWER_PAGES.map((page) => ({ question: page.question }));
}
Without dynamicParams = false, /is-onion-low-fodmapp renders the route, finds no matching page, and returns whatever you wrote for that case. If that is notFound() you get a soft 404 at a 200-ish shape; if it is anything friendlier you get an indexable page for a URL that does not exist. Every mistyped link on the internet becomes a page.
With it, the only paths this route serves are the ones generateStaticParams listed. Everything else is a real 404 from the edge, with no render at all.
There is a second guard in the data layer for the mirror-image problem. A generated slug that collided with a real route would be a page nobody could reach, because Next resolves static routes before dynamic ones, so /privacy would win and the answer page would vanish silently. That one throws at module load, so it is also a failed build:
if (RESERVED_PATHS.has(question)) {
throw new Error(`Answer page slug "${question}" collides with a real route.`);
}
Both guards exist for situations that cannot currently happen. Every slug starts is- or does-, and the params list is exhaustive. That is exactly when to install them, because the day the phrasing table grows a fourth shape, nobody will be thinking about route resolution order.
Dynamic and deliberately invisible
The fourth mode is the small set of pages that are per-request and must never be indexed. A support conversation at its own URL, for instance:
export const metadata: Metadata = {
title: 'Support',
// A conversation is private to its owner and has nothing to offer a crawler.
robots: { index: false, follow: false },
};
export const dynamic = 'force-dynamic';
The page itself is a thin wrapper. The client component owns the fetch and the API behind it answers 404 for a ticket that is not the caller's, so the route being guessable gives nothing away. noindex is not the security control, it is tidiness: the security control is the API, and the metadata just stops a private conversation showing up in a search result if somebody pastes a link somewhere public.
The admin pages carry the same pair for the same reasons.
The shape of the decision
It comes out as four buckets, and the question that sorts them is not "is this page fast enough" but "what does this page's content depend on":
- Depends on the request, public:
force-dynamic, and pin crawlers to a stable variant. - Depends only on the repo:
dynamic = 'error', so it can never quietly stop being true. - A catch-all route:
dynamicParams = false, always, or you are serving your own 404s. - Depends on who is asking:
force-dynamicplusnoindex, with the real check in the API.
The pattern across all four is that the correct mode is asserted rather than assumed. Next.js is good at silently doing the reasonable thing, and the reasonable thing is a per-page decision it cannot make for you.
Have a look at the outputs: the static ones are at munchable.app/answers and munchable.app/recipes, and the dynamic one is the price on munchable.app/#pricing, which is already correct in the HTML before any script runs.
Top comments (0)