engineering
Fetching content at build time, and the cache that is not React's
latellu.com makes no network calls to its CMS in production. Every route under app/[locale] is prerendered to HTML during the build, served from a CDN afterwards, and the only process that has ever held an Atlas API key is the build itself. That is a deliberate choice, and it changes more than the deploy step. Once the fetch moves from request time to build time, a good deal of the caching advice written for the Next.js App Router stops applying, and at least one thing that looks like a mistake becomes the right answer.
A build is one process, a request is not
The difference that matters is lifetime. A request handler exists for a few hundred milliseconds and then everything it touched is garbage. A build exists for the length of the build: one Node process, or a small pool of them, walking every route in the site from the first to the last. Anything you put in a module-level variable during a build stays alive for all of it.
So the memo over our content client is a plain Map. src/lib/atlas-cache.ts holds one map from a string key to a promise, and every exported fetcher runs through a small memoize(key, run): return the cached promise if the key is present, otherwise call run() and store what it returns. Keys are boring on purpose. An entry fetch is entry:article:some-slug:en. A list fetch is list:faq: plus the JSON of its options object. Storing the promise rather than the resolved value is the part that earns its keep, because two routes rendering concurrently both reach the cache before either fetch has settled, and the second one should wait on the first rather than start a duplicate.
React's cache() is the idiomatic answer here, and in a request-time app it is the correct one. It dedups within a single render pass. Two components in the same tree asking for the same entry get one fetch, and the cache is discarded when the request ends, which is exactly what you want when the next request may need different data. What it does not do is dedup across pages, because at request time there is no across-pages to speak of.
In a build there is. Our blog detail page is the clearest case. generateStaticParams emits one route per article slug. Each route calls a load() helper that fetches the article, the full author list, and the fifty most recent articles, because the page renders a byline and a related-reading rail. Next then calls generateMetadata for the same route, which calls the same load() again. With cache(), those two calls collapse into one set of fetches and every other route pays the same bill again: the author list is fetched once per article. With a module-level map, the author list is fetched once for the whole build. Service pages have the same shape, each asking for fifty FAQs, thirty services, and fifty industries to build its cross-links, so the saving multiplies by the number of services rather than adding to it.
Per-worker dedup, not perfect dedup
Next forks worker processes for static generation. Module state is per process, so the map is per worker. With four workers you get up to four copies of every cached value and up to four fetches per key. That is fine, and it is worth saying out loud that it is fine: the goal is to turn a request count proportional to the number of routes into one proportional to the number of workers. Reaching exactly one was never on the table.
What is not fine is writing anything that assumes the cache is global. src/lib/atlas.ts keeps a warn-once Set so a failing fetch logs one line instead of one per call, plus a boolean that logs the local-fixture notice once. Both are per worker, so both can appear more than once in a single build's output. That particular case is cosmetic. The same assumption attached to something with a side effect, write this file once or call this webhook once, would not be.
Staleness you can put a number on
A cache with no expiry and no eviction is normally worth an argument. Here the argument is short, because the process is the build. The worst staleness this map can produce is the duration of one build. If an editor publishes in Atlas thirty seconds after the build starts, some pages carry the old value and some carry the new one, and the next build makes them agree. There is no long tail, and there is no path by which a stale value reaches a visitor an hour later.
Drop the same map into a long-running server and it is two bugs at once: a map that never evicts is a leak, and content that never refreshes is permanently wrong. The thing keeping those two situations apart is not discipline. It is one line in app/[locale]/layout.tsx: export const dynamic = "force-static". Its job in this codebase is to be a tripwire. If any page under that layout ever reads cookies(), headers(), or searchParams, the build fails. Without it, that route would quietly become per-request server rendering, which means an Atlas call on every request in production, served out of a cache designed on the assumption that this never happens. A silent switch from static to dynamic is the failure mode that turns a reasonable cache into an incident.
The route list is the content list
Two exports carry that guarantee. The locale layout exports generateStaticParams returning one entry per locale, and every dynamic child route returns the product of locales and slugs: a loop over LOCALES, a call to listEntries per locale, one pushed pair per item. The prerendered set is that product, and nothing else exists.
The second export is dynamicParams = false. Anything not in the generated list is a 404, not an on-demand render. It has two consequences worth naming separately:
- A slug that does not exist is a missing page, found at build, rather than a runtime error found by a visitor. The set of URLs the site serves is a fact you can print at the end of the build.
- An article published in Atlas after the build does not appear. It 404s until the next build. This is the trade, stated plainly, and it is the reason the section below exists.
One detail makes the whole thing cheaper than it sounds: generateStaticParams calls the same memoized listEntries that the pages call. The list it fetched to produce the route params is the same promise the page bodies read afterwards. Enumerating the routes costs nothing beyond the fetch you were going to make anyway.
server-only, and the module split it forces
A content client has to be unreachable from the browser. There are two ways that goes wrong, and only one of them is loud.
The loud one: atlas-cache.ts imports server-only, which throws if the module ever lands in a client graph. You find out immediately.
The quiet one is the expensive one. src/lib/atlas.ts imports createClient from @latellu/atlas-sdk and, for its fallback path, every fixture in src/data/demo.ts. A Client Component that imports one six-line helper from that module, say splitCsv for rendering a tag row, pulls the entire SDK and the entire fixture file into the browser bundle. Nothing errors. The page renders. The bundle just grows, and content that was never meant to be public ships as JavaScript to every visitor.
That is what forced a three-module split, and the split is defined by what each module imports rather than by what it exports:
src/lib/fields.ts, pure. TheEntryandPagedtypes plussplitCsv,parseTags,firstBlockData, anduniqueIndustries. Nothing in it touches the network, the filesystem, or a Node API, so it is safe from a Server Component, a Client Component, and a plain test runner alike.src/lib/atlas.ts, transport. The SDK client, the fixture fallback, and the mapping from the wire shape to ours. It re-exports everything infields.ts, so existing server-side imports kept working when the helpers moved out.src/lib/atlas-cache.ts,server-onlyplus the memo. Every page imports from here and never fromatlas.tsdirectly.
There is a third reason for that shape, and it is the one that usually gets discovered late. The pure helpers have unit tests that run in a plain Vitest context. server-only throws outside a react-server context, so putting that import anywhere near the helpers breaks the test suite rather than the bundle. The guard and the testable code cannot live in the same file.
The rule that falls out of this is not "separate server code from client code". It is: a module is only as portable as its heaviest import. Sort modules by what they drag in, and the boundary draws itself.
What it costs, and when to do the opposite
Publishing now requires a rebuild. An editor who fixes a typo in Atlas sees nothing on the site until a build runs. If a build takes four minutes, that typo takes four minutes. You can shorten the wait with a publish webhook that triggers a build, or a schedule, or on-demand revalidation for a subset of routes, but the last option reintroduces request-time fetching for exactly those routes and the reasoning in this article stops covering them.
Build-time fetching is the wrong default in at least three situations. Content that changes hourly, where the rebuild latency is the product. Content that differs per user, which cannot be prerendered at all. And route counts where the build no longer fits: ten thousand product pages at a couple of hundred milliseconds each is a build measured in hours, and no amount of per-worker deduping fixes a fetch that is genuinely unique per route. At that size the answer is request-time rendering with a real cache: a TTL, an eviction policy, and a revalidation story. Everything above then inverts. cache() becomes correct, and the module-level map becomes the leak it always was in that setting.
The rule of thumb we use: decide where the fetch happens before you pick a cache, then match the cache's scope to the lifetime of the process that owns it. A render pass gets cache(). A build gets a map. A server gets something that forgets. Most cache bugs are the pairing being wrong in one direction or the other, either a cache that expires before it ever gets a second reader, or one that never lets go of anything.