We translated four product sites into Spanish and Brazilian Portuguese. This is what it cost, measured rather than estimated, including the part we got wrong and did not notice for weeks.
The measurement
Every visitor to any page on this site downloads all three languages of all six message groups.
The shared chunk is 481 KB raw, 153 KB gzipped, and it appears in firstLoadChunkPaths for all
60 routes. The non-English catalogues on their own — every groups/*.es.ts and
groups/*.pt-BR.ts — are 384 KB of source, 352 KB with comments stripped, somewhere in the region of
80–110 KB gzipped.
So an English speaker reading the pricing page downloads the Spanish and Portuguese pricing page too. And the dashboard strings. And the email templates.
The assumption that was wrong
The catalogue loader is about as simple as it gets:
const CATALOGUES: Record<Locale, Messages> = { en, es, "pt-BR": ptBR };
export function messages(locale: Locale): Messages {
return CATALOGUES[locale];
}
The comment above it argued for static imports over lazy ones on three grounds: the catalogues are
small, they are needed during server rendering where there is no loading state to show, and a dynamic
import would push await into components that have no other reason to be async. All three still hold.
It also said: "Static also means the bundler can tree-shake a catalogue that a given route never references."
That part is false, and it is false for a reason that is obvious once you look at it. CATALOGUES is
indexed by a runtime value. The bundler cannot know which key messages(locale) will read, so it
retains all three. There is no analysis that recovers this; it is not a limitation of a particular
bundler.
The claim sat in a comment for weeks, sounding reasonable, and nothing contradicted it because
nothing measures bundle size in CI. There is no size budget, no check, no failing build. The
number was only found by going and looking at .next/diagnostics/route-bundle-stats.json.
That is the transferable lesson: an assumption written in a comment, in a codebase with no guard for the thing it asserts, is indistinguishable from a fact until somebody measures it.
Why it has not been fixed
Because the fixes are worse, and it is worth being explicit about why rather than filing it as debt.
Dynamic import per locale makes every message lookup async. That pushes await into components
that are otherwise synchronous, including client components, and it introduces a loading state for
text — which is the one thing on a page that should never arrive late.
Splitting by group helps and is the most plausible option. It also means every page has to declare which groups it needs, which is a new invariant with no test behind it, and the failure mode is a missing string in production rather than a build error.
Server-only catalogues would work if every consumer were a server component. They are not:
theme-toggle.tsx, language-switcher.tsx and site-header.tsx are client components calling
messages() directly, and that is what pulls the whole thing into the browser bundle.
The honest position is that 153 KB gzipped on a marketing site is a real cost, it is not the largest cost on the page, and the available fixes each trade it for a correctness risk. It is written down so the next person does not rediscover it, and so the tree-shaking sentence does not get believed again.
Four failures that pass every test
Bundle size was the measurable problem. These were the ones that shipped working software that was wrong.
A locale that could not serve its own language. /en/* is meant to redirect to the unprefixed
path, since English is the default and has no prefix. For a while it did that on the apex and not on
one of the product subdomains — so /en/pricing worked in three places and 404'd in the fourth. The
symptom looks like a missing translation rather than a routing bug, which is why it survived.
A canonical tag pointing at the wrong language. A Spanish page whose canonical names the English
URL is telling a crawler "I am a duplicate of that one" — and the entire translated tree drops out of
the index while every hreflang on the page still looks correct. The first version of our alternates
helper did exactly this, and the tests asserting the hreflang values all passed while it did.
The home page's share card. Next serves file-based metadata beside the page it belongs to, so
/docs/opengraph-image-<hash> sits under /docs and matches a route rule. For the root page the
same file becomes a top-level segment — /opengraph-image-<hash> — which matched no rule, was treated
as opaque, and answered 404. Measured in production: three subdomains each served a home page whose
og:image nobody could fetch, while the identical card on /docs returned a 41 KB PNG. Every share
card looks fine until somebody pastes the home page, which is the URL people actually share.
A translation that was never translated. A string copied from English into the Spanish catalogue
is a perfectly valid string. tsc is satisfied, every test passes, and the page renders. The only
thing that catches it is a test asserting that translated values differ from English, with an
explicit allowlist for the ones that legitimately match — "Blog" is the same word in all three
languages, and so is a XXXX-XXXX placeholder.
What we would keep
The guards, mostly. Three of the four failures above are now unit tests rather than lore:
- Every advertised
hreflangis checked against what the router actually serves, in both directions. - Catalogue parity checks keys, types, argument counts, and that no value was left in English.
- A vitest scan fails the build on any JSX text node of five or more sentence-shaped words outside the catalogue, with a file-level allowlist where every entry carries a written justification.
And one thing we would do differently: measure the bundle in CI from the start. Not because 153 KB would have changed the design — it probably would not have — but because the number would have been a fact from day one rather than a discovery, and the comment claiming tree-shaking would never have been written.