Command Palette

Search for a command to run...

Hectal
PHASE 22Advanced ~29 min· topic 5 of 7

Topic 22.5

Internationalisation

In one line

Internationalisation (i18n) means building the app so it can speak any language and follow any region's rules: format numbers, money, dates and lists with the browser's Intl APIs, choose plural forms with Intl.PluralRules, keep all text in translation files read through a t() function (react-i18next, FormatJS), and lay out pages with logical CSS so right-to-left languages work.

Think of it like this

A travel adapter. Your laptop is the same in every country, but each country has a different socket. You don't rebuild the laptop; you carry one adapter per country. Internationalisation (shortened to i18n: the 18 letters between the i and the n) is designing the app so it accepts adapters. Localisation (l10n) is making each adapter: translating the text and choosing the formats for one locale. A locale is a language plus, usually, a region, written as a tag such as en-US, de-DE, hi-IN or ar-EG.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Internationalisation (i18n)
Building the app so it can support any language and region without code changes.
Localisation (l10n)
Adapting the app for one locale: translating text and choosing formats.
Locale
A language plus optional region, written as a tag such as en-US, de-DE, hi-IN.
Intl
The browser's built-in object of formatters for numbers, dates, plurals, lists and more.
Plural category
The grammatical group a number falls into in a language: zero, one, two, few, many or other.
Message file
A file (often JSON) mapping message ids to translated text for one language.
ICU MessageFormat
A standard syntax for messages with placeholders, plurals and choices: {count, plural, one {# item} other {# items}}.
RTL
Right-to-left scripts such as Arabic and Hebrew, enabled with dir="rtl".
Logical CSS property
A property named by text direction (margin-inline-start) instead of a physical side (margin-left).

Step by step

01Before: formats and English baked in

This component works perfectly, for one country. The $, the comma grouping, the date order, the word "items" and the plural rule are all hard-coded.

OrderCard.jsx (before)whole filejsx
function OrderCard({ order }) {
  const d = order.placed;
  return (
    <p>
      {order.count} {order.count === 1 ? "item" : "items"}, total ${order.total.toFixed(2)}, placed{" "}
      {d.getMonth() + 1}/{d.getDate()}/{d.getFullYear()}
    </p>
  );
}

02Put the locale in context and cache formatters

One provider at the top knows the locale (from the URL, the user's settings or navigator.languages). A hook builds the formatters once per locale.

shared/i18n/useFormat.jswhole filejavascript
export function useFormat() {
  const { locale, currency, timeZone } = useContext(LocaleContext);
  return useMemo(() => ({
    money: new Intl.NumberFormat(locale, { style: "currency", currency }).format,
    date: new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone }).format,
  }), [locale, currency, timeZone]);
}

03Move every sentence to message files

Each sentence is one message with placeholders. Plural messages get one entry per category the language uses.

locales/en.json and locales/pl.jsonwhole filejson
{
  "order.summary_one": "{count} item, total {total}, placed {date}",
  "order.summary_other": "{count} items, total {total}, placed {date}"
}

{
  "order.summary_one": "{count} produkt, razem {total}, złożono {date}",
  "order.summary_few": "{count} produkty, razem {total}, złożono {date}",
  "order.summary_many": "{count} produktów, razem {total}, złożono {date}",
  "order.summary_other": "{count} produktu, razem {total}, złożono {date}"
}

04After: the component only asks

The component passes raw values; formatting and grammar come from the locale.

OrderCard.jsx (after)whole filejsx
function OrderCard({ order }) {
  const t = useT();
  const f = useFormat();
  return <p>{t("order.summary", { count: order.count, total: f.money(order.total), date: f.date(order.placed) })}</p>;
}
After: the component only asksdiagram
Rendering diagram…

05Make the layout direction-proof

Replace physical CSS (left, right) with logical properties, set dir from the locale, and mirror only icons that point somewhere. Then test the page with dir="rtl" even before you have an RTL translation.

card.csswhole filecss
/* before */
.card { margin-left: 24px; padding-right: 8px; text-align: left; border-left: 4px solid teal; }
/* after */
.card { margin-inline-start: 24px; padding-inline-end: 8px; text-align: start; border-inline-start: 4px solid teal; }
[dir="rtl"] .icon-back { transform: scaleX(-1); }

Try it yourself

  1. 1

    Add a locale

    In the first example, add { locale: "ar-EG", currency: "EGP" } to LOCALES. Predict: which digits will it use? Run it. Arabic in Egypt uses Arabic-Indic digits (١٢٣) and the amount is wrapped in invisible right-to-left marks.

  2. 2

    Remove the time zone

    Delete timeZone: "UTC" from the date formatter. Predict whether your output still matches the expected one. It now depends on your computer's zone: in India the time becomes 16:00. This is the classic server/client hydration mismatch.

  3. 3

    Ordinals

    In the plurals example, add messages place_one: "{count}st", place_two: "{count}nd", place_few: "{count}rd", place_other: "{count}th" and select them with new Intl.PluralRules("en", { type: "ordinal" }). Predict the output for 1, 2, 3, 4, 11, 12, 21, 22 and 23.

Code & diagrams

One component, four locales: Intl does the formatting New tab

The same data, four results: grouping (lakh grouping in India), decimal sign, currency position, the yen's lack of decimals, the word order of dates, "yesterday" and the list joiner all come from the locale. The German amount contains a **non-breaking space** (U+00A0) before the €, which is why a test that compares against a typed `"1.234.567,89 €"` fails.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

en-US: $1,234,567.89 | January 15, 2024 at 10:30 AM | yesterday | Asha, Ben, and Chen
de-DE: 1.234.567,89 € | 15. Januar 2024 um 10:30 | gestern | Asha, Ben und Chen
hi-IN: ₹12,34,567.89 | 15 जनवरी 2024 को 10:30 am बजे | कल | Asha, Ben, और Chen
ja-JP: ¥1,234,568 | 2024年1月15日 10:30 | 昨日 | Asha、Ben、Chen
Plurals and placeholders: a tiny t() with Intl.PluralRules New tab

English needs two forms; Polish needs four, and 22 takes the same form as 2 ("few") while 5 takes "many" and a fraction takes "other". A `count === 1` check can't express that. Switch the menu to Polski and watch the list change.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

en: 1 file selected / 2 files selected / 5 files selected / 22 files selected / 1.5 files selected
pl: Wybrano 1 plik / Wybrano 2 pliki / Wybrano 5 plików / Wybrano 22 pliki / Wybrano 1.5 pliku
Cześć, Asha!
Right-to-left: logical CSS flips, physical CSS doesn't New tab

In Arabic the teal paragraph (logical properties) moves its margin and border to the right, where the text starts; the red one (physical `left`) stays on the left and looks broken. The back arrow is mirrored because it points somewhere. `text-align` stays `start` in both: the browser resolves it from `dir`.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

ltr | logical: left 24px right 0px | physical: left 24px right 0px | text-align start
rtl | logical: left 0px right 24px | physical: left 24px right 0px | text-align start
The same with react-i18next and with FormatJS (react-intl)jsx
// ---- react-i18next ----
// i18n.js
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
i18n.use(initReactI18next).init({
  lng: "pl",
  fallbackLng: "en",
  resources: {
    en: { translation: { files: { count_one: "{{count}} file selected", count_other: "{{count}} files selected" } } },
    pl: { translation: { files: { count_one: "Wybrano {{count}} plik", count_few: "Wybrano {{count}} pliki",
                                  count_many: "Wybrano {{count}} plików", count_other: "Wybrano {{count}} pliku" } } },
  },
  interpolation: { escapeValue: false }, // React already escapes text
});

// FilePicker.jsx
import { useTranslation } from "react-i18next";
function FilePicker({ count }) {
  const { t, i18n } = useTranslation();
  return <p>{t("files.count", { count })}</p>;        // "Wybrano 5 plików"
}
// i18n.changeLanguage("en") re-renders every component that uses useTranslation.

// ---- FormatJS / react-intl: ICU MessageFormat ----
import { IntlProvider, FormattedMessage, FormattedNumber, useIntl } from "react-intl";

const plMessages = {
  "files.count": "{count, plural, one {Wybrano # plik} few {Wybrano # pliki} many {Wybrano # plików} other {Wybrano # pliku}}",
};

<IntlProvider locale="pl-PL" messages={plMessages} defaultLocale="en">
  <FormattedMessage id="files.count" defaultMessage="{count, plural, one {# file selected} other {# files selected}}" values={{ count: 5 }} />
  <FormattedNumber value={1234.5} style="currency" currency="PLN" />
</IntlProvider>;
// renders: Wybrano 5 plików  1234,50 zł
// useIntl().formatMessage({ id: "files.count" }, { count: 5 }) gives the same string in code.

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Testing formatted text against a typed string

In a test, write expect(price.textContent).toBe("1.234.567,89 €") for the German price, typing the expected string by hand.

terminal
$ npx vitest run
── what you'll see ──
AssertionError: expected '1.234.567,89 €' to be '1.234.567,89 €' // Object.is equality

Break #2

Concatenating translated fragments

Build a sentence as t("you_have") + " " + count + " " + t("new_messages").

terminal
$ Preview (Japanese)
── what you'll see ──
あなたは 3 新しいメッセージ (word order and counter word are wrong; a translator can't fix it)

Myth vs fact

Myth

i18n just means translating the text.

Fact

Translation is a part. Numbers, currencies, dates, time zones, plurals, sorting (Intl.Collator), text direction, fonts and even layout length (German text is often 30% longer) all change by locale.

Myth

Plural rules are count === 1 ? singular : plural.

Fact

Languages have between one and six plural categories, chosen by rules about the number's last digits. Use Intl.PluralRules (directly or through a library).

Myth

You need a big library to format dates and money.

Fact

Intl is built into every modern browser and Node, covers hundreds of locales, and costs zero bytes of bundle. Libraries add translation management, ICU message parsing and React bindings.

Myth

RTL support means flipping the whole page with transform: scaleX(-1).

Fact

dir="rtl" plus logical CSS properties lets the browser mirror layout properly; only direction-carrying icons are flipped. Text, numbers, logos and media are not mirrored.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Intl objects cache locale data internally but constructing them is still relatively expensive (resolving locale data and options); profilers regularly show new Intl.NumberFormat inside list rows. Create formatters once per locale and option set (module-level Map or useMemo) and call .format many times.

  • ▸

    Intl.NumberFormat.prototype.formatToParts returns the pieces (currency, integer, group, decimal, fraction), so you can style the currency symbol differently without parsing strings. DateTimeFormat has the same method.

  • ▸

    Server rendering plus i18n needs care: the server must use the request's locale and time zone (from the URL, a cookie or Accept-Language), not the server machine's, and pass them to the client so hydration produces identical text. When a value really differs per device ("3 minutes ago"), render it after hydration or add suppressHydrationWarning to that single element.

  • ▸

    new Intl.Locale("ar").getTextInfo().direction returns "rtl" in recent Chromium (the older textInfo getter is being replaced by the method), which lets you derive dir from the locale instead of keeping a list of RTL languages.

Remember this

  1. 1

    Numbers, money and dates look different in every locale: 1,234,567.89 in the US is 1.234.567,89 in Germany and 12,34,567.89 in India (lakh grouping). Never build them by hand. The browser's built-in **Intl APIs** know the rules for hundreds of locales: Intl.NumberFormat (numbers, currency, percent, units, compact 1.2M), Intl.DateTimeFormat (dates and times), Intl.RelativeTimeFormat ("yesterday", "in 2 hours"), Intl.ListFormat ("Asha, Ben, and Chen") and Intl.PluralRules. They cost nothing to download because they're part of the browser.

  2. 2

    Dates have a hidden trap: time zones. new Date("2024-01-15T10:30:00Z") is one instant, but it's 10:30 in London and 16:00 in Delhi. A DateTimeFormat without a timeZone uses the device's zone, so the same code prints different text on the server and in the browser, which in server rendering causes hydration mismatches (Phase 20). Store and send instants in UTC (ISO strings ending in Z), and format with an explicit timeZone: the user's zone for local times, or "UTC" when you need the same output everywhere. Always pass the locale explicitly, too: the default is whatever the machine is set to.

  3. 3

    Plurals are harder than count === 1 ? "file" : "files". English has two forms (one, other), Polish has four (one, few, many, other: 1 plik, 2 pliki, 5 plików, 22 pliki), Arabic has six (zero, one, two, few, many, other), and Japanese has one. new Intl.PluralRules(locale).select(n) returns the plural category for a number, and translation files store one message per category. With { type: "ordinal" } it handles 1st, 2nd, 3rd, 4th, 11th, 21st.

  4. 4

    All visible text lives in message files, one per language (en.json, pl.json), keyed by id: "cart.items_one": "{count} item". Components never contain English; they call t("cart.items", { count }). Don't concatenate translated pieces (t("you have") + n + t("messages")): word order differs between languages, so each sentence must be one message with placeholders. Libraries add the rest: react-i18next (useTranslation() returns t, plural keys use _one/_other suffixes picked with Intl.PluralRules, translations can lazy-load per language) and FormatJS / react-intl (messages in ICU MessageFormat: {count, plural, one {# item} other {# items}}, components such as <FormattedMessage> and <FormattedNumber>).

  5. 5

    Some scripts run right to left (RTL): Arabic, Hebrew, Persian, Urdu. Set dir="rtl" (and lang) on <html> or on a section, and the browser mirrors text alignment, the order of inline content and flex rows. Your CSS must cooperate: logical properties such as margin-inline-start, padding-inline-end, border-inline-start, inset-inline-start and text-align: start mean "the side where text starts", so they flip automatically, while margin-left stays on the left and looks wrong. Icons that show direction (back arrows, progress) need mirroring; icons that don't (a clock, a tick) must not be mirrored.

  6. 6

    In React, i18n is mostly context plus memoisation. The current locale lives in context (Topic 9.2) or the URL (/de/products). Formatter objects are fairly expensive to create, so build them once per locale with useMemo or a module-level cache and reuse them. Set <html lang> (and dir) whenever the locale changes so screen readers pronounce the text correctly and the browser picks the right fonts and hyphenation.

Explain it without notes

01

How would you add internationalisation to an existing React app?

02

Why can't you write plural messages as count === 1 ? a : b, and what do you use instead?

03

What problems do time zones cause in a React app, especially with server rendering?

04

How do you support right-to-left languages?

Practice

01

Write a useRelativeTime(date, now) hook that returns text such as "3 minutes ago" or "in 2 days" with Intl.RelativeTimeFormat in the current locale, choosing the largest sensible unit.

02

Convert this CSS to logical properties: .toast { position: fixed; right: 16px; padding-left: 12px; border-left: 4px solid; text-align: left; }.

Trade-offs

  • ↔

    react-i18next is flexible, popular and JSON-key based; FormatJS follows the ICU standard that translators' tools understand but has a steeper syntax. Both add a few tens of KB; Intl alone handles formatting for free.

  • ↔

    Loading every language up front is simple but grows the bundle; lazy-loading per language keeps it small at the cost of a loading state when switching.

  • ↔

    Locale in the URL (/de/...) is shareable and SEO-friendly but touches routing; locale in a cookie or setting is simpler but invisible to search engines.

Done when you can

  • I can format numbers, currency, dates, relative times and lists with Intl for any locale.

  • I can always pass an explicit locale and timeZone and explain why.

  • I can choose plural forms with Intl.PluralRules and write messages per plural category.

  • I can describe how react-i18next and FormatJS (ICU MessageFormat) organise messages.

  • I can make a layout work right-to-left with dir and logical CSS properties.