PAUL CHONGSenior Software Engineer

Internationalization (i18n)

2026-08-08 · 7 min read

Internationalization is the practice of building UIs that can be adapted to different languages, regions, and writing systems without engineering changes for each new locale. The cost of doing it wrong is high — retrofitting RTL support or re-engineering date formatting into a mature codebase is expensive. Building for it from the start is cheap.

RTL (Right-to-Left) layout

Arabic, Hebrew, Farsi, and Urdu are written right-to-left. In an RTL layout, the entire page mirrors — content flows from right to left, the sidebar that was on the left is now on the right, and back/forward arrows reverse direction.

The wrong approach is to detect RTL and manually override left/right CSS values. The right approach is to never use physical properties in the first place.

CSS logical properties

CSS logical properties describe layout relative to the text flow direction, not the physical screen:

PhysicalLogical
margin-leftmargin-inline-start
margin-rightmargin-inline-end
padding-leftpadding-inline-start
padding-rightpadding-inline-end
border-leftborder-inline-start
leftinset-inline-start

In LTR, margin-inline-start resolves to margin-left. In RTL, it resolves to margin-right. The same CSS works for both directions.

/* Wrong: hardcoded physical direction */
.nav-icon {
  margin-right: 8px;
}

/* Right: flips automatically in RTL */
.nav-icon {
  margin-inline-end: 8px;
}

Enabling RTL

Set direction: rtl on the <html> element (or <body>). Flexbox and grid layouts that use logical properties flip automatically:

<html lang="ar" dir="rtl">
// Switch direction based on locale
document.documentElement.dir = locale === 'ar' || locale === 'he' ? 'rtl' : 'ltr';
document.documentElement.lang = locale;

Flex rows reverse, justify-content: flex-start now aligns to the right, and text alignment follows the writing direction — all without touching layout CSS.

The time to audit for physical properties is before shipping, not after. A codebase full of margin-left and padding-right means an RTL implementation is a search-and-replace exercise across hundreds of files.

Bidirectional text

A single post can contain multiple writing directions — an Arabic paragraph with an embedded English URL, or a Hebrew tweet quoting an English source. This is bidirectional (bidi) text, and the browser handles it automatically via the Unicode Bidirectional Algorithm.

<!-- Let the browser detect direction from the first strong character -->
<p dir="auto">
  مرحبا بكم في React — the JavaScript library for building UIs
</p>

dir="auto" on the text container tells the browser to infer the base direction from the first strong directional character in the content. Arabic characters set RTL; Latin characters set LTR. The browser then applies the Unicode Bidi Algorithm to handle embedded runs of the opposite direction correctly.

Use dir="auto" on any user-generated content container — post bodies, comments, messages — where you can't predict the language at render time.

Locale-aware formatting

Never format dates, numbers, or relative times with hand-rolled string manipulation. The Intl API handles locale-specific formatting correctly across all languages and regions.

Dates

const date = new Date('2026-08-08');

new Intl.DateTimeFormat('en-US', { dateStyle: 'full' }).format(date);
// "Friday, August 8, 2026"

new Intl.DateTimeFormat('ja-JP', { dateStyle: 'full' }).format(date);
// "2026年8月8日金曜日"

new Intl.DateTimeFormat('de-DE', { dateStyle: 'full' }).format(date);
// "Freitag, 8. August 2026"

Numbers and counts

new Intl.NumberFormat('en-US').format(103000); // "103,000"
new Intl.NumberFormat('de-DE').format(103000); // "103.000"
new Intl.NumberFormat('hi-IN').format(103000); // "1,03,000"

Grouping separators, decimal characters, and digit grouping conventions all vary by locale. Intl.NumberFormat handles all of them.

Relative time

const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
rtf.format(-2, 'day');  // "2 days ago"
rtf.format(1, 'day');   // "tomorrow"

new Intl.RelativeTimeFormat('zh-CN').format(-2, 'day'); // "2天前"
new Intl.RelativeTimeFormat('ar').format(-2, 'day');    // "قبل يومين"

Pluralization

Plural rules vary dramatically by language. English has two forms (one / other). Arabic has six (zero, one, two, few, many, other). Russian pluralization depends on the last two digits of the number. Hand-coding this is error-prone.

new Intl.PluralRules('en').select(1);  // "one"
new Intl.PluralRules('en').select(5);  // "other"

