Internationalization: one product in several languages

How one product speaks English, Russian and Azerbaijani: i18n versus l10n, i18next with namespaces, typed keys and CLDR plurals, Intl formatting and its Azerbaijani gap, localized .NET errors and stored sentences, and a translation pipeline with memory, glossaries, validation, machine translation, human review and a CI gate.

In short

Internationalization (i18n) prepares code so it can speak any language. Localization (l10n) fills it with one language: its words, dates and numbers. {{brand}}'s web app uses i18next with react-i18next and typed keys. Its .NET server keeps every sentence as a key with parameters and puts it into words at the last moment, in the reader's language. Dates and numbers come from the browser's Intl API, patched where a browser has no data. A translation pipeline turns the English into Russian and Azerbaijani, and a CI gate fails the build when a translation is missing or broken.

After this lesson you can Explain i18n and l10n to anyone. Set up i18next with namespaces and correct plurals. Format dates and numbers for any language and spot where Intl falls short. Localize a .NET API's errors and stored messages, and design a translation pipeline with a memory, a validator and a CI gate.

Words you need

i18n and l10n: two different jobs

The short names count letters: i, then 18 letters, then n. A third word, globalization (g11n), means both together. Most of this lesson is i18n work. Once it is right, adding a language is mostly translation work, not code.

One sentence, from code to screen

Inside i18next: namespaces, loading and markup

i18next is a translation library that knows nothing about any framework: it holds resources per language, resolves keys, fills placeholders and picks plural forms. react-i18next connects it to React. Its useTranslation hook gives a component the t function and redraws it when the language or the loaded text changes. Its Trans component translates sentences that contain markup.

Trans keeps the tags in the translation, so a translator can move a link or a bold word to where the language wants it. The code never glues a sentence from pieces.

One language per page load {{brand}} decides the language once, before the first render, and reloads the page when a person switches. Formatters, sorted lists and label tables built when the code loads can then never disagree with the screen. The cost is one reload on a switch, which people rarely make.

Plurals: why count === 1 is wrong

English has two forms: 1 item, 2 items. Russian needs three for whole numbers and a fourth for fractions; Arabic needs six. The rules come from CLDR, and the browser's Intl.PluralRules implements them, so code never writes its own if count is 1.

The same rules on the server .NET has no built-in plural rules, so {{brand}}'s server implements the CLDR rules for its languages itself. Tests pin the forms each language needs and the results for sample numbers. So items_few means the same thing in the browser, in an email and in a notification.

Typed keys: a typo becomes a compile error

A key written as plain text - t(“cart.titel”) - fails silently: the screen shows the raw key. Typed keys turn the English files into a TypeScript type and a tree of constants, so the editor completes key names and the type check refuses a wrong one.

Large key sets and the compiler With tens of thousands of keys, a type of every key can push the TypeScript compiler past its limit (type instantiation is excessively deep). Folding plural forms into one base name ahead of time keeps the type small enough for the compiler.

The options in the market

ICU MessageFormat keeps all forms in one message; i18next keeps one key per form. The first is compact for a developer, the second is simpler for a script or a translator to check.

What {{brand}} chose, and why

A company with many professional translators would pick a translation management system. A product with many messages that change by gender or choice would prefer ICU MessageFormat.

Never glue a sentence from pieces t(deleted) + name + t(by) + person breaks in any language that orders words differently - Azerbaijani puts the verb last. Give every whole sentence one key with placeholders, and let the translator move them.

Dates, numbers and lists with Intl

Never format a date or a number by hand, and never let it follow the browser's language by accident: toLocaleString() and new Intl.NumberFormat(undefined) do exactly that. In {{brand}} one module builds every formatter with the page's language and keeps it, and a check in CI refuses those calls anywhere else.

With the default numeric: "always" the English result is 1 day ago. The Azerbaijani column shows what a browser with full data is expected to write; check it in the browsers you support. Intl.Collator sorts names the way a language orders its letters, and Intl.DisplayNames names countries and languages in the reader's language.

When Intl has no data: the Azerbaijani gap

Intl is only as good as the ICU data a browser ships. Some browsers, including the Chromium the team tests with, ship a trimmed copy with no Azerbaijani. There a date reads M10 10, Sat, a number 1,234.5 in English style, and a list a, b, and c. Other browsers may have the data, so test in the ones your users have. {{brand}} fills the gap in the browser instead of waiting for it.

