Internationalization
Not a translation table bolted on later: plural rules, locale-aware formatting, text that grows, and a layout that has to work in both directions.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
What has to be true about this interface before it can exist in another language?
The product is launching in three more countries. People there expect an interface that reads naturally, not one that reads like it was translated from English by someone in a hurry.
Extract the strings into a JSON file, hand it to translators, and look up by key at render time. Everything else stays as it is.
Sentences get assembled from fragments — "Deleted " + count + " items" — and word order differs by language, so the translator receives pieces that cannot be reassembled correctly in theirs.
- Sentences get assembled from fragments —
"Deleted " + count + " items"— and word order differs by language, so the translator receives pieces that cannot be reassembled correctly in theirs. - Pluralization is treated as "one versus many". Many languages have three, four or six plural categories, and several have different rules for 2, for numbers ending in certain digits, or for zero. English is unusually simple, which is why the assumption survives.
- Dates, numbers and currency are formatted by hand, so
03/04means March 4th to some users and 3 April to others, and a decimal comma is read as a thousands separator. - German and Finnish strings are routinely much longer than the English original, so fixed-width buttons and single-line labels break in exactly the places nobody tested.
- Right-to-left languages need the layout mirrored, not just the text — and every
margin-leftandtext-align: leftin the codebase is now a bug (Fluid Layout First). - Sorting uses code-point order, so accented names land in the wrong place and the user concludes the list is broken.
What is actually happening
In the browser, not in the framework.
- A locale is not a language. It is a language plus a region plus conventions —
de-DEandde-ATshare a language and differ in formatting, anden-GBanden-USdiffer in date order in a way that silently produces wrong answers rather than obviously wrong ones. - The platform ships a full internationalization library:
Intl.NumberFormat,Intl.DateTimeFormat,Intl.PluralRules,Intl.RelativeTimeFormat,Intl.ListFormatandIntl.Collator. It knows the rules for every locale the browser supports, and it is already in the bundle at zero cost (The Real Cost of JavaScript). - `Intl.PluralRules` is the answer to the plural problem: it returns a category for a number in a locale, and the translation supplies a string per category. Your code never decides which form to use.
- Message formatting — a single translatable string with named placeholders and inline plural/select rules — is what lets a translator control word order. Concatenation is what takes that control away.
- Logical properties (
margin-inline-start,padding-block,text-align: start,inset-inline) are resolved against the writing direction, so one stylesheet works in both directions instead of two that drift apart (The Box Model). - The
langattribute on the document, and on any element that switches language, is what tells the browser which hyphenation, font and — critically — which pronunciation a screen reader should use.
What this makes the browser do
And which of it is avoidable.
Intlformatters are relatively expensive to construct and cheap to reuse. Constructing one inside a render for every row of a table is a real and commonly-shipped cost (What a Component Costs to Render).- Loading only the active locale's messages rather than all of them — otherwise every user downloads every translation (Code Splitting).
- Font loading for scripts the primary font does not cover, which is an extra critical-path resource for exactly the users you are trying to serve better (Images and Fonts).
- Re-layout when direction flips, and when longer strings change wrapping (The Box Model).
The sentence is the unit
Almost every serious internationalization defect traces back to one decision: whether the translatable unit is a sentence or a fragment. A translator handed fragments cannot reorder them, cannot agree the adjective, and cannot choose a plural form that depends on a number they were not given.
The fix is a message format — one string, named placeholders, plural and select rules inline — so the translator holds the whole structure and the code supplies only values.
t('you_have') + ' ' + count + ' ' +
(count === 1 ? t('message') : t('messages')) + ' ' + t('unread')
// the translator receives four disconnected strings
// and an English assumption about plural formst('unread_count', { count })
// en: {count, plural,
// one {You have # unread message}
// other {You have # unread messages}}
//
// pl: {count, plural,
// one {Masz # nieprzeczytana wiadomosc}
// few {Masz # nieprzeczytane wiadomosci}
// many {Masz # nieprzeczytanych wiadomosci}
// other {Masz # nieprzeczytanej wiadomosci}}The second version gives the translator the whole sentence and the plural categories their language actually has, and lets them put the number wherever their grammar requires. The first hardcodes English word order and English plural rules into application logic, where no translator can reach them and no reviewer who reads English will notice.
1// plural category for this number, in this locale2new Intl.PluralRules('pl').select(2) // 'few'3new Intl.PluralRules('en').select(2) // 'other'4 5// formatting, without inventing separators6new Intl.NumberFormat('de-DE').format(1234.5) // '1.234,5'7new Intl.NumberFormat('en-US', { style: 'currency',8 currency: 'EUR' }).format(1234.5) // '€1,234.50'9 10// sorting that respects the alphabet11['Ö', 'Z', 'A'].sort(new Intl.Collator('sv').compare) // A, Z, Ö12 13// construct once, reuse — building these per row is a real cost14const money = new Intl.NumberFormat(locale, { style: 'currency', currency })None of this is a dependency. It is in every browser, it knows more about locale rules than any table you would write, and the last line is the one people miss.
Layout that survives another language
Translation changes the physical shape of the interface. Strings get longer, sometimes dramatically, and in right-to-left languages the whole layout mirrors. Both are cheap to handle up front and expensive to retrofit, because the fix touches every stylesheet.
Logical properties are the mechanism: write margin-inline-start instead of margin-left, and the browser resolves it against the writing direction. Setting dir="rtl" then mirrors the layout without a second stylesheet.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Translated to German | Button labels truncate or overflow | Fixed widths sized to the English string | Fluid sizing and a pseudo-locale build in CI that inflates every string (Fluid Layout First). |
| Switched to Arabic | Text mirrors, chevrons and progress bars do not | Physical CSS properties and directional icons that do not follow dir | Logical properties throughout; flip directional icons explicitly and leave non-directional ones alone. |
User in en-GB | A date is read as the wrong day | Manually formatted MM/DD presented without a locale | Intl.DateTimeFormat against the user's locale (Timezones and Locale Formatting). |
| A locale ships late | Raw keys visible in production | Fallback returns the key rather than the source language | Fall back to the source locale, and alert on fallback rate per locale. |
| Screen reader on a mixed-language page | A foreign phrase is unintelligible | No lang on the element that switches language | Set lang on the document and on any element in a different language. |
| Names list sorted | Accented names sort to the end | Default comparator uses code-point order | Intl.Collator for the active locale. |
| Physical (breaks in RTL) | Logical (works in both) | What it resolves to |
|---|---|---|
margin-left | margin-inline-start | Left in LTR, right in RTL |
padding-right | padding-inline-end | Right in LTR, left in RTL |
text-align: left | text-align: start | The reading direction's beginning |
left: 0 | inset-inline-start: 0 | The reading direction's near edge |
border-left | border-inline-start | The near edge's border |
width / height | inline-size / block-size | Along and across the text flow |
Where the work actually goes
Internationalization is usually described as a feature and behaves like a constraint: it changes how you write strings, how you write CSS, and what you can assume about layout. Adopted early it is nearly free; adopted late it is a sweep through every component in the codebase.
The pipeline below is worth making explicit because the handoffs are where things go wrong — most commonly at extraction, where a string assembled at runtime simply never appears for a translator to translate.
- 1Author
Write a whole sentence with named placeholders and a context note.
fails by Concatenating fragments, which cannot be reordered in any other language.
- 2Extract
Static extraction pulls messages into a catalogue.
fails by A dynamically-built key, which extraction cannot see and which silently never gets translated.
- 3Translate
A human supplies each plural category for their locale.
fails by No context, so an ambiguous word is translated as the wrong part of speech.
- 4Load
Ships only the active locale to the browser.
fails by Bundling every locale, so all users pay for all languages (Code Splitting).
- 5Format
Intlrenders numbers, dates and plurals for the locale.fails by Hand-formatting, which is wrong somewhere and looks right where it was written.
- 6Lay out
Logical properties and fluid sizing absorb length and direction.
fails by Physical properties and fixed widths, discovered by users rather than by CI.
How to build it
Most important first.
- Make the translatable unit a whole sentence with placeholders, never a fragment. The translator needs the sentence to reorder it.
- Use
Intl.PluralRulesand let the message catalogue carry one form per category. Never writecount === 1 ? ... : ...in application code. - Format every number, date, currency and list with
Intl, against the user's locale, at the moment of display. - Design for text that is substantially longer than the source. Test with a pseudo-locale that inflates every string — it finds truncation and overflow before a translator does (Responsive Typography).
- Use logical properties throughout, and set
diron the document. Then RTL is a configuration change rather than a second stylesheet. - Give translators context. A key called
submitwith no note produces a different word depending on whether it is a button or a heading, and they cannot tell which. - Sort with
Intl.Collator, never with the default comparator. - Never put text in an image, and never rely on word order in a layout — both make translation impossible rather than merely awkward.
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- The
langattribute is an accessibility feature before it is anything else: a screen reader uses it to select pronunciation, and text marked with the wrong language is read in the wrong accent and is often unintelligible (Semantics Are Behaviour). - A phrase in another language inside a page needs its own
langon that element, or it is read with the surrounding language's rules. - Text expansion and accessibility pull in the same direction: a layout that survives a 40% longer string is also a layout that survives a user increasing text size (Responsive Typography).
- RTL is not only a language question. Mirroring must include focus order expectations, and directional icons must flip while non-directional ones (a clock, a checkmark) must not (Keyboard Operability).
- Never convey meaning through word order alone in a layout, and never build a sentence from separately-positioned DOM nodes — a screen reader reads them in DOM order, which may not be the visual order in any language (The Accessibility Tree).
What can go wrong
- Concatenated sentences that cannot be reordered, producing translations that are grammatically wrong in a way engineers never see.
- A missing key falling back to the raw key, so production shows
checkout.confirm.buttonto a user. - A pluralization that is correct in English and wrong everywhere else, which reviews never catch because the reviewer reads English.
- Hardcoded date parsing of a localized string, which breaks the moment the locale changes the separator.
- An RTL layout where the text mirrored and the icons, progress direction and chevrons did not, so "next" points backwards.
- Locale detected once and cached, so a user who changes their preference keeps getting the old one.
- Translated strings are content, and if any of them are ever user-supplied or community-contributed they are untrusted content. A message catalogue rendered as HTML is an injection sink like any other (Cross-Site Scripting).
- Locale is user-controlled input. A locale string taken from a URL or header and used to build a file path is a path-traversal risk; validate against a known list.
- Right-to-left override characters can be used to disguise text — a filename that appears to end in
.txtand does not. Where you display untrusted strings, be aware that visual order and logical order can be made to disagree.
- "Internationalization is translation." Translation is one part. Formatting, pluralization, direction, sorting and layout resilience are the rest, and they are the parts that produce wrong answers rather than awkward ones.
- "We only support English for now." That is a reasonable product decision and it is not a reason to concatenate sentences. Retrofitting is far more expensive than not foreclosing.
- "
toLocaleDateStringis enough." It is a good default and it is not a substitute for deciding *which* locale and *which* timezone (Timezones and Locale Formatting). - "RTL is just
direction: rtl." Text flows; icons, animation direction and physical CSS properties do not follow automatically. - "Plurals are singular and plural." That is true of English and a minority of languages.
Measuring it, and what changes in the field
- Missing-key and fallback-rate telemetry per locale — the fastest signal that a release shipped untranslated strings (Frontend Error Tracking).
- Layout-overflow detection in a pseudo-locale build, run in CI rather than discovered in a screenshot from a user.
- Locale distribution in field data, which usually reveals that a locale nobody prioritised has real users (Real User Monitoring).
- Bundle size per locale, to confirm users are not downloading translations they will never see (Bundle Analysis).
- On a slow network, shipping every locale is a direct and entirely avoidable cost to every user.
- At scale, the translation pipeline becomes the constraint: strings must be extractable, reviewable and shippable independently of code, or releases start waiting on translation.
- In a product with user-generated content, the interface locale and the content locale differ, and both need marking.
- Message formatting with placeholders is more ceremony than string concatenation, and it is the only form that survives translation.
- Loading locales on demand saves bytes and adds a loading state on language switch.
- Logical properties are less familiar than physical ones and remove an entire class of RTL bug permanently.
- A pseudo-locale build costs a CI step and catches the expansion problems that otherwise reach translators as bug reports.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALPlural categories, locale-dependent formatting and text expansion are properties of human languages, so they hold regardless of framework, platform or library choice.
- BROWSER-SPECIFICWhich locales
Intlsupports and how completely differs by browser and by operating system, since engines often defer to system locale data — so a formatted string can differ between two users on the same site, and anything requiring byte-identical output across clients must be formatted server-side. - FRAMEWORK-SPECIFICMessage extraction, catalogue loading and locale switching are library concerns and differ substantially between ecosystems; what does not differ is that the message must be a whole sentence with placeholders rather than assembled fragments.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Software Design — the message catalogue is an interface between engineers and translators, and it fails in the ordinary way interfaces fail: when one side can express things the other cannot act on.