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
- Internationalization (i18n) Building software so text, formats and layout can change per language without code changes. Developers do it once.
- Localization (l10n) Adapting the product to one language and region: translated text, local date and number style, sometimes images and legal pages.
- Locale and language tag A code for a language, optionally with a region and a script, in the BCP 47 standard: en, ru-RU, az-Latn-AZ.
- Translation key A stable name for one sentence, such as cart.title. Code uses the key; a file per language holds the words.
- Namespace A group of keys kept in one file and loaded together, usually one per screen or product area.
- Placeholder A slot in a sentence, such as {{name}}, filled with a value when the sentence is shown. Filling it is called interpolation.
- Plural category The form a word takes after a number. CLDR names six: zero, one, two, few, many and other; each language uses some of them.
- CLDR and ICU CLDR is Unicode's database of language rules: plurals, month names, number styles. ICU is the library that reads it; browsers, Node.js and .NET all use it.
- Intl The JavaScript API, built on ICU, that formats numbers, dates, lists and relative times, and sorts text, for a given locale.
- Accept-Language A request header in which the browser lists the languages the person prefers, with weights such as ru,en;q=0.8.
- Translation memory A store of finished translations keyed by their source text, so nothing is translated twice and a changed sentence is found.
- Pseudo-localization A fake language - English made about a third longer, with accents - that shows broken layouts and hard-coded text before any real translation exists.
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
- 1. Write the English once The developer adds the sentence to the English file of its namespace, under a key. A sentence with a number gets plural forms: items_one and items_other.
- 2. Generate the key names A script turns the English files into typed constants, so code names a key without typing it as text, and a typo fails the build.
- 3. Choose the language The page picks its language once, before the first render. It tries a language in the page's web address (for example /ru/), then the person's saved choice, then the browser's language list, then English.
- 4. Load the namespace When a screen loads, its words arrive with it: the English file and the page language's file, fetched in parallel with the screen's code.
- 5. Pick the plural form A call such as t(key, { count: 3 }) asks Intl.PluralRules for the category of 3 in this language. English answers other, Russian answers few, and that form is read.
- 6. Fill and format Placeholders get their values, and the count is written the way the language writes numbers: 1,234 in English, 1 234 in Russian.
- 7. Fall back A key this language has not translated yet reads in English, key by key, so a new sentence never shows as a raw key.
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.
- Build The bundler sees which namespaces each lazily loaded screen uses and wraps its import, so code and words are asked for together.
- Open the screen The person navigates; the screen's code, its English file and the page language's file are fetched at the same time.
- Merge The files are added to i18next's resources. The screen renders with every key in place - no raw key flashes.
- A late key If a key's file was not asked for, the key renders empty, the file is fetched and the screen redraws. In development a warning names the namespace to add.
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.
- English is the source Every key exists in the English file first; other languages may only translate keys English has.
- Generate A generator writes the key names - no sentences - nested like the files, with plural forms folded into one base name: items_one and items_other become items.
- Use Code calls t(K.cart.items, { count }). A key that does not exist is a compile error.
- Inline at build A build plugin replaces each constant with its string, so the tree of names never ships in the first download.
- Check CI fails when the generated files are older than the English files, so types and text never drift apart.
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
- i18next + react-i18next A framework-free core with a React binding.; Plurals by Intl.PluralRules with simple _one and _few keys; Namespaces and lazy loading built in; Large ecosystem of loaders and plugins; The same JSON format works on a server; Its own placeholder syntax, not the ICU standard; Typing a very large key set needs care
- FormatJS (react-intl) ICU MessageFormat, the Unicode standard message syntax.; Plurals, gender and formats inside one message; Many translation tools read the syntax; Messages are harder for non-developers to edit; Splitting and lazy loading are yours to build
- Lingui Messages extracted from the source at compile time.; Small runtime; Extraction from code is automatic; ICU syntax; Needs macros and a compile step; Smaller ecosystem
- A hand-written dictionary An object per language and a lookup function.; No dependency; Plurals, fallbacks, loading and formats are all yours; Usually wrong for Russian on day one
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.
- Translation management system Crowdin, Lokalise, Phrase and similar services.; A friendly editor with memory, glossary and review; Machine translation built in; A subscription per seat or per word; The text lives outside the repository and needs syncing
- Professional translators only The best tone and quality; Slow and costly for tens of thousands of strings; Every English change waits for a person
- Raw machine translation at build time Instant and cheap; Breaks placeholders and plural forms; Terms change from screen to screen; Nobody reviews it
- A pipeline in the repository Memory, glossary, validator, machine translation, human review.; Text, memory and rules are versioned with the code; Broken placeholders are refused before they ship; Only new or changed text is ever translated; The team owns the scripts; Reviewers edit files, not a friendly editor
What {{brand}} chose, and why
- One format for the app and the server: the web app and the .NET server share nested keys, {{name}} placeholders and plural suffixes, so one pipeline translates both. The browser extension, which Chrome requires to be flat, shares the placeholders and plural suffixes and follows the browser's language.
- i18next because its plurals follow CLDR through Intl.PluralRules and it loads namespaces on demand. Its JSON is simple enough for a script, or a translator, to read and check.
- Typed keys because a product with tens of thousands of sentences cannot rely on people typing key names correctly.
- A pipeline in the repository because the text, the memory and the glossary are reviewed in the same pull requests as the code. A validator stands between every translator and the product, and there is no subscription per seat or per word.
- English as the source and the fallback: at run time a new sentence reads in English at once. The build blocks the merge until it is translated, so nothing slips out untranslated unnoticed.
- i18next's placeholder syntax is its own, not ICU MessageFormat, so moving tools means converting files.
- The team maintains its own scripts instead of paying a vendor.
- There is no gender or choice logic inside messages, so sentences are written to avoid agreeing with a person's gender ("Card moved" instead of "She moved the card").
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.
- Pass raw values into a sentence - a number, a date - and format them in the reader's language at the end. A pre-formatted string carries the writer's language with it.
- Store moments in UTC and format them in the viewer's time zone.
- Money is formatted with the price's own currency code, never a fixed pattern.
- The clock - 12 or 24 hours - follows the language unless the person chose one.
- Search compares folded text: lowercase, accents removed, every kind of i the same, so sirket finds şirkət. Never show folded text.
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.
- 1. Probe Format the month of a known date in the language. A word means the data is there; a number (M1) means it is missing.
- 2. Pick stand-ins Borrow a neighbouring language that lays out dates and numbers the same way: Turkish is close to Azerbaijani. Then fix the differences, such as the percent sign (Turkish writes %42, Azerbaijani writes 42%).
- 3. Rename the parts formatToParts splits a formatted date into month, weekday and day-period parts; each is swapped for the Azerbaijani name of the same month or weekday.
- 4. Supply the words Relative times (dünən, 3 gün öncə) and list words (və, və ya) come from a small table.
- 5. Install once Before anything formats, wrap the Intl constructors in a JavaScript Proxy. A Proxy is an object that intercepts calls and can answer instead of the original. Do it only for the missing language, and only where the data is missing.
- 6. Leave the rest Country names and sorting stay the browser's own.
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.
- 1. Know who is asking The language is chosen once the caller is identified.
- 2. Pick the language The person's saved choice; else the best match of the Accept-Language header (a list such as ru,en;q=0.8, where q is the preference weight); else English. The web app sends its page language in that header.
- 3. Set the UI culture only CurrentUICulture follows the reader; CurrentCulture stays invariant, so parsing never changes.
- 4. Handle the request Code throws or returns keys with parameters - no words yet.
- 5. Render at the edge The error handler and the JSON writer turn each key into words in the request's language.
- 6. Work outside a request An email or a digest for one person runs in a block of code that temporarily sets that person's language, then restores it.
- .resx files + IStringLocalizer The classic ASP.NET Core way.; Built into the framework; Satellite assemblies per culture; An XML format no web tool or translation script shares; No CLDR plurals - you write them yourself; Keys are strings unless you generate classes
- A shared JSON catalog with generated keys The web app's format, read by a small service on the server.; One format and one pipeline for web and server; Generated constants: no key typed as text; Plurals by the same CLDR rules as the browser; A small service to own: lookup, plurals, formats; A script to rerun after adding a sentence
- Codes only; the client translates The server stays free of language; Emails, exports and notifications still need words on the server; Every client must know every message
- Keep English sentences out of server code: a test or lint rule can scan the sources for sentence-like strings.
- A stable code beside the words: clients branch on the code, never on the text, which changes with the language.
- Logs stay English: an exception's message and a sentence written into a log are always English, so anyone on the team can read them.
- Names inside sentences are keys too: a field name or a status label, never an enum's ToString().
- Render as late as possible: an error when the API answers, a notification when it is read, an email when it is composed for its recipient.
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.
- Write The row keeps the sentence's key, its parameters and the ids of the people it names - no names, no words.
- Group Ten similar events become one row with a count, shown with a grouped sentence: 10 mentions on Fix the login redirect.
- Read The inbox renders each row in the reader's language and looks the names up at that moment.
- Rename or erase A renamed person reads by their new name at once. When a person is erased, only the stored person id is swapped for a placeholder, and the inbox shows Deleted user. No stored sentence is rewritten.
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.
- 1. Extract Walk every English source into units: a text, or a plural group as one unit. Each unit gets a hash of its English text and of where it sits.
- 2. Check the memory A unit whose hash is in the memory is done. One whose key had an older hash is stale - the English changed. The rest are missing.
- 3. Package Missing and stale units go into small JSON packages by namespace, with context: the neighbouring texts, the glossary terms that apply, and the exact strings that must survive.
- 4. Translate A translator - an AI model or a person - fills each unit's translation, following the language's rules file.
- 5. Validate A script refuses a unit that broke a placeholder, tag, address, plural form or protected term, and warns about softer problems.
- 6. Apply Good units enter the memory, and the locale files are rewritten from the memory in the English file's key order and shape.
- 7. Gate CI compares each language's files with the memory and the language's status.
What a translator is given, and what the validator refuses
- Glossary One chosen translation per product term - workspace is рабочее пространство in Russian and iş sahəsi in Azerbaijani - with a note. The validator warns when a sentence uses the English term without it.
- Do-not-translate list Brand, product and plan names, formats and protocols (JSON, OAuth), other products (PostgreSQL). A changed one is refused.
- Style rules Tone and form of address (formal вы and siz), quotation marks, sentence case, and language-specific advice, such as never gluing an Azerbaijani case ending to a placeholder.
- Plural notes Which numbers take which form, so the translator writes every form the language needs.
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.
- Machine pass An AI model translates the packages with the rules file and the glossary. Its entries are marked as machine-written.
- Validation Anything structurally broken is refused, one line per unit, and goes back for another pass.
- Native review A reviewer starts where mistakes cost most: money, legal text and text people type back. They correct the memory, or edit a locale file and adopt the edit into the memory.
- Protection A later machine run never overwrites a reviewed translation unless someone asks for that explicitly.
- Term fixes A corrected glossary term is found everywhere by searching the memory for the old translation, and those units are sent through again.
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.
- Generated files The generated key names and types are up to date with the English files.
- Parity Every translated file matches the English keys, placeholders and plural forms.
- Source scan A scan refuses hard-coded text in the screens and in the server code.
- The gate Each language is compared with the translation memory and its status.
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.
- Keep the word lists in the locale files, beside the sentences, so the translator supplies every form a word takes.
- Match whole words, longest first, so cümə axşamı (Thursday) is never read as cümə (Friday).
- Fold case and accents for matching only; what is shown keeps the letters the person typed.
- Test every example in your documentation by typing it back into the parser.
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
- 1. Start a project Create a Vite and React app and install i18next and react-i18next.
- 2. Write two files An English and a Russian file for one namespace, with a plural key: two forms in English, four in Russian.
- 3. Count Render t(items, { count }) for 1, 2, 5, 11 and 21 in both languages and check the Russian forms against the table above.
- 4. Format Write a date and a number with Intl for en, ru and az. Open the page in Chrome and in Firefox and compare the Azerbaijani output.
- 5. Pseudo-localize Add the small function below that accents and stretches every English string; switch to it and look for overflow and for text that did not change.
- 6. Validate Write the small checker further down and break a placeholder on purpose to see it fail. Also check that every Russian key has _one, _few, _many and _other.
Common mistakes
- Gluing sentences from pieces: word order differs between languages. One key per whole sentence, with placeholders.
- Plurals with count === 1: correct in English, wrong in Russian, French and Arabic. Use plural keys and Intl.PluralRules.
- Formatting with the browser's language: toLocaleString() and localeCompare() without a locale follow the browser, not the page. Pass the page's language every time.
- Parsing data with the reader's culture: the Turkish i and the decimal comma break lookups and numbers. Keep data invariant; only words follow the reader.
- Storing rendered words: a notification saved as English stays English for every reader. Store keys and parameters, render on read.
- Shipping unvalidated translations: one lost {{name}} or a missing plural form breaks a screen. Validate every translation, and let CI fail on stale text.
Interview questions
- What is the difference between i18n and l10n? Internationalization prepares code to support any language: keys instead of text, formatters, plural support, stretchable layouts. Localization fills it for one language: translations, local formats, reviewed terms. i18n is done once; l10n is done per language and again whenever the source changes.
- How do plurals work in i18next? A key has one suffix per CLDR category, for example items_one and items_other, and t("items", { count: 21 }) asks Intl.PluralRules which category 21 falls into for the current language. Russian gives one for 21, few for 22 and many for 25, so a rule like count === 1 breaks there.
- What are CLDR and ICU, and how do they relate to Intl? CLDR is Unicode's data about languages: plural rules, month names, number styles. ICU is the library that applies it. Intl in browsers and Node.js, and globalization in .NET, are built on ICU - which is why a browser that trims ICU data cannot format some languages.
- Why not call toLocaleString() for dates? Without a locale it follows the browser's language, not the page's, so a Russian page can show English dates. Build formatters with the page's language in one module, cache them, and refuse direct calls in a lint check. For example new Intl.DateTimeFormat("ru", { dateStyle: "medium" }).format(date) always writes Russian.
- What is the Turkish i problem, and how do you prevent it in .NET? In Turkish and Azerbaijani, I lowercases to dotless ı, so culture-sensitive ToLower breaks identifiers. Keep CurrentCulture invariant, let only CurrentUICulture follow the reader, and turn the globalization analyzers into build errors.
- How do you localize API error messages? Throw a key with parameters, not English, and render it in the caller's language in the error handler. Return a stable error code beside the text so clients never branch on words. Logs keep the English version.
- Why store notifications as keys and parameters? Because they are read later, by people with different languages. A row with a key, parameters and person ids renders in each reader's language, and a renamed or erased person changes no stored text.
- What is a translation memory, and why key it by a hash of the source? It stores finished translations so unchanged text is never translated twice. Keying by a hash of the English and its position means a reworded sentence is detected as stale automatically, while moved items keep their translations.
- How do you make machine translation safe to ship? Give it context and a glossary. Validate placeholders, tags, plural forms and protected terms before anything is kept. Have native reviewers correct the important text, and never let a later machine run overwrite a reviewed translation.
- What changes when you add a right-to-left language? The layout mirrors: dir=rtl, CSS logical properties instead of left and right, mirrored arrows, isolated mixed-direction text and testing with a mirrored pseudo-language. Translation is the smaller part of the work.
Related
- /help/technology/frontend
- /help/technology/request-path
- /help/technology/testing