new Intl.PluralRules('ar').select(0);  // "zero"
new Intl.PluralRules('ar').select(1);  // "one"
new Intl.PluralRules('ar').select(2);  // "two"
new Intl.PluralRules('ar').select(5);  // "few"
new Intl.PluralRules('ar').select(11); // "many"
new Intl.PluralRules('ar').select(100);// "other"

Use the plural category from Intl.PluralRules to select the correct translated string from your translation bundle.

Determining the locale

All Intl APIs accept the same locale string — but where does that string come from?

navigator.language — the browser's preferred language, derived from OS settings. A good client-side default:

const locale = navigator.language; // "ar", "en-US", "de-DE"

navigator.languages — an ordered list of preferred languages, most preferred first. Use index 0 for the top preference:

const locale = navigator.languages[0] ?? navigator.language;

Accept-Language header — sent by the browser on every request. The server can read it to detect locale before the page renders, set the correct <html lang="">, and serve the right translation bundle in the SSR'd HTML — no locale flash on load.

User account preference — what most large apps do. The user explicitly sets their language in settings, it's stored in their profile, and the server reads it on every request. More reliable than browser detection because it persists across devices and browsers.

In practice, use all three as a fallback chain:

// 1. User's saved preference (most reliable)
// 2. Browser language (good default for logged-out users)
// 3. Safe baseline
const locale = user?.preferredLocale ?? navigator.language ?? 'en';

new Intl.DateTimeFormat(locale).format(date);
new Intl.NumberFormat(locale).format(count);
new Intl.PluralRules(locale).select(count);

IME (Input Method Editors)

CJK languages — Chinese, Japanese, Korean — are written using thousands of characters that can't be typed directly on a standard keyboard. Users type phonetic sequences and an IME assembles them into characters through a composition process.

Korean Hangul is a clear example. Each syllable block is built from individual consonants and vowels typed in sequence. Typing "한" (han):

User types: ㅎ  →  하  →  한
             h       ha      han (committed on Space or next key)

Each intermediate state is uncommitted — shown with an underline in the input field. If you fire a search API on every input event, you'd be sending requests for , , before the user has finished a single syllable.

The browser fires composition events to signal when composition starts and ends:

let isComposing = false;

input.addEventListener('compositionstart', () => { isComposing = true; });
input.addEventListener('compositionend', () => {
  isComposing = false;
  handleInput(input.value); // fires once: "한"
});

input.addEventListener('input', () => {
  if (!isComposing) handleInput(input.value); // skips ㅎ, 하, 한
});

compositionstart fires when IME input begins. compositionend fires when the user commits the final character. Any search, validation, or API call should be gated on !isComposing or deferred to compositionend.

This applies to:

  • Search / autocomplete: fire the API on compositionend, not keydown or input
  • Form validation: don't validate a field while IME composition is active
  • Character limits: count characters against the limit on compositionend, not during composition

String externalization

Hardcoded strings in components make a codebase untranslatable. Every user-facing string — labels, error messages, button text, empty states — must live in a translation file, not in the component.

// Wrong: untranslatable
<button>Submit</button>
<p>No results found for "{query}"</p>

// Right: externalized
<button>{t('actions.submit')}</button>
<p>{t('search.noResults', { query })}</p>

Translation files map keys to locale-specific strings:

// en.json
{
  "actions": { "submit": "Submit" },
  "search": { "noResults": "No results found for \"{query}\"" }
}

// ar.json
{
  "actions": { "submit": "إرسال" },
  "search": { "noResults": "لا توجد نتائج لـ \"{query}\"" }
}

ICU MessageFormat

Plain string interpolation breaks for pluralization and gender-aware strings. ICU MessageFormat handles both:

// Pluralization
"{count, plural, one {# post} other {# posts}}"
// count=1 → "1 post", count=5 → "5 posts"

// Gender
"{gender, select, male {He liked your post} female {She liked your post} other {They liked your post}}"

Libraries like i18next and FormatJS (react-intl) implement ICU MessageFormat and integrate with React. They handle string lookup, interpolation, pluralization, and locale fallbacks.

// react-intl
import { useIntl } from 'react-intl';

function PostCount({ count }) {
  const intl = useIntl();
  return <p>{intl.formatMessage({ id: 'feed.postCount' }, { count })}</p>;
}

The translation pipeline is: developers write strings in the source locale (usually English) → strings are extracted to translation files → translators localize them → the app loads the correct file based on the user's locale setting.