Test in the browsers your users have Node.js ships full ICU, so unit tests in Node pass while a browser without the data shows M10. Before releasing a language, format a month in each browser your users have. A number instead of a name means that browser has no data for it.

The Turkish i: keep data invariant

In Turkish and Azerbaijani the capital of i is İ, and the lower case of I is ı. Code that lowercases with the reader's culture breaks: TITLE becomes tıtle, and a lookup by name fails. A decimal comma does the same to parsing: under Russian or Azerbaijani rules, 1.5 is not one and a half.

.NET keeps two cultures per thread: CurrentCulture for data and CurrentUICulture for words. {{brand}} sets the data culture to invariant for every thread at startup. Invariant is a neutral setting that formats and parses the same way everywhere. Only the UI culture follows the reader. .NET's globalization analyzers (CA1304, CA1305, CA1307, CA1309, CA1311) are build errors, so every call says which culture or comparison it means. The web app has the same rule: no toLocaleLowerCase() or localeCompare() outside the formatting module.

Server-side localization in .NET

The API writes words too: error messages, emails, notifications, reports, exports. {{brand}} keeps them in the same JSON format as the web app: nested keys, {{placeholders}} and plural suffixes. There is one file per language per project, embedded in the build, with key constants generated from the English file.

Storing sentences as keys with parameters

Some sentences outlive the request: a notification read tomorrow, a failed run's error shown in next week's report. If the server writes words into the database, every reader gets the writer's language forever. Store the key and its parameters instead, and turn them into words when someone reads them.

Never parse a stored sentence Keep a status code beside it and compare, filter and group by the code. Treat anything a person types as plain text and never as a stored sentence.

The translation pipeline: memory, glossary, validator

{{brand}}'s pipeline is a set of dependency-free Node scripts in the repository. It never calls a translation service itself: it prepares work packages, checks what comes back and writes the locale files. Four sources go through it: the web interface, the Help guides, the server's sentences and the browser extension.

What a translator is given, and what the validator refuses

These checks run before a translation is accepted: a broken placeholder is refused, a softer problem is only warned about.

Machine translation with human review

Machine translation is fast and good enough for a first pass, but it misses terms and context: is Close a button or an adjective? The pipeline makes it safe to ship. Every unit travels with context, the validator refuses broken structure, and each memory entry records who wrote it - a machine or a reviewer.

Give the machine what a human translator would ask for The neighbouring texts, the parent key, a note about where the text appears and the glossary terms that apply. A short label with no context is the most common source of a wrong translation.

The CI gate and language status

The consequence is deliberate. Changing an English sentence makes it stale in every complete language. The build fails until it is translated again, or until the language moves back to in-progress. A released language can never drift back to English without anyone noticing.

Pseudo-localization: test before you translate

A pseudo-language is English with accented letters, about 35% longer, between brackets: Your cart becomes [Ẏǿǿŭŭř ƈȧȧřŧ] and Hello, {{name}}! becomes [Ħḗḗŀŀǿǿ, {{name}}!]. Nothing is translated, yet it shows three problems before any translator starts.

Placeholders, tags and addresses pass through untouched, so every sentence still works. Teams usually keep the pseudo-language for developers and test tooling only. By convention it is tagged en-XA, and a right-to-left variant is tagged ar-XB.

When text comes back in

Some text is typed back and parsed: a quick-add line such as Report by Friday, or a length such as 1h 30m. A parser must read the reader's language, and languages bend their words. Russian writes пятница, пятницу and в пятницу; Azerbaijani adds case endings: cüməyə qədər means by Friday.

Right-to-left languages

Arabic, Hebrew, Persian and Urdu run right to left. Supporting them is more than translation: the whole layout mirrors. {{brand}}'s languages are all written left to right, and the team decided not to do right-to-left work yet. This is what it would take.

Direction characters are a security matter Unicode direction overrides such as U+202E can make a file name or a display name read backwards - the trick known as Trojan Source. Strip or refuse them in names and anywhere one person's text is shown to another, whatever languages you support.

Try it yourself: a three-language page in an hour

Common mistakes

Interview questions

Related