Skip to content
academia.sh

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 i do 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.

Start typing to search.

↑↓ Esc navigate · open · close