Loading...
Loading...
Locale bundles, RTL, CDN regional delivery, ICU, and compliance copy at global scale.
Module 22 · Global i18n at Scale
Locale bundle splitting, RTL layouts, CDN regional delivery, ICU message format, compliance copy, and i18n testing at global scale.
Locale bundle splitting, RTL layouts with logical CSS, CDN regional delivery, ICU message format, compliance copy, and i18n testing.
Global products need more than translation — locale-aware formatting, RTL layouts, regional CDN delivery, ICU plurals, and jurisdiction-specific compliance copy.
This module covers lazy locale bundles, CSS logical properties for RTL, ICU via FormatJS/next-intl, compliance copy workflows, and pseudo-localization CI.
Staff interviews ask how you ship 30 languages without bloating the bundle or breaking layouts.
Staff track module — extends Frontend System Design i18n section (Module 15) with operational depth.
Global products at staff scale require lazy locale bundles, RTL correctness, ICU plurals, and jurisdiction-specific compliance — not just string translation.
Locale bundles, RTL, CDN edge, ICU, and compliance copy.
Internationalization at staff scale is infrastructure — not just wrapping strings in t(). Engineers design locale loading, RTL layouts, CDN edge delivery, ICU pluralization, compliance copy, and performance budgets for 40+ languages.
The economics matter: 40 locales x 6 namespaces is 240 artifacts on every deploy, a translation vendor bill of roughly $0.12–0.25 per word, and a 3–5 day lag between merging English copy and shipping the translated build. Architecture has to absorb that lag without blocking releases.
| Concern | Owner | Failure if unowned |
|---|---|---|
| Source strings + ICU authoring | Feature teams | Concatenated strings that cannot be translated |
| TMS sync + vendor SLA | Localization team | Untranslated keys ship to production |
| Loader, fallback chain, Intl caching | Web platform | Every app invents its own locale loader |
| Legal copy per jurisdiction | Legal + product | Regulatory exposure in one market |
| RTL and pseudo-locale CI gates | Web platform | Layout breaks discovered by users in ar/he |
Migration path
Code-splitting, namespaces, and lazy loading.
// Lazy load locale namespace const messages = await import( `@/locales/${locale}/checkout.json` ); i18n.addResourceBundle(locale, "checkout", messages.default);
| Packaging | First paint cost | Cache behaviour | Cost of choosing it |
|---|---|---|---|
| All locales in main bundle | 40 x 45KB ≈ 1.8MB gzip | One cache entry | Unshippable on mobile |
| One file per locale | 45KB | Invalidated by any string edit | Fine under ~10 namespaces |
| Locale x namespace | 8–14KB critical path | Granular, high hit rate | 240 artifacts to build and purge |
| Server-rendered strings only | 0KB client | Full-page cache per locale | No client-side locale switch |
| Runtime fetch from TMS | Network on every load | Poor | Adds a live dependency to first paint |
// Fallback chain with a single network round trip per namespace. const CHAIN: Record<string, string[]> = { "es-MX": ["es-MX", "es", "en"], "pt-BR": ["pt-BR", "pt", "en"], }; export async function loadNamespace(locale: string, ns: string) { for (const candidate of CHAIN[locale] ?? [locale, "en"]) { const res = await fetch(`/i18n/${candidate}/${ns}.${HASH}.json`, { cache: "force-cache", // hashed URL => immutable, 1y max-age }); if (res.ok) return { locale: candidate, messages: await res.json() }; } throw new Error(`missing_namespace:${ns}`); } // Missing key must degrade, never crash: render the English string and report once. export function t(key: string, vars?: Record<string, unknown>): string { const msg = store.get(key) ?? source.get(key); if (!store.has(key)) reportOnce("i18n_missing_key", { key, locale: store.locale }); return msg ? format(msg, vars) : key; }
Failure mode
checkout.cta.submit to real customers. Always fall through to English and alert on the missing-key counter.Logical properties and mirroring beyond text.
| Physical | Logical (preferred) |
|---|---|
| margin-left | margin-inline-start |
| padding-right | padding-inline-end |
| border-left | border-inline-start |
| text-align: left | text-align: start |
| transform: translateX(8px) | translate: logical offset or dir-aware var |
/* Direction-aware without duplicating a whole stylesheet. */ .drawer { inset-inline-start: 0; /* left in LTR, right in RTL */ padding-inline: 16px 24px; /* start 16, end 24 */ border-inline-start: 2px solid var(--accent); } /* Mirror only glyphs that encode direction. */ [dir="rtl"] .icon-chevron-forward { transform: scaleX(-1); } [dir="rtl"] .brand-logo, [dir="rtl"] .icon-play { transform: none; } /* media controls never mirror */ /* Numerals, phone numbers, and code stay LTR inside RTL paragraphs. */ .code-inline, .phone { direction: ltr; unicode-bidi: isolate; }
Tailwind gives you most of this for free with logical utilities (ms-4, pe-6, text-start). The remaining bugs live in inline styles, canvas rendering, drag-and-drop maths, and any place a component hardcodes a pixel offset.
| RTL bug | What the user sees | Root cause |
|---|---|---|
| Drawer slides in from the wrong edge | Panel covers the nav | Hardcoded left / translateX |
| Progress bar fills backwards | Looks like it is undoing | Width animation anchored to left |
| Phone number reversed | +1 555 becomes 555 1+ | Missing unicode-bidi: isolate |
| Truncation clips the wrong side | Start of the sentence disappears | text-overflow with fixed direction |
| Tooltip escapes the viewport | Cut off at the edge | Positioning maths assumes LTR origin |
Staff depth
Geo routing, hreflang, and cached translations.
| URL scheme | SEO | Cacheability | Cost of choosing it |
|---|---|---|---|
| /es/checkout (path) | Strongest — distinct crawlable URLs | One cache key per locale | Routing rewrite for every app |
| es.example.com | Strong | Clean per-host caching | Certificates and cookie scope per host |
| example.es (ccTLD) | Strongest per market | Independent | Highest ops and legal cost |
| ?lang=es | Weak — often treated as duplicate | Query strings often stripped | Avoid for indexed pages |
| Cookie only | None — one URL for all locales | Breaks shared CDN cache | Never for public pages |
// Edge middleware: resolve once, redirect, then cache per locale. export function middleware(req: Request): Response | undefined { const url = new URL(req.url); if (LOCALE_PATH.test(url.pathname)) return; // already localized const explicit = readCookie(req, "locale"); // user override wins const geo = req.headers.get("x-vercel-ip-country"); // hint, never a lock const accepted = pickBestLocale(req.headers.get("accept-language"), SUPPORTED); const locale = explicit ?? accepted ?? DEFAULT_BY_COUNTRY[geo ?? ""] ?? "en"; url.pathname = `/${locale}${url.pathname}`; return Response.redirect(url, 307); // 307 keeps method + body } // Vary correctly or the CDN will serve Spanish HTML to English users. // Cache-Control: public, s-maxage=600, stale-while-revalidate=86400 // Vary: Accept-Language, Cookie
Failure mode
Vary header. One PoP caches the German HTML and serves it to everyone in that region for the full TTL. Blast radius is a whole geography, and a purge is the only mitigation.MessageFormat for complex grammar rules.
{count, plural, =0 {No items} one {# item} other {# items} }
// Real message: plural + select + inline formatting + BiDi-safe interpolation. // en.json "cart.summary": "{name} has {count, plural, =0 {no items} one {# item} other {# items} } totalling {total, number, ::currency/USD} — {gender, select, female {she} male {he} other {they} } saved {saved, number, ::percent}." // ar.json — six plural categories, and the name is isolated so BiDi cannot flip it. "cart.summary": "\u2068{name}\u2069 {count, plural, zero {لا عناصر} one {عنصر واحد} two {عنصران} few {# عناصر} many {# عنصرًا} other {# عنصر} } ..." // Runtime import { IntlMessageFormat } from "intl-messageformat"; const cache = new Map<string, IntlMessageFormat>(); export function formatMessage(locale: string, id: string, values: object): string { const key = `${locale}:${id}`; let mf = cache.get(key); // parsing ICU is the expensive part if (!mf) cache.set(key, (mf = new IntlMessageFormat(catalog[id], locale))); return mf.format(values) as string; }
| Language | Plural categories | Trap |
|---|---|---|
| en / de / es | one, other | Zero reads badly without an explicit =0 case |
| fr | one, other | 0 is singular, unlike English |
| pl | one, few, many, other | Depends on the last two digits |
| ru | one, few, many, other | 21 is 'one', 22 is 'few' |
| ar | zero, one, two, few, many, other | All six must exist or output falls back |
| ja / zh / ko | other only | No plural, but counters and spacing differ |
Legal text that varies by jurisdiction.
| Region | Requirement | UI surface | Reversal cost if wrong |
|---|---|---|---|
| EU | GDPR consent before non-essential cookies | Cookie banner + privacy link | Fines to 4% of global revenue |
| CA (US) | CCPA / CPRA opt-out | Footer Do Not Sell link | Regulator notice + remediation |
| BR | LGPD localized notice | Signup + privacy centre | Suspension of processing |
| DE | Impressum and withdrawal terms | Footer + checkout | Competitor cease-and-desist |
| JP | APPI cross-border disclosure | Consent copy at signup | Market entry blocked |
Compliance copy is jurisdiction-keyed, not language-keyed. A Spanish speaker in Madrid and one in Mexico City read the same words but are owed different notices. Key the catalog on jurisdiction × locale and store the accepted version id with every consent record so you can re-prompt only the users affected by a legal change.
Blast radius
Bundle size, TTFB, and runtime cost.
// Constructing Intl formatters is ~0.2–1ms each. In a 500-row table that is a frame budget. const numberFmt = new Map<string, Intl.NumberFormat>(); export function currency(locale: string, code: string, value: number): string { const key = `${locale}:${code}`; let fmt = numberFmt.get(key); if (!fmt) numberFmt.set(key, (fmt = new Intl.NumberFormat(locale, { style: "currency", currency: code, }))); return fmt.format(value); }
| Cost | Typical size | Budget | Lever |
|---|---|---|---|
| Locale namespace JSON | 8–45KB gzip | < 50KB per namespace | Split by route, prune unused keys in CI |
| Latin subset font | 22–35KB woff2 | < 40KB | unicode-range subsetting, font-display: swap |
| CJK font | 2–6MB full | < 120KB per page | Dynamic subsetting from rendered glyphs |
| ICU runtime parser | ~40KB gzip | Load once | Precompile messages at build time |
| Intl polyfills | 0–180KB | 0 on evergreen | Serve only to browsers that need them |
Budget
Pseudo-locale, visual regression, and key coverage.
// Pseudo-locale generator: accents prove the string went through t(), // brackets prove nothing is clipped, padding simulates German/Finnish length. const MAP: Record<string, string> = { a: "á", e: "é", i: "í", o: "ó", u: "ú" }; export function pseudo(msg: string): string { const body = msg.replace(/\{[^}]+\}/g, (m) => m) // never touch ICU args .replace(/[aeiou]/g, (c) => MAP[c] ?? c); const pad = "~".repeat(Math.ceil(body.length * 0.35)); // +35% length return `⟦${body}${pad}⟧`; } // CI gate: coverage per shipped locale, not a global pass/fail. const missing = Object.keys(en).filter((k) => !catalog[locale][k]); const coverage = 1 - missing.length / Object.keys(en).length; const threshold = LAUNCH_MARKETS.includes(locale) ? 0.98 : 0.9; if (coverage < threshold) { fail(`${locale} coverage ${(coverage * 100).toFixed(1)}% — missing ${missing.length} keys`); }
| Test | Runs on | Catches | CI cost |
|---|---|---|---|
| Key coverage diff | Every PR | Missing or orphaned keys | Seconds |
| Pseudo-locale snapshot | Every PR | Hardcoded strings, truncation | ~1 min |
| RTL visual regression (ar) | Every PR on UI paths | Mirroring and overflow bugs | 3–6 min |
| Longest-locale layout (de, fi) | Nightly | Button and nav overflow | 5 min |
| Intl formatting unit tests | Every PR | Currency, date, and plural drift | Seconds |
| Full 40-locale screenshots | Pre-release | Font and glyph regressions | 40+ min |
Gate pull requests on the cheap checks and keep the exhaustive matrix on a nightly or pre-release schedule. Two representative locales — Arabic for direction and German for length — catch the large majority of layout regressions without paying for 40 browser runs per commit.
Failure mode
Zahlungsinformationen best… because the label is clipped at a fixed width. Coverage tests pass on presence, not on fit — only a pseudo-locale or long-locale screenshot catches it.Global i18n at staff depth.
Global i18n at scale interview questions.
Test your understanding — 5 questions
You are launching in 40 additional languages. Translation files average 200KB each. What is the decisive architectural choice?