I19 · Part 2 · Intermediate
Internationalisation
Translating strings is the easy third. Plurals, formats and layout direction are the rest.
09On this page
What the word covers
Internationalisation (i18n, because of the eighteen letters between the i and the n) is building the app so it can speak more than one language. Localisation (l10n) is supplying one particular language. A team that has done the first can add a locale by adding a file; a team that has not has to rewrite components.
Four things change between locales, and only the first is translation:
- Text, including plurals, gender and word order.
- Formats: dates, numbers, currencies, lists, relative times.
- Direction: Arabic and Hebrew run right to left.
- Length: German and Finnish routinely run a third longer than English, and a button sized to "Save" breaks.
Never build a sentence out of pieces
"Delete " + count + " " + (count === 1 ? "file" : "files") // ✗
t("deleteFiles", { count }) // ✓ one whole message
Word order differs between languages, so a sentence assembled from fragments
cannot be translated: the translator sees "Delete" and "files" as unrelated
strings and has no way to move the number. And English has two plural forms,
but Russian has three that matter (1, 2–4, 5 and up) and Arabic has six.
A ternary on count === 1 is wrong in most of the world.
The platform already does formatting
new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(1234.5);
// "1.234,50 €"
new Intl.DateTimeFormat("en-GB", { dateStyle: "long" }).format(new Date());
// "21 September 2026"
new Intl.RelativeTimeFormat("es", { numeric: "auto" }).format(-1, "day");
// "ayer"
new Intl.ListFormat("en", { type: "conjunction" }).format(["a", "b", "c"]);
// "a, b and c"
new Intl.PluralRules("ru").select(3);
// "few"
Before adding a dependency for any of these, use Intl. It is built
into every browser and Node, ships the locale data, and is what the libraries
below call underneath. Two rules: never format dates or numbers by hand
(toFixed and string slicing are not localisation), and pass the
locale from your i18n layer instead of letting the browser guess, so
the server and client agree.
react-i18next, for any React app
import i18n from "i18next";
import { initReactI18next, useTranslation } from "react-i18next";
i18n.use(initReactI18next).init({
resources: {
en: { translation: {
welcome: "Welcome back, {{name}}",
file_one: "{{count}} file",
file_other: "{{count}} files",
} },
fr: { translation: {
welcome: "Bon retour, {{name}}",
file_one: "{{count}} fichier",
file_other: "{{count}} fichiers",
} },
},
lng: "en",
fallbackLng: "en",
interpolation: { escapeValue: false }, // React already escapes
});
function Files({ n, name }) {
const { t } = useTranslation();
return (
<>
<h1>{t("welcome", { name })}</h1>
<p>{t("file", { count: n })}</p> // picks file_one or file_other
</>
);
}
Plural forms are separate keys with the CLDR suffixes _one,
_other, and where a language needs them _zero,
_two, _few and _many. The variable must
be named count, and it must be passed — without it there is
no plural lookup at all. Underneath, i18next asks
Intl.PluralRules, so the rules are the standard ones.
When a translated sentence contains markup, Trans lets the
translator move the tag: "Read our <1>terms</1>"
maps index 1 to your <a>. Never split that sentence into
three strings around a link.
next-intl, for the Next.js App Router
// i18n/routing.ts
export const routing = defineRouting({ locales: ["en", "de"], defaultLocale: "en" });
// proxy.ts — called middleware.ts before Next.js 16
export default createMiddleware(routing);
export const config = { matcher: "/((?!api|trpc|_next|_vercel|.*\\..*).*)" };
// app/[locale]/layout.tsx
export default async function LocaleLayout({ children, params }) {
const { locale } = await params;
if (!hasLocale(routing.locales, locale)) notFound();
setRequestLocale(locale); // lets the page render statically
return (
<html lang={locale}>
<body><NextIntlClientProvider>{children}</NextIntlClientProvider></body>
</html>
);
}
// a Server Component // a Client Component
const t = await getTranslations("Inbox"); const t = useTranslations("Inbox");
{ "Inbox": { "unread": "{count, plural, =0 {No messages} one {# message} other {# messages}}" } }
The locale lives in the URL — /de/pricing — because a
URL is what search engines index and what a user can share. The proxy
negotiates the locale from the Accept-Language header on the first
visit and redirects. Messages use the ICU format, so plurals and selects live
inside the string, where the translator can see and change them. Server
Components read translations on the server, so those strings never reach the
browser bundle. What you hand to the client provider is what ships, so pass
only the namespaces that Client Components actually use.
Choosing
| Situation | Reach for |
|---|---|
| Vite, or any client-rendered React app | react-i18next, or FormatJS (react-intl) if you want ICU messages |
| Next.js App Router | next-intl — it understands Server Components and static rendering |
| Translators work in a tool | Whatever exports to the format that tool accepts; ICU JSON is the common one |
| One language now, maybe more later | Still extract strings now; retrofitting is the expensive path |
Gender, choices and fallbacks
"{gender, select, female {She replied} male {He replied} other {They replied}}"
ICU's select handles the cases a plural cannot: a message that
changes with a value that is not a number. Always include other;
it is the branch used when the value is anything unexpected, and it is what
keeps a new enum value from producing an empty string. Locales also fall back
in a chain — a French-Canadian visitor (fr-CA) should see
fr before English — and the last stop must be a complete
catalogue, so a missing key shows real text rather than the key itself.
Making it a workflow, not a one-off
- Extract, do not hand-maintain. A tool that scans the code for message calls and writes the catalogue means the source and the file cannot drift apart.
- Fail CI on missing keys. A locale with a key absent in one file is a bug you can find in seconds, or one a customer in Munich finds in production.
- Give translators context. "Post" is a noun on a blog and a verb on a button. A short description alongside the key is worth more than any tool.
- Type the keys. With TypeScript, both major libraries can check
t("settings.svae")at compile time against the English catalogue. - Keep the default language honest. If English is only a fallback and nobody reads it, its typos ship.
Layout direction
<html lang="ar" dir="rtl">
.card { margin-inline-start: 1rem; padding-inline: 1rem; } /* not margin-left */
Set lang and dir on the root element, and write CSS
with logical properties — margin-inline-start,
padding-inline, inset-inline-end, text-align:
start — so the layout mirrors without a second stylesheet. Icons
that imply direction, like a back arrow, need to flip; icons that do not, like
a clock, must not.
What goes wrong
- Keys named after the English text.
"Save"as a key breaks the day marketing changes it to "Save changes". Name by meaning:settings.save. - Translated strings in state or the database. Store the key or the raw value and translate at render, or the text stays in the old language when the user switches.
- Hydration mismatch. A date formatted on the server in one timezone and in the browser in another renders two different strings. Format with an explicit locale and
timeZone. - Bundle weight. Loading every language for every user is the default failure. Load the active locale, and split large catalogues by page.
- Untested layouts. Pseudo-localisation — automatically stretching every string with accented characters — finds truncation and hard-coded text before a translator does.
- SEO. One URL per locale,
langon the page, andhreflangalternates linking the versions.
Interview answer, one sentence
"Internationalisation is text, formats, direction and length: whole
messages with ICU or CLDR plural rules instead of concatenation, the
built-in Intl APIs for formatting, the locale in the URL,
logical CSS properties for right-to-left, and translation resolved at render
time, never stored."
Opens in the editor: write it, run it, and check it against real tests.
All 66 for this level →Finished reading?
0 of 57 chapters read in this series