Definitions
Four words, often used as if they meant the same thing. They don't.
Internationalisation (i18n)
Building the product so that it can be used in any locale — currency formatting plugs in, dates render per region, layouts breathe under longer text. The engineering side.
Localisation (l10n)
Adapting the product for a specific locale. Translates copy, swaps formats, accounts for cultural fit. The product side.
Translation
Converting text from one language to another. One step inside localisation; not the whole job.
Locale
A linguistic region — a language paired with a country. en-GB ≠ en-US ≠ en-AU. Always specify both halves; the language alone hides decisions about format, spelling, and convention.
The British English default
Source copy is written in British English (en-GB). Every other locale is a translation of that source. There's no “international English” fallback — that's a euphemism for stripping voice and leaving stiff sentences nobody enjoys.
Why en-GB and not en-US? Two reasons. The product is European-first — seven of eight markets sit in Europe — and en-GB is closer to the conventions our local translators expect. And en-US is one more locale that gets translated, so the codebase doesn't privilege it as the default.
The team's working language is also en-GB
Strings live in en-GB in the source. PRs use en-GB. Slack messages about product copy use en-GB. The translation step happens at build / handoff time — not in the middle of a discussion about whether a button should say “Save” or “Save changes”.
Markets and locales
Eight markets, four currencies, ten languages — counting English in each country as its own register. Primary is the local language; alternate is what a non-resident English speaker in that country falls back to.
| Market | Locale | Currency | Primary language | Alternate |
|---|---|---|---|---|
| United Kingdom | en-GB | GBP | British English | — |
| Denmark | da-DK | DKK | Danish | English (en-GB) |
| Finland | fi-FI | EUR | Finnish | Swedish (sv-FI), English (en-GB) |
| Germany | de-DE | EUR | German | English (en-GB) |
| France | fr-FR | EUR | French | English (en-GB) |
| Italy | it-IT | EUR | Italian | English (en-GB) |
| Netherlands | nl-NL | EUR | Dutch | English (en-GB) |
| United States | en-US | USD | American English | — |
Finland gets two primary languages — Finnish and Swedish — both official. Translation has to ship for both at the same time; we don't backfill Swedish later.
Writing copy that survives
A copy decision the writer makes in en-GB has consequences for the translator and the layout. These four rules are the difference between a string that translates cleanly and one that breaks.
Plan for text expansion
Translated copy is, on average, 30–50% longer than English. Short labels can inflate by 200–300% — German articles alone add weight, and Finnish compounds things further. Layouts that scrape past in English break in Helsinki.
en-GB
Settings
8 chars
de-DE
Einstellungen
13 chars · +63%
fi-FI
Asetukset
9 chars · +13%
- Use flexible layouts. Single column or grid with
auto-fit, minmax()over fixed widths. - Don't set narrow column widths on labels or buttons that pretend the English string is the upper bound.
- Test components with pseudo-localised strings — bracket each token with extra characters — to surface overflow before translation lands.
Plan for word order
German puts the verb at the end. Finnish reorders things English speakers expect to be subject-verb-object. If the source copy assumes English word order, the translation will twist the layout to match.
- Don't hyperlink full phrases. Word order shifts may fragment them in other languages.
- Don't place icons or buttons inside the middle of a sentence. The translator can't move them.
- Treat each sentence as one atomic string. Don't expect translators to piece sub-strings together.
No concatenation
Stitching strings together at runtime makes it impossible for translators to reorder words. Pass the variables into the template; let the translation framework decide where they sit.
ts
// ✗ Concatenated — translators can't move "files"
const msg = t("upload.success.prefix") + " " + count + " " + t("upload.success.suffix");
// ✓ One template, named placeholders
const msg = t("upload.success", { count });
// "Uploaded 12 files" (en-GB)
// "12 Dateien hochgeladen" (de-DE)
// "Tiedostoa ladattu: 12" (fi-FI)No UI inside sentences
A button or input rendered inside a sentence locks word order. Lift the UI out, write the sentence as a sentence, and let the form sit beside it.
No
Show me 10 prototypes per page
The number-input is glued into the middle of the sentence. Word order in de-DE or fi-FI lands the input somewhere it doesn't fit visually.
Yes
Prototypes per page
10Label, input. Two atoms. Translation rewrites the label freely; the input never moves.
Plurals and gender
Six plural categories exist across our languages — zero, one, two, few, many, other — and not every language uses the same rules. Hard-coding count === 1 ? "item" : "items" breaks Russian, Polish, Welsh, and Arabic the moment they land. Use ICU MessageFormat. The framework picks the right form per locale.
ts
// ICU plural form
t("prototype.count", { count }, {
en: "{count, plural, =0 {No prototypes} one {1 prototype} other {{count} prototypes}}",
da: "{count, plural, =0 {Ingen prototyper} one {1 prototype} other {{count} prototyper}}",
de: "{count, plural, =0 {Keine Prototypen} one {1 Prototyp} other {{count} Prototypen}}",
fi: "{count, plural, =0 {Ei prototyyppejä} one {1 prototyyppi} other {{count} prototyyppiä}}",
});Same applies to gender. French agreements, German articles, and Italian adjective endings change with the noun's gender — the source copy needs to expose enough context for the translator to pick the right form.
Names, addresses, and forms
- Don't split names into first / last. Use one full name field. Some cultures order family-name-first; some list multiple surnames; some use a single mononym.
- Address formats vary by market. Postal code position (before vs. after the city), state/region requirement (US yes, EU mostly no), house-number-then-street vs. street-then-number.
- Phone-number inputs need a country prefix.Pre-fill from the merchant's registered country, but allow change.
- Mark required fields consistently. Conventions on whether an asterisk indicates required or optional differ — pick required-with-asterisk and stay with it across the product.
Icons and color
Most icons travel well; a few don't. Hand gestures, religious symbols, and anything that pairs with text in a culturally specific way (a piggy bank for savings, a mailbox shape) are worth checking with someone in-market.
Color associations also shift — green and red mean “up/down” in some markets, “down/up” in others, and stand for entirely different political signals across them. The system pairs color with copy rather than relying on color alone, which side-steps most of the trap.
RTL preparedness
None of our current markets are right-to-left. We still write CSS as if one could be — logical properties (margin-inline-start, padding-inline) over physical ones (margin-left). It costs nothing now and saves a refactor when the first RTL market lands.
Translation handoff
Five steps. The order matters; skipping any of them loads work onto the next.
- 1
Lock the source
Source copy in en-GB ships to translators only after product review. Late edits to en-GB cascade through every locale.
- 2
Provide context
Each string carries a short note for the translator — what it labels, where it appears, and the variable types if any. A button label and a column header that read identically in en-GB often need different translations.
- 3
Translate
In-market translators, not machine translation. Machines miss the voice every time.
- 4
QA in-app
Pseudo-localise first to catch overflow. Then run each locale through the product on real screens — staff in-market or a localisation QA pass.
- 5
Ship
All locales ship together with the en-GB source. Don't ship en-GB and add Finnish two weeks later — the surface looks half-finished to half the audience.
In code
Two non-negotiables: every user-facing string lives in the i18n catalog, and every formatted value (date, time, currency, number) goes through Intl with an explicit locale. Hardcoded strings and hand-rolled formatters are the two ways localisation rots quietly.
tsx
// ✓ String through the catalog, locale on every Intl call
import { useTranslations, useLocale } from "@/lib/i18n";
export function PayoutSummary({ amount, when }: Props) {
const t = useTranslations("payouts");
const locale = useLocale();
const money = new Intl.NumberFormat(locale, {
style: "currency",
currency: "EUR",
}).format(amount);
const date = new Intl.DateTimeFormat(locale, {
day: "numeric",
month: "long",
year: "numeric",
}).format(when);
return <p>{t("summary", { money, date })}</p>;
}Pseudo-localise on every PR
Add a CI step that swaps the en-GB strings for a pseudo-locale — bracketed, length-inflated versions like [Şéţţîñĝš ──]. It catches hardcoded strings, missing keys, and overflow at review time instead of in production.
Related
Page history
1 revision- DocumentedDerek Fidler@derekfidler
First documented version. Definitions, market matrix, source-copy rules, plurals, RTL preparedness, and the translation handoff flow.