Internationalization (i18n) Cheat Sheet
Covers locale handling, the Intl API, react-i18next setup, ICU pluralization syntax, and RTL/logical-CSS techniques for internationalized web apps.
Core i18n Concepts
Vocabulary you need before wiring up translations.
- Locale- BCP 47 language tag identifying language + region, e.g. en-US, fr-CA
- ICU MessageFormat- Standard syntax for pluralization, gender, and interpolation inside translation strings
- Pluralization- Rules mapping a count to a grammatical category (zero/one/few/many/other) that vary by language
- RTL (right-to-left)- Layout direction required for Arabic, Hebrew, and other scripts
- Namespace- Grouping of translation keys by feature/page so translations can be lazy-loaded
- Fallback locale- Language used when a key is missing in the active locale's translation file
react-i18next Setup & Usage
The most common i18n library in the React ecosystem.
// i18n.js - setupimport i18n from 'i18next';import { initReactI18next } from 'react-i18next';i18n.use(initReactI18next).init({ resources: { en: { translation: { greeting: 'Hello, {{name}}!' } }, fr: { translation: { greeting: 'Bonjour, {{name}} !' } }, }, lng: 'en', fallbackLng: 'en', interpolation: { escapeValue: false }, // React already escapes output});// Greeting.jsx - usageimport { useTranslation } from 'react-i18next';function Greeting({ name }) { const { t, i18n } = useTranslation(); return ( <div> <p>{t('greeting', { name })}</p> <button onClick={() => i18n.changeLanguage('fr')}>FR</button> </div> );}
Native Intl API
Built-in browser formatting, no library required.
// Number / currency formattingnew Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(1234.5);// "1.234,50 \u20ac"// Date formattingnew Intl.DateTimeFormat('en-US', { dateStyle: 'long' }).format(new Date());// "July 8, 2026"// Relative time ("2 days ago", "in 3 hours")const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });rtf.format(-1, 'day'); // "yesterday"// Pluralization category for a given locale/numberconst pr = new Intl.PluralRules('en-US');pr.select(1); // "one"pr.select(5); // "other"
ICU Plural Syntax
A single translation string that handles every plural form.
{ "itemCount": "{count, plural, =0 {No items} one {# item} other {# items}}"}
RTL & Locale-Aware CSS
Layout that adapts instead of a mirrored stylesheet.
- dir="rtl"- HTML attribute that flips text direction and default layout for an element/document
- margin-inline-start/-end- Logical CSS properties that adapt to LTR/RTL instead of fixed left/right
- text-align: start/end- Direction-agnostic alternative to text-align: left/right
- :dir() pseudo-class- CSS selector that matches elements based on resolved text direction
- unicode-bidi- CSS property controlling how bidirectional text is rendered within an element
ICU select for Gender & Category Branching
select handles non-numeric branching (gender, categories) the same way plural handles counts, and the two nest freely.
{ "invite": "{gender, select, male {He invited} female {She invited} other {They invited}} {count, plural, one {# friend} other {# friends}}"}
Lesser-Known Intl APIs
Intl.Segmenter and Intl.ListFormat solve locale-correctness problems that hand-rolled JS gets wrong.
// Segmenter: locale-correct word/grapheme/sentence boundaries// (naive .split(' ') breaks for Thai, Japanese, Chinese which have no spaces)const segmenter = new Intl.Segmenter('ja', { granularity: 'word' });for (const { segment, isWordLike } of segmenter.segment('\u79C1\u306F\u5B66\u751F\u3067\u3059')) { if (isWordLike) console.log(segment);}// ListFormat: "A, B, and C" with correct conjunction/locale punctuationconst lf = new Intl.ListFormat('en', { style: 'long', type: 'conjunction' });lf.format(['apples', 'bananas', 'cherries']);// "apples, bananas, and cherries"new Intl.ListFormat('fr', { type: 'disjunction' }).format(['a', 'b']);// "a ou b"// DisplayNames: locale-correct human labels for language/region/currency codesnew Intl.DisplayNames(['en'], { type: 'region' }).of('DE'); // "Germany"new Intl.DisplayNames(['fr'], { type: 'language' }).of('en'); // "anglais"
Locale Routing with next-intl (App Router)
Middleware-driven locale prefixes and server-rendered translations for Next.js's app directory.
// middleware.tsimport createMiddleware from 'next-intl/middleware';export default createMiddleware({ locales: ['en', 'fr', 'ar'], defaultLocale: 'en', localePrefix: 'as-needed', // /fr/about, but bare /about for the default locale});export const config = { matcher: ['/((?!api|_next|.*\\..*).*)'] };// app/[locale]/layout.tsximport { NextIntlClientProvider } from 'next-intl';import { getMessages } from 'next-intl/server';export default async function LocaleLayout({ children, params: { locale } }) { const messages = await getMessages(); // loaded server-side, zero client JS cost return ( <html lang={locale} dir={locale === 'ar' ? 'rtl' : 'ltr'}> <body> <NextIntlClientProvider messages={messages}> {children} </NextIntlClientProvider> </body> </html> );}
Production i18n Concepts
What separates a real localization pipeline from hardcoded English strings with a translation object.
- Pseudo-localization- Auto-generated fake locale (e.g. Åççéññññññññññ) used in QA to catch untranslated strings and layout overflow before real translation exists
- Translation memory (TM)- Database of previously translated segments reused across projects to cut cost and keep terminology consistent
- CLDR- Unicode's Common Locale Data Repository; the source of truth Intl/ICU implementations pull plural, date, and number rules from
- String extraction / message IDs- Build-time tooling (e.g. formatjs extract, i18next-parser) scans source for t() calls and generates translator-facing key files
- Locale negotiation- Resolving Accept-Language headers against supported locales, falling back gracefully rather than 404ing on an unsupported tag
- Namespace code-splitting- Loading only the translation JSON a given route needs instead of one giant bundle for every locale
- Transcreation- Adapting marketing copy/idioms for cultural meaning rather than literal word-for-word translation
Lazy-Loading Translation Namespaces
Fetch translation JSON per-route on demand via i18next-http-backend instead of bundling every locale upfront.
import i18n from 'i18next';import HttpBackend from 'i18next-http-backend';import { initReactI18next } from 'react-i18next';i18n .use(HttpBackend) .use(initReactI18next) .init({ backend: { loadPath: '/locales/{{lng}}/{{ns}}.json' }, ns: ['common'], // eagerly loaded on init defaultNS: 'common', fallbackLng: 'en', react: { useSuspense: true }, // pairs with <Suspense> while a namespace loads });// Inside a route-level component, request an extra namespace only when neededimport { useTranslation } from 'react-i18next';function CheckoutPage() { const { t } = useTranslation('checkout'); // triggers /locales/{lng}/checkout.json fetch return <h1>{t('title')}</h1>;}
Store translations with ICU plural/select syntax even for an English-only launch — retrofitting pluralization rules after adding a second language (especially one with more plural categories, like Arabic's six) is far more error-prone than doing it up front.