Internationalization (i18n) separates locale, messages, formatting, and writing direction from business logic so the same software can adapt to different languages and regions.
Translating interface text isn’t enough: concatenated sentences, guessed currency or time zones, hard-coded plural rules, and half-mirrored layouts all produce plausible but incorrect interfaces.
Keep raw data, make the resolved locale explicit, format complete messages and values with Intl, and test the entire rendering path with real locales and pseudo-localization.
What it is and why it exists
Internationalization is a set of design constraints: software treats translatable messages, regional formats, writing direction, and cultural rules as inputs instead of constants scattered through business code. It is commonly shortened to i18n because 18 letters sit between the first and last letters of the English word. Once those boundaries exist, adding a region usually means adding data and configuration rather than copying the business flow.
Localization (l10n) is the process of filling and validating those inputs for a market, including translation, terminology, date formats, number formats, and layout checks. i18n creates the adaptable boundary; l10n delivers a usable result for a particular region. The two aren’t interchangeable: an architecture can support several languages while none of its translations is accurate yet.
A locale isn’t another name for a language. zh-Hans-CN expresses a language, script, and region, while en-US and en-GB can differ in dates, spelling, and product conventions despite sharing a language. A user’s location also can’t reliably determine their language, currency, or time zone.
The most important i18n boundary lies between data and presentation. An order amount should remain a number paired with an ISO 4217 currency code, and an event should retain explicit time semantics; the interface then renders those values for a locale. If $1,234.50 or 04/09/2026 becomes business data, later code can’t safely change regions and may parse the original value incorrectly.
You encounter this boundary in language switching, server rendering, email, billing, search sorting, accessible names, and right-to-left (RTL) layouts. Even when a product currently has one language, explicit units, currencies, and time zones prevent display conventions from masquerading as data facts.
How it works
A predictable internationalization path resolves a request to a supported locale first. The application then loads the matching message catalog, passes raw values to locale-sensitive formatters, and sets the document language and base direction together. Business rules produce message keys and structured parameters; they don’t assemble the final sentences.
Locale candidates may come from an account preference, URL, client storage, or request header. The product defines a clear priority and lets an explicit user choice outrank detection; a server also passes its resolved result to the client so hydration doesn’t switch languages. An unmatched request uses one deterministic fallback rather than the host machine’s default environment.
The resolved result isn’t one global string that answers every question. Language, script, and region may come from a locale; time zone, currency, unit system, and first day of the week may come from the user or business context. Modeling them separately supports an ordinary combination such as a Chinese interface, euro billing, and the Paris time zone.
An i18n library handles message catalogs and message syntax, while the runtime supplies lower-level number, date, list, display-name, segmentation, and collation behavior. JavaScript’s Intl API uses the runtime’s Unicode locale data. It doesn’t translate product copy or know which currency an order uses.
Locale identifiers
The web platform generally uses BCP 47 language tags such as en, pt-BR, and zh-Hant-TW. Subtags can describe language, script, region, variants, and Unicode extensions, so don’t infer their meaning from fixed positions in split('-'). Validate and canonicalize first, then match against the product’s supported set.
Intl.getCanonicalLocales() validates tags and returns their canonical form; Intl.Locale exposes structured fields such as language, script, and region. Canonicalization isn’t negotiation. Turning en-us into en-US doesn’t decide whether the application supports en, en-US, or both.
A locale tag can carry extensions for a calendar or numbering system, but it still doesn’t supply business defaults. For example, ar-EG shouldn’t automatically decide an order’s currency because the user may be viewing a product priced in dollars. Formatting functions should receive locale, currency, and time zone separately.
Message selection and fallback
A message catalog connects code and translations with stable semantic keys such as cart.items. A key should describe its purpose instead of using the entire English source sentence; a semantic key can stay stable when source copy changes, while translation tooling tracks the content change separately. The same English word may need different keys in a button, noun label, and legal sentence.
Dynamic values enter a complete message through named placeholders. A translator can change word order and, in systems that support ICU MessageFormat, select full branches for plural or grammatical gender. Splitting a message into t('youHave') + count + t('items') imposes English order on every language.
A fallback chain should be short, deterministic, and observable. A common policy tries an exact locale, a broader language or script catalog, and finally the product default, but the precise order is a product contract. Development and CI should report a missing key as an error; production should emit telemetry even when it displays fallback text.
Translations are input data too. If a catalog permits rich text, insert validated component placeholders into the message structure instead of passing a translation string to innerHTML. Variable values remain escaped as ordinary text, while code controls link targets and the allowed elements.
Numbers, time, and plurals
Intl.NumberFormat accepts a numeric value and format options, and it can produce decimals, percentages, units, and currencies. A currency code determines the unit of account, while a locale determines customary symbol placement and separators; both inputs matter. Formatted text is for display and shouldn’t be parsed back into an amount.
Intl.DateTimeFormat needs an explicit date value and display time zone. The same instant can fall on different dates in Shanghai and New York, so servers and clients using different default zones may generate different HTML. Data that represents only a calendar date, with no instant semantics, needs its own model rather than an arbitrary conversion from UTC midnight.
Intl.PluralRules returns a plural category such as zero, one, two, few, many, or other. The locale rules and number choose the category; this isn’t a multilingual spelling of count === 1. The category only selects a message branch, and the number still needs separate formatting.
Create formatters with the options the operation requires, and reuse the same configuration within a rendering scope. Don’t claim a particular caching strategy is faster unless it has been measured on the target runtime and real call pattern. Correctness depends first on consistent input semantics, not object-creation policy.
Language and writing direction
HTML lang tells browsers and assistive technology which language the content uses; dir specifies its base writing direction. They solve different problems and should be updated together on the root element when the locale changes. CSS logical properties such as margin-inline-start, padding-inline-end, and text-align: start avoid duplicating an entire stylesheet for RTL.
Base direction doesn’t solve bidirectional mixing inside a piece of text. A user name, order number, or URL may run in the opposite direction from its surrounding sentence, so use <bdi> or a suitable isolation mechanism to contain its effect. Don’t patch layout by inserting invisible direction characters into user input because those characters are hard to audit and copy.
Mirroring depends on meaning. Back arrows and directional flow icons usually need mirroring, while play buttons, brands, clocks, and images containing numbers usually don’t. A design system should declare direction behavior per icon instead of applying scaleX(-1) to every icon.
Examples
These four examples use only the Intl API built into Node 24, so they need no framework or downloaded message library. They move from formatting to plural categories, a message catalog, and pseudo-localization; every output shown came from local Node v24.14.0.
Formatting order values explicitly
The first example takes locale, currency, and time zone as three independent parameters. A fixed ISO instant lets server and client produce the same date instead of consulting their host machines’ default zones.
const placedAt = new Date("2026-09-04T10:30:00Z");
function formatOrder(locale, currency, timeZone) {
const date = new Intl.DateTimeFormat(locale, {
dateStyle: "medium",
timeZone,
}).format(placedAt);
const total = new Intl.NumberFormat(locale, {
style: "currency",
currency,
}).format(1234.5);
return `${Intl.getCanonicalLocales(locale)[0]} | ${date} | ${total}`;
}
console.log(formatOrder("en-us", "USD", "America/New_York"));
console.log(formatOrder("de-DE", "EUR", "Europe/Berlin"));
console.log(formatOrder("zh-cn", "CNY", "Asia/Shanghai"));en-US | Sep 4, 2026 | $1,234.50
de-DE | 04.09.2026 | 1.234,50 €
zh-CN | 2026年9月4日 | ¥1,234.50Intl.getCanonicalLocales() canonicalizes the input tags, so the output uses en-US and zh-CN. It doesn’t alter currency or time zone; the caller provides those explicitly instead of guessing from language or location.
Observing plural categories
The second example doesn’t invent Arabic translations; it only observes which category the runtime selects for the same numbers. French assigns 0 to one, while Arabic also uses zero, two, few, and many, so a binary condition can’t replace locale rules.
const counts = [0, 1, 2, 3, 11, 100];
for (const locale of ["en", "fr", "ar"]) {
const rules = new Intl.PluralRules(locale);
const selections = counts.map(
(count) => `${count}=${rules.select(count)}`,
);
console.log(`${locale}: ${selections.join(", ")}`);
}en: 0=other, 1=one, 2=other, 3=other, 11=other, 100=other
fr: 0=one, 1=one, 2=other, 3=other, 11=other, 100=other
ar: 0=zero, 1=one, 2=two, 3=few, 11=many, 100=otherThe catalog must provide branches for the categories its target locale can return and always retain other. Don’t display category names to users; they are internal message-selection keys.
Keeping grammar in complete messages
This minimal catalog stores complete messages per language and replaces a number as a named parameter. It separates catalog fallback from locale formatting: de-DE has no catalog and therefore receives English copy, but its number is still formatted with the requested locale.
const catalogs = {
en: {
"cart.items": { one: "{count} item", other: "{count} items" },
},
zh: {
"cart.items": { other: "{count} 件商品" },
},
};
function formatMessage(locale, key, values) {
const language = new Intl.Locale(locale).language;
const message = catalogs[language]?.[key] ?? catalogs.en[key];
const category = new Intl.PluralRules(locale).select(values.count);
const template = message[category] ?? message.other;
const count = new Intl.NumberFormat(locale).format(values.count);
return template.replace("{count}", count);
}
for (const locale of ["en-GB", "zh-CN", "de-DE"]) {
console.log(`${locale}: ${formatMessage(locale, "cart.items", { count: 2 })}`);
}en-GB: 2 items
zh-CN: 2 件商品
de-DE: 2 itemsA production system should use a mature library that parses message syntax, validates placeholders, and reports missing keys rather than extending this small function. Its boundary still applies: business code passes a key and typed values, and the message layer owns word order and branch selection.
Exposing layout assumptions with pseudo-localization
Pseudo-localization changes characters and expands text without waiting for real translations. This simplified transformer preserves the {amount} and {name} placeholders, helping tests find leaked keys, clipped containers, and code that incorrectly depends on English source text.
const accents = {
a: "à", e: "ë", i: "ï", o: "ø", u: "ü",
A: "Å", E: "Ë", I: "Ï", O: "Ø", U: "Ü",
};
function pseudoLocalize(message) {
const parts = message.split(/(\{[a-zA-Z][\w]*\})/g);
const transformed = parts.map((part) => {
if (/^\{.*\}$/.test(part)) return part;
return part.replace(/[aeiouAEIOU]/g, (letter) => accents[letter]);
});
return `[${transformed.join("")}~~~]`;
}
console.log(pseudoLocalize("Pay {amount} now"));
console.log(pseudoLocalize("Hello, {name}"));[Pày {amount} nøw~~~]
[Hëllø, {name}~~~]This regular expression is only a demonstration for simple placeholders; it can’t safely process nested ICU messages. A real project transforms the parsed message syntax tree or uses a pseudo-locale supplied by its message toolchain, so plural and select branches remain intact.
Pitfalls
Treating language, region, and user location as one value
Concatenating translatable sentences
Treating formatted strings as storage formats
Hiding catalog defects with a fallback language
Flipping text without checking bidirectional content
In the AI era
Adding an RTL locale such as Arabic is a useful end-to-end agent task. The agent can add the ar catalog, compare keys, placeholder types, and plural branches with existing locales, wire the chosen fallback, and run representative screens with RTL direction and bidirectional order identifiers. Product choices remain explicit: for example, a missing legal message may block the release while a missing administrative label may use a named fallback. Once those choices are recorded, the agent can encode them in catalog validation and rendering tests so later locales follow the same contract.
Locale negotiation is product policy
Locale negotiation maps an ordered request list to a catalog the product actually supports. HTTP Accept-Language can express weighted preferences, but an account setting or language-prefixed URL is usually more explicit. Decide the input priority first, then use a validated matcher; don’t let individual pages implement separate guessing rules.
Canonicalization, likely-subtag expansion, and matching are three different operations. Canonicalization normalizes a tag, Intl.Locale.prototype.maximize() can add a likely script and region from locale data, and matching may return only a deployed locale. The added likely values are algorithmic results rather than facts about a user’s identity, so don’t write them back to an account preference.
The supported set is an allowlist, not a path template. Interpolating an unvalidated requested tag into import() or a file path turns typos into load failures and can broaden the attack surface for path traversal or unintended module loading. Match to a known internal ID first, then resolve resources through a fixed mapping.
Unicode extensions can express preferences such as calendar, collation, or numbering system through subtags including u-ca-* and u-nu-*. The application decides which extensions it supports and safely degrades unsupported choices. A syntactically valid tag doesn’t guarantee that every downstream library preserves or understands its extensions.
Fallback is better modeled as a controlled graph than a loop that keeps deleting the final text segment. Product content policy might map zh-Hant-HK to zh-Hant and then the default language; catalog configuration should express that relationship. At build time, verify that the graph is acyclic and every endpoint exists, and record the catalog ultimately selected at runtime.
| Concern | Input | Owner |
|---|---|---|
| User preference | Account, URL, request header | Product policy |
| Tag canonicalization | BCP 47 tag | Standard library |
| Supported-locale match | Request list, deployed catalogs | Internationalization layer |
| Display time zone | User or business context | Domain model |
| Transaction currency | Order or quote | Domain model |
Server and client consistency
Server rendering should pass the resolved locale, time zone, and message catalog version to the client as page state. If the client resolves browser defaults again, initial hydration may change text, node count, or direction, causing warnings and a visible jump. A later user switch should instead run as an explicit state transition.
Cache keys must include the i18n dimensions that actually affect the response. If HTML varies by locale, the cache must distinguish at least the resolved locale rather than only the raw request header; if time zone affects the first render, it belongs to the cache contract too. Conversely, an entire raw header that doesn’t affect output shouldn’t create unbounded cache variants.
A language switch may load a catalog asynchronously. Validate catalog completeness before committing the new state, and prevent an older request that finishes late from overwriting a newer user choice. Root lang, dir, message catalog, and formatting context should update together from the same confirmed locale.
A message catalog is an interface
Message keys and placeholders form an interface between code and translations. Deleting or renaming a key, or changing a parameter type, deserves the same migration discipline as an API change. Comparing key presence alone isn’t enough; validate each message’s parameter names, plural variable, and permitted rich-text slots.
Placeholder names should express domain meaning, such as {itemCount}, {dueDate}, and {customerName}. Positional or vague parameters such as {value1} force translators to inspect source code and are easy to swap when word order changes. Parameter types should be visible in extraction metadata or type definitions so a date can’t quietly arrive as an arbitrary preformatted string.
Plural messages must cover the categories a target locale can produce and provide other. An exact-number branch such as =0 expresses product copy, while zero is a locale grammar category; they aren’t the same condition. Reviews should exercise exact branches, category branches, and fallback separately.
Rich-text messages should preserve a translator’s ability to restructure the whole sentence while restricting executable content. A safe model lets code supply a set of known component slots and allows the parser to reference only those slots. A translation can’t choose arbitrary elements, event handlers, or link protocols, and parameter values don’t gain an escape bypass.
Catalog releases need version consistency. If code deploys a new key while a CDN still caches an old catalog, users see a transient miss; removing an old key before all clients update creates the opposite break. Content hashes, compatibility windows, or atomic manifests can let code and resources state compatible versions explicitly.
| Contract part | Build-time check | Runtime behavior |
|---|---|---|
| Message keys | Catalog sets agree | Record missing key and fallback |
| Placeholders | Names and types agree | Reject missing required values |
| Plural branches | Possible categories covered | Always retain other |
| Rich-text slots | Only allowed components referenced | Escape text parameters |
| Resource version | Manifest and code compatible | Atomically switch validated catalog |
Translation context and ownership
A key needs context describing where it appears, its audience, character constraints, and parameter meaning. Without context, open may be a verb, adjective, or state, and both machine and human translators can only guess. Screenshots help, but a written semantic note is easier to version and validate automatically.
The product team owns message intent and the parameter contract, language specialists own expression in the target language, and engineering owns loading, validation, and security boundaries. Machine translation can create candidate copy; it can’t prove legal terminology, politeness level, or vocabulary consistency. High-risk flows need explicit human language review and a release record.
Semantic boundaries for time, numbers, and sorting
Time data needs to distinguish at least instants, calendar dates, and local times governed by regional rules. A flight departing at local 09:00 isn’t the same kind of value as a log event at a UTC instant. Daylight-saving transitions can create nonexistent or repeated local times, so attaching a time-zone name to a string doesn’t complete the conversion.
An amount consists of a numeric value and currency code. 100 alone can’t say whether it means yen, euros, or minor units, and a locale can’t supply that business fact. Rounding and fraction-digit options should follow the domain contract rather than changing settlement values merely to make the interface look tidy.
Percentages need an explicit data convention too. The percent style in Intl.NumberFormat displays 0.25 as 25%, so passing a value that already means 25 produces an error such as 2,500%. An interface should state whether input is a ratio or percentage points and cover the distinction in boundary tests.
Intl.Collator is appropriate for human-facing text ordering and comparison, but locale-sensitive comparison shouldn’t decide database keys, permissions, or protocol identifier equality. The same names can change order under another locale. Stable pagination needs a stable secondary key rather than relying only on display order.
Search, case conversion, and text segmentation are language-sensitive as well. Turkish casing, combining characters, and user-visible characters formed from several code points all break ASCII assumptions. A user-facing character limit usually counts grapheme clusters or a product-defined unit rather than UTF-16 code units.
Bidirectional text is more than layout mirroring
The Unicode bidirectional algorithm arranges mixed-direction text from character properties, while HTML dir supplies a paragraph’s base direction. A Latin order number inside an RTL page still runs LTR, but nearby punctuation can be influenced by neighboring characters. Isolating a dynamic fragment prevents its directionality from rearranging the outside sentence.
When dynamic text has unknown direction, <bdi> establishes isolation for the fragment; an independent input or plain-text container can also evaluate whether dir="auto" fits the product. Structures with a known direction should state it explicitly instead of allowing the first strong character to decide by accident. Copy, selection, and screen-reader tests expose boundary errors better than a static screenshot.
CSS logical properties express layout with inline and block axes. They let spacing, borders, and positioning follow writing mode, but they don’t automatically repair absolute-coordinate diagrams, canvas drawing, or text embedded in an image. A component contract should identify which visuals depend on direction and expose controlled variants for them.
Security review also needs to consider bidirectional control characters. Invisible controls in logs, source snippets, and account identifiers can make visual order differ from stored order. Don’t strip all RTL characters indiscriminately; preserve legitimate language content while visualizing or restricting control characters in security-sensitive identifiers and diagnostic views.
Testing the internationalization contract
Choose a test matrix by behavior instead of cloning one happy path for every language. English covers a basic catalog, French exposes the plural category for 0, Arabic covers RTL and multiple plural categories, Simplified and Traditional Chinese exercise script fallback, and a pseudo-locale amplifies length and unextracted-text defects.
Unit tests validate locale resolution, the fallback graph, key sets, placeholders, and formatting inputs. Integration tests run from server response through client hydration and confirm that lang, dir, catalog version, and time zone remain consistent. End-to-end tests then cover language switching, persistence, keyboard order, clipping, and dynamic bidirectional content.
Prefer semantic invariants when asserting standard-library output, such as the correct currency, plural branch, and date components. If the product truly needs character-for-character snapshots, pin Node and ICU versions and treat locale-data upgrades as reviewed changes. Otherwise, a valid punctuation or spacing update can cause a failure with no business meaning.
Catalog tests should deliberately remove a key, alter a placeholder, and simulate a failed resource load to prove alerts and fallback work. Testing only a complete catalog can’t expose silent failure paths. A concurrent-switch test should also return the first request last and confirm that stale content doesn’t overwrite the newest selection.
Pseudo-localization isn’t language-quality review. It finds hard-coded text, insufficient space, and damaged placeholders, but it can’t validate terminology, tone, grammar, or cultural meaning. Before release, target-language users still review critical flows, while accessibility tests cover language declarations, reading order, and accessible names.
Failure behavior is an interface too
An invalid BCP 47 tag can make an Intl constructor throw RangeError, and catalog loading can fail because of network or version problems. Validate external input at the request boundary and distinguish a user-recoverable selection error from a deployment defect. A broad catch shouldn’t turn every failure into the default language.
An unsupported locale isn’t the same as a syntactically invalid tag. The former can follow product fallback policy, while the latter usually signals bad input or an implementation error. Logs can retain a sanitized requested tag and failure stage, but they shouldn’t record an entire request header or message parameters by default.
Formatting calls need domain-level failure policy as well. If a currency code, time-zone name, or message parameter is invalid, a checkout flow shouldn’t silently omit the amount; it can enter an explicit recovery state and block submissions that depend on the value. The error copy itself must come from a minimal catalog guaranteed to be present.
A loading interface should avoid flashing the source language. It can retain the previous complete catalog, show a language-neutral skeleton, or receive required messages from the server, depending on the product interaction. The key constraint is that a half-loaded new catalog and old formatting context must not form a submittable page.
Tests should assert the failure classification and recovery result, not merely the appearance of some default text. That proves missing keys, invalid tags, resource timeouts, and stale switches reach their intended paths separately. Observability should use the same categories so operators can distinguish content, input, network, and code defects.
Minimum evidence before release
A locale release should have a repeatable evidence set. It combines automated checks with human judgment of real content; the mere existence of a catalog file isn’t a completion criterion.
- Every catalog passes key, placeholder, plural-branch, and rich-text slot validation.
- Formatting tests on a pinned runtime cover amounts, time boundaries, percentages, and sorting policy.
- Server and client resolve locale, time zone, catalog version, and direction consistently.
- Human language review covers critical checkout, authentication, error, notification, and legal flows.
- RTL, pseudo-localization, keyboard, and assistive-technology tests have no blocking defects.
Incident diagnostics need structured context without sensitive message parameters. Logs may include requested locale, resolved locale, catalog version, message key, fallback selection, and time zone; user names, free text, and tokens shouldn’t enter logs with translation failures. That evidence locates missing keys without turning i18n telemetry into another data-leak channel.
Further reading
4 questions · 1 predict-the-output · 1 spot-the-bug