Internationalization (i18n) & localization (l10n)
How to design a web app so it can be translated, localized for currencies and dates, and laid out for right-to-left languages — without rewriting it later. ICU MessageFormat, locale routing, the timezone trap, and the right way to do plurals.
Internationalization (i18n) & localization (l10n)
In one line: Internationalization (i18n) is making your app translatable and culturally adaptable; localization (l10n) is the actual adaptation — both are vastly cheaper if you do them from the start than to retrofit, and the techniques (message catalogs, ICU MessageFormat, locale-aware date/number formatting, RTL-friendly CSS) are the same in every framework.
i18n is the engineering of "this app can be translated." l10n is the content of "here's the Japanese translation." You can have one without the other (an English-only site with i18n in place, ready to translate), but you cannot have l10n without i18n. The expensive thing isn't translating words — it's untangling all the hardcoded "$" symbols, US date formats, English-only plurals, and left-aligned layouts that you baked in before thinking about it.
This page is the working playbook. If you might ever support more than one locale — or one region (US vs UK English, EU vs US date formats, USD vs EUR) — the day-one decisions save you from a multi-month rewrite later.
What i18n actually covers
| Aspect | Example |
|---|---|
| Translation of UI strings | "Sign up" → "Inscríbete" |
| Pluralization | "1 item" vs "5 items" — different rules per language |
| Date/time formatting | 2026-05-26 vs 26/05/2026 vs 5/26/26 vs 令和8年5月26日 |
| Number formatting | 1,234.56 vs 1.234,56 vs 1 234,56 |
| Currency formatting | $1,234.56 vs 1.234,56 € vs ¥1,235 |
| Timezone handling | "5pm" — in whose timezone? |
| Direction (RTL) | Arabic, Hebrew, Urdu flow right-to-left |
| Sorting (collation) | Different alphabets, different sort orders |
| Capitalization | Some languages don't have it the way English does |
| Address/phone formats | US vs UK vs JP — completely different structures |
| Cultural | Calendar systems, name order, formality registers |
| SEO | Per-locale URLs, hreflang |
The expensive ones to retrofit are: pluralization, RTL layout, date/time, and translation injection. Worth setting up from day one even if you only ship English.
Locale routing
The first decision: how does a URL identify a locale?
Subdirectory (recommended default)
example.com/en/blog/post
example.com/ja/blog/post
example.com/es/blog/post
Pros: one domain (SEO simpler), easy to share assets, easy hosting. Cons: locale handling at the routing layer.
Subdomain
en.example.com
ja.example.com
Pros: clear separation, can deploy separately. Cons: technically separate "sites" for SEO; cookie scoping is fiddly.
ccTLDs
example.de
example.fr
example.co.jp
Pros: strongest geographic signal, looks local. Cons: expensive, separate ops, complex DNS, separate SEO entities.
Path-less with content negotiation
example.com (serves based on Accept-Language header)
Pros: one URL. Cons: not shareable ("send me the English link"), harder to crawl, generally avoided.
Pick subdirectory unless you have a specific reason otherwise. Next.js, Astro, SvelteKit, Remix all support it natively.
// Next.js App Router — /app/[locale]/page.tsx
export async function generateStaticParams() {
return [{ locale: 'en' }, { locale: 'ja' }, { locale: 'es' }];
}
Message catalogs and ICU MessageFormat
The naive approach (works fine for small apps)
A nested object per locale:
// messages/en.json
{
"greeting": "Hello",
"welcome": "Welcome, {{name}}!",
"items_count": "{{count}} items"
}
// messages/ja.json
{
"greeting": "こんにちは",
"welcome": "ようこそ、{{name}}!",
"items_count": "{{count}} アイテム"
}
Use it via a t() function:
<h1>{t('welcome', { name: user.name })}</h1>
Works for small apps. Falls apart on pluralization (next section).
ICU MessageFormat (the right way for non-trivial)
ICU MessageFormat is the cross-language standard for translatable strings with variables, plurals, gender, and selectors.
"items_count": "{count, plural, =0 {No items} one {# item} other {# items}}"
For English: count=0 → "No items"; count=1 → "1 item"; count=5 → "5 items".
For languages with more plural forms (Russian, Polish, Arabic have 3-6 plural categories):
"items_count": "{count, plural, =0 {Нет элементов} one {# элемент} few {# элемента} many {# элементов} other {# элемента}}"
The translator fills the categories appropriate to their language. Your code stays the same.
The plural problem
Many devs default to count === 1 ? 'item' : 'items'. This is wrong everywhere except English:
| Language | Categories |
|---|---|
| English | one, other (2) |
| Russian | one, few, many, other (4 — based on last digit and tens) |
| Arabic | zero, one, two, few, many, other (6) |
| Welsh | zero, one, two, few, many, other (6) |
| Chinese / Japanese / Korean | other only (1) |
The Unicode CLDR defines plural rules per language. Libraries (i18next, FormatJS, Lingui, Intl.PluralRules) implement them.
// Native browser
new Intl.PluralRules('ru').select(2); // "few"
new Intl.PluralRules('ja').select(99); // "other"
Always use a plural-aware formatter. Hardcoding count === 1 is one of the most common i18n bugs.
Libraries
| Library | Strengths |
|---|---|
| next-intl | Next.js-first; ICU support built in |
| react-intl / FormatJS | React, ICU, mature |
| i18next + react-i18next | Framework-agnostic, plugin ecosystem |
| Lingui | TypeScript-first, macros, compile-time extraction |
| Vue I18n | Vue ecosystem |
| svelte-i18n | Svelte ecosystem |
| Crowdin / Lokalise / Phrase | Translation management platforms (TMS), source-of-truth for translators |
For a Next.js app in 2026: next-intl. For React generic: FormatJS or Lingui (Lingui has better DX).
The Intl API: the most underused tool in JS
The browser (and Node) ship with Intl.* — locale-aware formatters for dates, numbers, lists, relative time, and more. Zero deps, fast, correct.
// Numbers
new Intl.NumberFormat('en-US').format(1234567.89); // "1,234,567.89"
new Intl.NumberFormat('de-DE').format(1234567.89); // "1.234.567,89"
new Intl.NumberFormat('hi-IN').format(1234567.89); // "12,34,567.89"
// Currency
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(1234.5); // "$1,234.50"
new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' }).format(1234.5); // "¥1,235"
// Dates
new Intl.DateTimeFormat('en-US').format(new Date()); // "5/26/2026"
new Intl.DateTimeFormat('en-GB').format(new Date()); // "26/05/2026"
new Intl.DateTimeFormat('ja-JP-u-ca-japanese').format(new Date()); // "令8/5/26"
// Relative time
new Intl.RelativeTimeFormat('en').format(-3, 'day'); // "3 days ago"
new Intl.RelativeTimeFormat('es').format(-3, 'day'); // "hace 3 días"
// Lists
new Intl.ListFormat('en', { style: 'long', type: 'conjunction' }).format(['a','b','c']); // "a, b, and c"
new Intl.ListFormat('de', { style: 'long', type: 'conjunction' }).format(['a','b','c']); // "a, b und c"
If you're hand-formatting numbers or dates, you're almost certainly doing it wrong. Use Intl.
Timezones
The most common i18n bug isn't a wrong translation — it's wrong time.
The rules
- Store all timestamps in UTC in the database. Not "user's local time"; not "server time." UTC.
- Convert to the user's timezone at the display boundary. The user's timezone is not their locale —
en-USusers live in dozens of timezones. - Get the user's timezone from the browser (
Intl.DateTimeFormat().resolvedOptions().timeZonereturns "America/Los_Angeles"). Store it on their account for server-rendered pages. - Use IANA timezone names (e.g., "Europe/London") — not offsets like "+01:00" (which doesn't capture DST).
The DST trap
You schedule a recurring event at "9am every weekday in London." DST shifts. Your stored offset (+01:00) was right in summer; now it's wrong.
Solution: store the timezone name and the local time; compute the UTC instant each occurrence. Libraries: Temporal API (modern), Luxon, date-fns-tz, or the experimental @js-temporal/polyfill.
Recurring events
The hard case in calendar/event apps. The rules:
- For recurring events, store the user's intended timezone + the local time + recurrence rule.
- At display time, compute each occurrence's UTC instant, respecting DST.
- For "fixed UTC" events (a 9pm UTC livestream), store the UTC time; localized display will compute the user's local view.
Don't trust the server clock as gospel
NTP keeps clocks within ~10ms but isn't perfect. For ordering, use DB-generated timestamps where possible; never compare Date.now() from two different machines as exact equality.
RTL (right-to-left) layout
Arabic, Hebrew, Persian, Urdu — and Yiddish, Pashto, Dari — write right-to-left. Layout flips.
Use logical properties
CSS lets you write direction-agnostic layout:
/* Old, direction-tied */
.card { margin-left: 16px; padding-right: 8px; text-align: left; }
/* New, direction-agnostic */
.card { margin-inline-start: 16px; padding-inline-end: 8px; text-align: start; }
margin-inline-start is "left" in LTR, "right" in RTL. The CSS flips automatically with dir="rtl" on <html> or a container.
What changes
- Horizontal alignment.
- Margins/paddings, borders.
- Icons that have direction (arrows, chat bubbles, "next" buttons).
- Some visualizations (timelines, breadcrumbs).
- Numbers stay LTR even inside RTL text — be careful with mixed content.
Tooling
Tailwind has RTL plugins. Vanilla CSS has logical properties (broad support since ~2021). Test RTL with <html dir="rtl"> and a Hebrew/Arabic translation in your * fonts.
Translation workflow
Source language
Most teams write in English (the source language), then translate. The codebase has English strings (or keys with default English values); translators fill other locales.
Extraction
Tools scan your code for translatable strings and extract them to catalogs:
# Lingui example
npx lingui extract # produces messages.po per locale
import { Trans } from '@lingui/react';
<Trans>Welcome, {user.name}!</Trans>
The extractor finds the <Trans> calls, builds catalogs, and your translators (or LLM, or human translators via a TMS) fill them.
Translation management systems (TMS)
For non-trivial projects, use a TMS:
- Crowdin, Lokalise, Phrase, POEditor, Transifex — translators see context, suggest translations, get review workflow.
- Pull translations into your repo via CLI/CI.
For small projects: a translator filling a JSON file, manually committed.
LLM-powered translation
In 2026, the workflow is often: LLM does the first pass, a human translator reviews. Quality is excellent for most languages and dropping in price.
But: cultural nuance, brand voice, idioms, slang, sensitive content — human review remains valuable. Don't ship pure-LLM translations for a customer-facing premium product without review.
SEO for i18n
See SEO fundamentals. Key: hreflang tags announcing each locale variant, per-locale sitemaps, subdirectory or ccTLD URL structure.
Cultural and content gotchas
Beyond strings:
- Name order. "First Last" in Western; "Last First" in many Asian cultures. Don't assume the second word is the surname.
- Honorifics and formality. Japanese has different forms for different formality levels; German has
du/Sie. Pick a register and use it consistently. - Calendar systems. Some users want Hijri, Persian, Hebrew, or Japanese (令和) calendars.
- Phone numbers and addresses. Country-specific formats; libraries (
libphonenumber-js) parse and format. - Currency conversion. If you sell in multiple currencies, exchange rates change. Display the user's currency; charge in the merchant's. Or charge in user's currency (more UX work).
- Time-sensitive content. "Mother's Day" is on different days in different countries. "Christmas" isn't universal. Don't hardcode dates.
- Right-of-center / left-of-center cultural assumptions — colors (white = mourning in some Asian cultures, weddings in the West), gestures, examples (don't use a credit card example with a US card in JP).
- Censorship and legal requirements. Some content is illegal in some jurisdictions (Nazi symbols in Germany; certain political content in China). Plan for region-specific rules if you operate there.
Common mistakes
- Concatenating strings.
"Hello, " + name + "!"doesn't survive translation — word order varies by language. Use placeholders in messages:"Hello, {name}!" - Hardcoded
count === 1. Plural rules differ per language. Use ICUpluralorIntl.PluralRules. - Hardcoded
$,€, dates.Intl.NumberFormatandDateTimeFormatdo this correctly. Roll your own and it'll be wrong somewhere. - Storing timestamps as local-time strings. Always UTC in storage; localize at display.
- Confusing locale and timezone. A user with locale
en-UScould be in Tokyo. Don't infer one from the other. - Translating literal strings instead of keys. "Submit" appears in 12 places, gets translated 12 different ways. Use stable keys, not the English text, for the catalog.
- No context for translators. Translator sees "Run" — is that a verb or a noun? Provide context (
runActionButton.label, with description "label for the button that runs the action"). - Pseudo-localization missing. Before shipping, replace strings with
[!!! Háéllóó wörld !!!]-style accented versions. Long-form text reveals truncation; surrounded brackets reveal hardcoded strings. Catch issues before launch. - RTL layout broken. Hardcoded
left/margin-left/ arrows pointing right. Switch to logical properties; test withdir="rtl". - Currency conversion done client-side from stale rates. Either trust your server's rate or use a real FX API; never use cached client-side rates for billing.
- Locale URLs with no
hreflang. Search engines don't know which locale variant to show; users land on the wrong one. Always pair locale URLs withhreflangtags. - No fallback locale. Translation missing → blank string in UI. Always have a fallback chain (
ja → en). - Trying to detect locale from IP geolocation. A user in Germany may prefer English. Use
Accept-Languagefirst, geolocation as a secondary signal, and always give the user a manual switcher. - Locale-switching loses URL. User clicks "Deutsch" and lands on
/de(home) instead of the German version of the current page. Map the current URL to its locale equivalent.
Page checkpoint
Did i18n stick?
RequiredWhat's next
→ Continue to SEO fundamentals.