| name | i18n-patterns |
| description | Internationalization architecture: locale detection strategy, translation key organization (flat vs. namespaced), pluralization rules (CLDR), gender agreement, RTL layout (CSS logical properties), date/time/number/currency formatting (Intl API), and locale-aware sorting. Language-agnostic patterns applicable to any framework. |
i18n Patterns
vs i18n-frameworks: This skill covers language-agnostic architecture decisions (locale detection strategy, key organization, RTL support, Intl API). Use i18n-frameworks when you need framework-specific setup code (react-i18next, next-intl, Django i18n, Rails I18n, SwiftUI, Flutter ARB).
When to Activate
- Adding internationalization to an existing application
- Designing translation file structure from scratch
- Supporting RTL languages (Arabic, Hebrew, Persian)
- Fixing locale-sensitive formatting (dates, numbers, currency)
- Auditing for hardcoded user-visible strings
- Choosing a translation key naming convention (flat, namespaced, or ICU MessageFormat) before building a multi-locale feature
- Implementing pseudo-localization in development to catch layout breaks caused by string length expansion
- Configuring a fallback locale chain so partially translated locales gracefully fall back to English
Locale Detection
Apply locale sources in this priority order (highest wins):
1. User preference (stored in DB or localStorage)
2. URL parameter (?lang=de, /de/about)
3. Subdomain (de.example.com)
4. Accept-Language header (server-side)
5. Browser (navigator.language)
6. Default (en)
Implementation
function detectLocale(req?: Request): string {
const userPref = getUserPreferenceLocale();
if (userPref) return userPref;
const urlParam = new URLSearchParams(window.location.search).get('lang');
if (urlParam && SUPPORTED_LOCALES.includes(urlParam)) return urlParam;
const browserLang = navigator.language.split('-')[0];
if (SUPPORTED_LOCALES.includes(browserLang)) return browserLang;
return 'en';
}
const SUPPORTED_LOCALES = ['en', 'de', 'fr', 'ar', 'zh'];
Translation Key Design
Flat vs. namespaced
# Flat (simple apps, <200 keys)
"submit_button" = "Submit"
"error_required" = "This field is required"
# Namespaced (recommended for larger apps)
"checkout:payment.submit" = "Pay now"
"checkout:payment.error.card_declined" = "Your card was declined"
"auth:login.error.invalid_credentials" = "Invalid email or password"
Key naming conventions
# Format: namespace:context.element[.state]
"dashboard:sidebar.nav.overview" = "Overview"
"dashboard:sidebar.nav.settings" = "Settings"
"forms:validation.required" = "Required"
"forms:validation.min_length" = "Minimum {min} characters"
# CORRECT — descriptive key, not the string itself
"auth:submit_button" = "Sign in"
# WRONG — using the English string as the key (breaks on reuse and German has cases)
"Sign in" = "Anmelden"
Key conventions checklist
- Keys are
snake_case or camelCase — be consistent
- Keys describe the element, not the content
- Keys include context (namespace + location)
- No full sentences as keys
- Keys survive renaming of the English string
Pluralization
CLDR plural categories
Different languages use different plural forms. Never assume two forms (singular/plural).
| Language | Categories | Example |
|---|
| English | one, other | 1 item / 2 items |
| German | one, other | 1 Element / 2 Elemente |
| Russian | one, few, many, other | 1 файл / 2 файла / 5 файлов / 1.5 файла |
| Arabic | zero, one, two, few, many, other | 0 ملفات / 1 ملف / 2 ملفان / 5 ملفات |
| Japanese | other | (no plural forms) |
ICU MessageFormat syntax (recommended)
Most modern i18n libraries support ICU MessageFormat:
# English
"items_selected": "{count, plural, one {# item selected} other {# items selected}}"
# German
"items_selected": "{count, plural, one {# Element ausgewählt} other {# Elemente ausgewählt}}"
# Russian
"items_selected": "{count, plural, one {# файл выбран} few {# файла выбрано} many {# файлов выбрано} other {# файла выбрано}}"
Usage in code
t('items_selected', { count: selectedItems.length })
const pr = new Intl.PluralRules('ru');
const rule = pr.select(count);
const message = ruTranslations[`items_${rule}`];
RTL Support
CSS Logical Properties
Replace directional properties with logical equivalents that auto-flip in RTL:
.card {
margin-left: 16px;
padding-right: 12px;
border-left: 2px solid blue;
text-align: left;
}
.card {
margin-inline-start: 16px;
padding-inline-end: 12px;
border-inline-start: 2px solid blue;
text-align: start;
}
Full logical property mapping
| Directional | Logical equivalent |
|---|
margin-left / margin-right | margin-inline-start / margin-inline-end |
padding-left / padding-right | padding-inline-start / padding-inline-end |
border-left / border-right | border-inline-start / border-inline-end |
left / right (position) | inset-inline-start / inset-inline-end |
text-align: left | text-align: start |
float: left | float: inline-start |
HTML dir attribute
<html lang="ar" dir="rtl">
<p dir="rtl">نص عربي</p>
Icon mirroring in RTL
Some icons need to be mirrored (arrows, chevrons, navigation icons — not symmetric icons):
[dir="rtl"] .icon-arrow-right,
[dir="rtl"] .icon-chevron-right,
[dir="rtl"] .icon-back {
transform: scaleX(-1);
}
Intl API
Date formatting
const formatter = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'long',
timeStyle: 'short',
});
console.log(formatter.format(new Date()));
const relFormatter = new Intl.RelativeTimeFormat('de', { numeric: 'auto' });
console.log(relFormatter.format(-1, 'day'));
console.log(relFormatter.format(-3, 'day'));
Number and currency
new Intl.NumberFormat('de-DE').format(1234567.89);
new Intl.NumberFormat('en-US').format(1234567.89);
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(1234.5);
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(1234.5);
Locale-aware sorting
const sorted = names.sort();
const collator = new Intl.Collator('de', { sensitivity: 'base' });
const sorted = names.sort((a, b) => collator.compare(a, b));
Missing Translation Handling
Fallback locale chain
i18n.init({
fallbackLng: ['en'],
fallbackLng: {
'de-AT': ['de', 'en'],
'de-CH': ['de', 'en'],
default: ['en'],
},
});
Pseudo-localization for testing
Replace characters to verify layout handles expansion (German is ~30% longer than English):
function pseudoLocalize(str: string): string {
return str
.replace(/a/g, 'à').replace(/e/g, 'é').replace(/i/g, 'î')
.replace(/o/g, 'ô').replace(/u/g, 'û').replace(/c/g, 'ç')
+ ' !!!';
}
Enable in dev:
if (process.env.NODE_ENV === 'development' && process.env.PSEUDO_LOCALE) {
i18n.addResourceBundle('pseudo', 'translation', pseudoLocalize);
i18n.changeLanguage('pseudo');
}
Missing translation logging
i18n.init({
missingKeyHandler: (lngs, ns, key) => {
console.warn(`[i18n] Missing key: ${ns}:${key} for ${lngs.join(', ')}`);
if (process.env.NODE_ENV === 'production') {
Sentry.captureMessage(`Missing i18n key: ${ns}:${key}`);
}
},
});
Checklist