Lesson 22 / 22
Internationalization
Separating text from code, translation catalog and key design, plural rules changing by language, leaving formatting to the platform, direction support, and selecting the locale.
Contents
The identity flow is complete: the observer is recognized, and their session carries on by itself. But the recognized observer also has a language. Every piece of text produced throughout this course was written for a single language — error messages, the “session dropped” notice, empty-result explanations. Numbers and dates were the same way: the measurement value was written as “−4.2”, the date was shown in day-month-year order.
None of this is correct for an observer watching the North Slope station from another country. This lesson builds the structure that makes the interface adaptable to language and region.
Two Separate Jobs
Internationalization makes the application adaptable: separating text from code, not hardcoding formatting, and having the layout hold up under a direction change. It is done once, and it is independent of language.
Localization is producing content for a specific language and region: writing the translations, choosing dates and currency to fit that region. It is done separately for each language.
The practical value of this distinction follows directly. Adding a translation to an application that has not been internationalized requires touching every part of the code. Adding a second language to one that has means writing nothing more than a catalog file. This is why separating text from code is a cheap investment even in applications expected to stay single-language.
Translation Catalog and Key Design
A translation catalog is a structure that maps keys to messages. The code knows the key, not the text.
The key itself is a design decision. Using the source sentence as the key — “2 fields need
correction” — looks easy, but every translation breaks whenever that text is edited. A
structured name that defines the meaning — form.summary — protects that link. Because the
same word may need to be translated differently in two places, the key also carries context:
button.save and menu.save are separate keys, even though both are “Save” in English.
// translation-catalog.mjs — keys, placeholders, and plural rules const CATALOG = { tr: { "form.summary": { one: "{count} alan düzeltilmeli", other: "{count} alan düzeltilmeli" }, "measurement.range": "{min} ile {max} arasında bir değer girin.", "session.expired": "Oturumunuz sona erdi.", }, en: { "form.summary": { one: "{count} field needs attention", other: "{count} fields need attention" }, "measurement.range": "Enter a value between {min} and {max}.", }, }; const DEFAULT = "tr"; function message(locale, key, vars = {}) { const entry = CATALOG[locale]?.[key] ?? CATALOG[DEFAULT]?.[key]; if (entry === undefined) return `!${key}`; // a missing key is not hidden const pattern = typeof entry === "string" ? entry : entry[new Intl.PluralRules(locale).select(vars.count)] ?? entry.other; return pattern.replace(/\{(\w+)\}/g, (whole, name) => name in vars ? String(vars[name]) : whole); } console.log("plural categories (Intl.PluralRules)"); console.log("count".padEnd(6) + ["tr", "en", "ru"].map((d) => d.padEnd(8)).join("")); for (const count of [0, 1, 2, 5, 11, 21, 100]) { console.log(String(count).padEnd(6) + ["tr", "en", "ru"].map((d) => new Intl.PluralRules(d).select(count).padEnd(8)).join("")); } console.log("\nmessages"); for (const locale of ["tr", "en"]) { for (const count of [1, 2]) { console.log(` ${locale} count=${count}: ${message(locale, "form.summary", { count })}`); } console.log(` ${locale} range : ${message(locale, "measurement.range", { min: -60, max: 60 })}`); console.log(` ${locale} session: ${message(locale, "session.expired")}`); console.log(` ${locale} missing: ${message(locale, "measurement.unknown")}`); }
plural categories (Intl.PluralRules) count tr en ru 0 other other many 1 one one one 2 other other few 5 other other many 11 other other many 21 other other one 100 other other many messages tr count=1: 1 alan düzeltilmeli tr count=2: 2 alan düzeltilmeli tr range : -60 ile 60 arasında bir değer girin. tr session: Oturumunuz sona erdi. tr missing: !measurement.unknown en count=1: 1 field needs attention en count=2: 2 fields need attention en range : Enter a value between -60 and 60. en session: Oturumunuz sona erdi. en missing: !measurement.unknown
The plural table shows why a hand-written plural rule is not enough. Turkish and English have
two categories, and the test count === 1 gives the right answer in both. Three of Russian’s
four categories show up in this table, and the choice depends not just on the number itself
but on its last digit: 21 falls into the “one” category, while 11 stays in “many.” Code that
writes the rule by hand gets rewritten the moment a third language is added.
Intl.PluralRules reads the rule from the language; the catalog only says what to write for
each category.
Turkish’s two forms carrying the same text is not an inconsistency. The catalog preserves the
structure; how many forms a given language uses is that language’s own decision. When the
same catalog gets Russian added to it, few and many keys are written too, and the code
does not change.
Two more behaviors show up in the output. A missing key is not hidden: a display marked with an exclamation point is produced for a key that does not exist. Returning an empty string, or silently skipping the key, would make the missing translation invisible, and it would only be noticed after release. A missing translation falls back to the default language: the session message, absent from the English catalog, came back in Turkish. This is better than the missing text not appearing at all, but it is a warning sign; it should be logged during development.
One last rule: messages are not built with string concatenation. Text written as “Total “
- count + “ records“ cannot be translated into a language with a different word order. The parts stay together in a single pattern, with placeholders.
Leaving Formatting to the Platform
Everything besides text — number, date, duration, list, sorting — changes by language and region, and every one of these rules is already available on the platform.
// formatting.mjs — how numbers, dates, lists, and sorting depend on locale const LOCALES = ["tr", "en-US", "de-DE"]; const MEASUREMENT = -4.2; const INSTANT = new Date("2026-01-14T06:00:00Z"); // fixed instant: output is reproducible console.log("temperature (with unit)"); for (const l of LOCALES) { console.log(" " + l.padEnd(6) + new Intl.NumberFormat(l, { style: "unit", unit: "celsius", minimumFractionDigits: 1 }).format(MEASUREMENT)); } console.log("large number"); for (const l of LOCALES) { console.log(" " + l.padEnd(6) + new Intl.NumberFormat(l).format(1840.5)); } console.log("date and time (UTC)"); for (const l of LOCALES) { console.log(" " + l.padEnd(6) + new Intl.DateTimeFormat(l, { dateStyle: "long", timeStyle: "short", timeZone: "UTC" }).format(INSTANT)); } console.log("relative time (-3 hours)"); for (const l of LOCALES) { console.log(" " + l.padEnd(6) + new Intl.RelativeTimeFormat(l, { numeric: "auto" }).format(-3, "hour")); } console.log("list"); const stations = ["North Slope", "East Ridge", "West Valley"]; for (const l of LOCALES) { console.log(" " + l.padEnd(6) + new Intl.ListFormat(l, { style: "long", type: "conjunction" }).format(stations)); } // --- Two Turkish-specific traps --------------------------------------------- const CITIES = ["Ordu", "Ödemiş", "Osmaniye", "Sivas", "Şile", "Isparta", "İstanbul"]; console.log("\nsorting"); console.log(" tr " + [...CITIES].sort(new Intl.Collator("tr").compare).join(", ")); console.log(" en " + [...CITIES].sort(new Intl.Collator("en").compare).join(", ")); console.log(" raw " + [...CITIES].sort().join(", ")); console.log("uppercasing"); console.log(" tr " + "ilk ölçüm".toLocaleUpperCase("tr")); console.log(" en " + "ilk ölçüm".toLocaleUpperCase("en"));
temperature (with unit) tr -4,2°C en-US -4.2°C de-DE -4,2 °C large number tr 1.840,5 en-US 1,840.5 de-DE 1.840,5 date and time (UTC) tr 14 Ocak 2026 06:00 en-US January 14, 2026 at 6:00 AM de-DE 14. Januar 2026 um 06:00 relative time (-3 hours) tr 3 saat önce en-US 3 hours ago de-DE vor 3 Stunden list tr North Slope, East Ridge ve West Valley en-US North Slope, East Ridge, and West Valley de-DE North Slope, East Ridge und West Valley sorting tr Isparta, İstanbul, Ordu, Osmaniye, Ödemiş, Sivas, Şile en Isparta, İstanbul, Ödemiş, Ordu, Osmaniye, Şile, Sivas raw Isparta, Ordu, Osmaniye, Sivas, Ödemiş, İstanbul, Şile uppercasing tr İLK ÖLÇÜM en ILK ÖLÇÜM
This output depends on the environment: the details of the formats can vary with the
version of locale data the runtime carries. The lines above were produced on an installation
where node --version reports v24.18.0. What can change is the detail of the format; the
principles do not.
Five observations follow.
The decimal separator and the thousands separator swap places by language. Turkish and German write the decimal with a comma, English with a period. Code that formats a number by hand misses this, and would show “1.840,5” as “1840.5”; worse, it would misread a “1,5” a user typed. This is why the validation schema converted between comma and period.
Even whether the unit sticks to the number depends on the language. In the German output there is a space between the number and the degree sign; in Turkish there is not.
Date order and the month name change; so does the time format. The English output uses a twelve-hour display, the others a twenty-four-hour one. The time zone has to be supplied separately; in the example, UTC is fixed, so the output is reproducible.
Joining a list is grammar. Turkish puts “ve” before the last item, English a comma and “and,” German “und.” Placing commas by hand and adding a conjunction before the last item means a separate rule for every language.
Sorting and case conversion are a special case in Turkish. Raw sorting gives code-point
order and does not match the Turkish alphabet at all. In Turkish sorting, O and Ö, S and
Ş are separate letters and are arranged in that order; in English sorting they are counted
as variants of the same letter, so “Ödemiş” sits in a different place in the two lists. In
case conversion, too, the uppercase of i is İ in Turkish and I in other languages; a
conversion done without supplying the locale produces a result that varies with the machine
it runs on.
Direction Support
Right-to-left languages require not just the text but the layout to change direction too. The work splits into three levels.
The document level. Direction is declared on the root element; direction is a property of the writing system, not of the language, so it is supplied separately from the language declaration. When direction changes, text alignment, which side a list’s bullet sits on, and scroll direction all flip on their own.
The layout level. The logical properties introduced in the Visual Presentation with CSS course find their match here: when the start and end edges of the block and inline axes are written instead of left and right, the layout adapts to a direction change on its own. Every rule written with a physical direction has to be reviewed one by one when direction support is added.
The text level. Mixed-direction text — a left-to-right station code inside a right-to-left sentence — raises a special problem: the ordering algorithm can push punctuation at the boundary to the wrong side. A fragment coming from the user or from data needs to be isolated from the surrounding text; markup has an element set aside for this, and this element’s default direction guess looks at the fragment’s own content.
Some icons are directional too: a forward arrow, a back arrow, indentation markers get mirrored on a direction change. Icons with no inherent direction, like a clock face or a volume level, are not mirrored.
Selecting the Locale and Loading the Catalog
Which language to use comes from three sources, and the priority order is fixed. The user’s
explicit preference sits at the top, and is stored. Failing that, the address itself is
used — a path segment or subdomain carrying the language code — which keeps the link
shareable. If neither is present, the language list the browser reports, the
Accept-Language header, is evaluated.
The browser’s list is a ranked preference, not a single language. Matching looks for an exact match first; if none is found, the region is dropped and tried again; if nothing sticks, it falls back to the default language. A locale contains more than a language — region, calendar, numbering system — so there are cases where the text language and the formatting setting can differ.
Loading the catalog is a bundling decision. Shipping every language in a single bundle makes the user download text they will never use. The right approach is producing a separate chunk per language and loading only the selected one; this ties directly to the Code Splitting topic in the Rendering Strategies and Infrastructure course. What the interface shows during the time it takes to load a new catalog after a language switch — the old language, or a blank — connects to the state machine from the Loading and Error States lesson.
Finally, translated text takes up space. The same sentence can be noticeably longer in one language than another; fixed-width buttons and labels squeezed onto a single line overflow once translated. Having the layout hold up under text length is an internationalization requirement.
Summary
- Internationalization makes the application adaptable and is done once; localization is content produced separately for each language.
- A translation catalog uses structured keys that define meaning; making the source sentence the key breaks every translation once the text is edited.
- Plural categories change by language and can depend on a number’s last digit; the rule is read from the platform, and the catalog only carries what to write for each category.
- Messages are built with a single pattern and placeholders, not string concatenation; a missing key is not hidden, and a missing translation falls back to the default language.
- Number, date, relative time, list, and sorting are left to the platform’s locale tools;
sorting in Turkish and converting the letter
ido not produce a correct result without supplying the locale. - Direction support is handled separately at the document, layout, and text levels; logical properties resolve the layout, and an isolation element resolves mixed-direction text.
Course Wrap-Up
This course built an application’s internal architecture around four questions.
Where is the user? The Routing topic made the address a part of the application’s state: nested layouts shared a common shell, route parameters carried state in the address, route guards managed access at the visibility level, and code splitting made every route pay its own cost.
Where does information live? The State Management topic split state into its kinds — local, shared, server, and address state — and showed a different container for each. Treating server state separately opened the door to the next topic.
How does information arrive? The Data Access topic built the request layer and the error contract, showed cache normalization for query-based access, consumed streamed data in a way that resists disconnection, reduced a view’s states to a finite set, and absorbed network instability without it reaching the user.
How does information get entered, and who enters it? The Forms and Identity topic built where a value is held, how rules stay identical on both sides, how an error is made perceivable, where a proof of identity is stored, how a session keeps going, and how the interface adapts to language.
What all four have in common is that they all take place inside the browser. This course has covered what happens after the application opens; we never once asked what happens before it opens. Yet the same application can be built in three different places: in the user’s browser, on the server answering the request, or at build time, before any request arrives. The choice determines how quickly the first screen arrives, what the search engine sees, how much work the server does, and how fresh the content can be.
The next course — Rendering Strategies and Infrastructure — builds this choice along with its trade-offs: the costs of rendering on the client, on the server, and at build time, selectively refreshing static output, gaining interactivity piece by piece, bundling and asset optimization, and the deployment pipeline that gets the application to the user.
To keep your progress and take notes, Log in
My notes
Log in to take notes.