I ship a release tracker called Verdict. It watches 37 technologies, and for every release that matters it writes a summary with an explicit verdict: action required or safe to upgrade. Last week I translated its interface into five languages: English, Italian, French, Spanish, German.
The content it shows cannot be translated. That single constraint decided almost every other thing in this work.
Why the content stays in English
Two kinds of text live on a release page, and neither one can be translated.
The release notes are the project's own words, kept character for character, because every claim in a summary points at the line it was read from. Translate the notes and the pointer points at nothing: the one check a reader can run on an LLM's output stops working.
The summaries are generated. They are already the expensive part of the product, and every extra language would be one more model call per release. They run on Claude Haiku 5.5 through the Batch API, which is cheap, but cheap times five languages times every release of 37 technologies is not a rounding error.
So a release page in Italian carries an Italian interface around an English summary of English notes. It is a mixed page. The honest options were to hide that or to say it, and the page says it: a one-line notice above the verdict, printed only when there is actually a second language on screen: never on an English page, and never on a release that has no notes and no summary to be in another language.
The locale goes in the URL because a cookie would cost the whole site
The tempting design is a cookie: no URL changes, no redirects, nothing indexed moves.
It would also have made every page dynamic. Reading a cookie at the root turns the whole tree into per-request rendering, and the site prerenders its public pages with revalidate = 3600. I had already refused that price once, for the theme toggle. Paying it for the language would have been the same mistake with a better excuse.
So: the locale is a path prefix, English has no prefix, and the build prerenders 70 static pages, the same pages as before, times five. The locale arrives as a root param, which any server component can read, and a provider carries it to the client half where the links live.
One consequence that is easy to miss until it bites: next/link is imported in exactly one file. A link that forgets the prefix drops the reader back into English halfway through a path, and that is precisely the kind of bug that does not show up in a typecheck. Everywhere else the import reads LocaleLink as Link, so each component's diff was a single line. Canonicals, hreflang, the sitemap and robots.txt are all derived from the same path helper, so they cannot disagree with each other.
The dictionary is 200 lines, not a dependency
I wrote the translation layer instead of installing one: a message module with Zod schemas and {named} placeholders, a pure createTranslator(locale, dictionary), and one file per namespace (seventeen of them by the end).
Three decisions in there earned their keep.
Plural rules come from Intl.PluralRules. The platform already knows the rules for five languages. What was left to write was choosing the form, not the rules.
The fallback lives in the registry, not in the translator. Each locale's dictionary is merged over English once, at startup. t stays a lookup, and the browser receives one object instead of two.
An unfilled placeholder stays on screen with its braces. {count} showing up in the UI is ugly, and that is the point: it is the loudest possible way to say the caller forgot an argument.
And the dictionary is passed down as a prop from the layout. A client component that imported the dictionaries directly would have shipped all five languages in the bundle.
The test that keeps English out of production
A missing key is the worst kind of i18n bug, because nothing breaks. The string renders in English, on an Italian page, and says nothing about itself. Users read it as a typo and move on.
So there is a parity test across dictionaries: same keys, same placeholders, and the plural forms that that language actually selects. Italian needs a many form for numbers like 1000000; its text is identical to other, but the test demands it be written, because a missing many is a half-translation that only shows up on a big number.
That test is what makes the English fallback a safety net instead of a shipping strategy.
Translating the words is the easy half
The layout is the other half. Four verdicts say less in Italian than in English, and not for reasons of style: at the panel's width, the line holds 26 characters including the label. The copy yields; the type scale does not.
| English | Italian |
|---|---|
action required (for n) |
{count} interventi |
nothing requires action |
nessun intervento |
notes too thin to judge |
note non giudicabili |
Only one of those got better: note non giudicabili is the name the domain glossary already gave those notes.
Grammar pushes back too. German needed two verbs for one. folgen takes the dative, so it works in every sentence and on every button, but it forms no attributive participle: gefolgte Technologien is not German. Those positions read verfolgt instead, which is the verb the catalog was already using for tracking.
And some words refuse to move. featured, notable, digest, preview, and the prefixes breaking[n]:, note:, warning:, ok: stay English in every language, because they are the same tokens that appear inside the summaries, which stay English. Translating the label while the content keeps the original word would break the identity between the interface and what it is describing. The landing page's legend is translated, because the legend is where those words get explained, and explaining them is exactly its job.
I got the rule wrong on the first pass, and the fix is worth stating: what stays English is the name as an identifier, in code, in the database, in keys and values. Inside a sentence you use the word the language actually uses. "Le Release delle Technology che segui" is not Italian, it is a glossary leaking into prose.
Four traps, all found by tests
-
usePathname()returns the rewritten path. On an English page it reads/en/faq, not/faq. The language picker built its options from that and sent readers to/it/en/faq, a 404. -
The route matcher could not exclude paths containing a dot. The usual
.*\..*pattern excludes static files, and with them/technologies/vite/7.1.0, which is every release page in the product. -
An unknown path has to stay inside its locale. Without a catch-all that calls
notFound(), a bad URL fell out of the locale tree and got the framework's bare 404: no shell, no theme, no footer. - The header had to become a client component. The error boundary renders it, and the root-params API has no client export, so a header reading the locale on the server broke the build on the error page.
None of these were visible to the typechecker. All four came out of end-to-end tests against a production build. Those tests were already green before any string moved, which is how I knew the English site had not shifted under me.
What I am not claiming
French, Spanish and German have not been reviewed by native speakers. The decision record says so in writing, and the review is on the list before launch, because the alternative is letting three languages pass as verified when they are one developer and a lot of care.
The legal pages carry one more line that is not a translation: terms and privacy end by saying which language version prevails. Five translations are not five contracts. The three other document pages do not carry it, because there a bad translation is a mistake to fix, not a conflict to arbitrate.
Verdict is still pre-launch: release-tracker-drab.vercel.app. If you want to see the mixed-language page, here is React 19.3.0 in Italian.
Top comments (0)