Fifteen Locales Is Not Fifteen Translations
Translating the strings is the part everyone budgets for. The part that actually breaks is everything around the strings — the metadata, the fallbacks, the one link that stays stubbornly English.

Fifteen Locales Is Not Fifteen Translations 🌍
The plan says: extract the strings, send them out, put them back, ship fifteen languages. The plan is not wrong, it is just describing a fraction of the work. What actually consumes the time is the surface area around the strings — the places that quietly hold a default while everything near them changes.
Here is what we kept finding, roughly in the order it embarrassed us.
The dictionary is not the whole surface
A translation file covers the text a component renders. It does not cover:
- Page metadata.
<title>and description are frequently assembled in a layout or agenerateMetadatafunction that nobody thought of as UI. A fully translated page can still announce itself in English in the browser tab, in search results, and in every link preview anyone shares. This is the single most common miss, because the page looks right. - Navigation and footer entries defined in a config file rather than a component. Config is data, data does not get flagged by a "no hardcoded strings" lint rule, and so a nav label can sit untranslated across every locale for months.
- Alt text and aria labels. Invisible to a visual review, entirely visible to a screen reader.
- Error and empty states. Rarely rendered during review, which is exactly why they survive.
- Anything server-generated — emails, receipts, notification copy. The user's locale has to travel from the request all the way to the template, and usually there is one hop where it does not.
The pattern is consistent: things get translated in proportion to how often someone looks at them, not how often a user encounters them.
Missing keys should be loud in development and invisible in production
Two failure modes, opposite fixes.
If a key is missing and the app renders the raw key path, you ship nav.products.subtitle to a real user. If it silently renders empty, you ship a blank space and nobody notices for a release or two.
What works: fall back to the default locale's string in production, and make it scream in development. The user gets English — imperfect, but a real sentence. The developer gets a console error, or better, a build-time check that every locale has every key the default locale has. That check is twenty lines and it is the single highest-value piece of i18n infrastructure you can write.
Text has a size, and the size changes
The same sentence in German is routinely 30% longer than in English. In Chinese and Japanese it is often much shorter but taller, and the line-break rules are different. Korean sits in between with its own spacing behavior.
You do not need a typographic theory. You need to stop building layouts that assume the English length:
- Don't size a button to its label; size it to its content with padding and let it grow.
- Don't put two fixed-width columns next to each other and trust the gap.
- Don't use ellipsis truncation on anything a user has to read to make a decision.
- Do look at the longest locale, not the shortest, when checking a layout. If it survives German, it survives.
A translation can be grammatical and still wrong
The one that cost us the most review time was not a mistranslation. It was a Korean sentence that, after a copy change, ended up with two consecutive connective endings — grammatically parseable, but it read like someone had stapled two half-sentences together. The fix was to split it into two sentences, which no glossary or terminology check would ever have suggested.
This is the structural limit of translating strings rather than meaning. When you change English copy, the corresponding change in another language is sometimes not the same change. Joining two clauses works in one grammar and reads as a stutter in another.
The practical guard is not more process. It is: when copy changes in a way that alters sentence structure, flag it for a native read rather than a key-for-key swap. Adding a qualifying clause is exactly the kind of edit that does this, and it is also the kind that seems too small to bother anyone with.
What we would build first, next time
In order of return on effort:
- A build-time key-completeness check across every locale. Catches the largest number of real defects for the least code.
- Metadata localization from day one, including
<title>and any Open Graph fields. Retrofitting it means touching every route. - A rule that config files are translated too — or better, that user-visible strings never live in config.
- Longest-locale layout review as part of any UI change, not a separate pass.
None of this is about translation quality. It is about the fact that "add a language" is an architectural property of the app, and the strings were always the easy part.