Skip to main content

Internationalization and Localization

Building applications that support multiple languages, locales, and cultural conventions for global audiences.

Overview

Internationalization (i18n) is the process of designing applications so they can be adapted to various languages and regions without engineering changes. Localization (l10n) is the actual adaptation process - translating content, formatting data according to regional conventions, and adjusting for cultural differences. Together, they enable applications to serve global markets effectively.

The distinction matters: i18n is done once during development to make localization possible, while l10n is performed multiple times for each target market. Good i18n architecture allows adding new locales without modifying code.

Key Terminology:

  • Internationalization (i18n): The "18" represents the 18 letters between "i" and "n". The process of designing software to support multiple locales.
  • Localization (l10n): The "10" represents the 10 letters between "l" and "n". The process of adapting software for a specific locale.
  • Locale: A combination of language and region (e.g., en-US for American English, pt-BR for Brazilian Portuguese). Determines language, date formats, number formats, and cultural conventions.
  • Translation: Converting text from one language to another while preserving meaning and context.
  • Globalization (g11n): The overall process encompassing both i18n and l10n to create globally accessible software.

Core Principles

  • Externalize All User-Facing Text: Never hardcode strings in code; use translation keys and resource files
  • Locale-Aware Formatting: Use libraries for dates, numbers, and currencies - never format manually
  • Flexible Layouts: Design UIs that accommodate varying text lengths across languages
  • Unicode Everywhere: Use UTF-8 encoding throughout the stack (database, API, frontend)
  • Separate Content from Code: Translation is a content concern, not a code concern
  • Cultural Sensitivity: Adapt imagery, colors, and metaphors for target cultures
  • Translation Context: Provide translators with context to ensure accurate translations
  • Incremental Localization: Support adding new locales without code changes

Locale Structure and Standards

Locales follow the BCP 47 standard (IETF language tags), typically formatted as language-region:

Language codes (ISO 639-1):
en - English
es - Spanish
pt - Portuguese
fr - French
de - German
zh - Chinese
ja - Japanese
ar - Arabic

Region codes (ISO 3166-1 alpha-2):
US - United States
GB - Great Britain
BR - Brazil
MX - Mexico
CN - China
JP - Japan

Common locales:
en-US - American English
en-GB - British English
es-ES - European Spanish
es-MX - Mexican Spanish
pt-BR - Brazilian Portuguese
pt-PT - European Portuguese
fr-FR - French (France)
fr-CA - Canadian French
zh-CN - Simplified Chinese (China)
zh-TW - Traditional Chinese (Taiwan)
ar-SA - Arabic (Saudi Arabia)

Why Region Matters: Languages vary significantly by region. Portuguese in Brazil (pt-BR) differs from Portugal (pt-PT) in vocabulary, spelling, and formality. Spanish in Mexico (es-MX) uses different words than Spain (es-ES). Always specify both language and region.

Fallback Chain: Implement fallback logic: en-AU (Australian English) → en-GB (British English) → en (generic English) → default language. This ensures partial translations are usable.


String Externalization and Translation Keys

All user-facing text must be externalized into translation files, never hardcoded. This allows translators to work independently of developers.

Translation Key Naming Conventions

Use descriptive, hierarchical keys that convey context and location:

//  BAD: Generic keys without context
{
"button1": "Submit",
"error": "Invalid input",
"msg": "Success"
}

// GOOD: Descriptive keys with namespace and context
{
"payment.form.submit_button": "Submit Payment",
"payment.form.amount_label": "Transfer Amount",
"payment.validation.amount_invalid": "Amount must be greater than $0.01",
"payment.validation.account_required": "Please select a recipient account",
"payment.success.message": "Payment submitted successfully",
"payment.success.confirmation_number": "Confirmation number: {{number}}",

"dashboard.greeting.morning": "Good morning, {{name}}",
"dashboard.greeting.evening": "Good evening, {{name}}",
"dashboard.balance.label": "Available Balance",
"dashboard.balance.value": "{{amount, currency}}",

"navigation.menu.dashboard": "Dashboard",
"navigation.menu.transfers": "Transfers",
"navigation.menu.accounts": "Accounts"
}

Naming Best Practices:

  • Use dot notation for hierarchy: feature.component.element
  • Include context in key names: submit_button vs submit_link vs submit_text
  • Separate by feature/module for maintainability
  • Use snake_case or camelCase consistently
  • Avoid abbreviations that translators won't understand

Translation File Structure

Organize translations by locale in a consistent directory structure:

src/
i18n/
locales/
en-US/
common.json # Shared across all features
navigation.json # Navigation labels
payment.json # Payment feature
dashboard.json # Dashboard feature
es-MX/
common.json
navigation.json
payment.json
dashboard.json
pt-BR/
common.json
navigation.json
payment.json
dashboard.json

Example Translation File (en-US/payment.json):

{
"form": {
"title": "New Payment",
"amount_label": "Amount",
"amount_placeholder": "Enter amount",
"recipient_label": "Recipient Account",
"note_label": "Note (optional)",
"submit_button": "Submit Payment",
"cancel_button": "Cancel"
},
"validation": {
"amount_required": "Amount is required",
"amount_minimum": "Amount must be at least {{min, currency}}",
"amount_exceeds_balance": "Amount exceeds available balance of {{balance, currency}}",
"recipient_required": "Please select a recipient account"
},
"success": {
"title": "Payment Submitted",
"message": "Your payment of {{amount, currency}} to {{recipient}} has been submitted.",
"confirmation": "Confirmation number: {{number}}",
"view_details": "View Details"
},
"error": {
"title": "Payment Failed",
"network_error": "Unable to connect. Please check your connection and try again.",
"insufficient_funds": "Insufficient funds for this transaction.",
"generic": "An error occurred while processing your payment. Please try again."
}
}

Interpolation and Variables

Translation strings often need dynamic values (names, amounts, dates). Use consistent interpolation syntax:

// React (react-i18next)
import { useTranslation } from 'react-i18next';

function PaymentSuccess({ amount, recipient, confirmationNumber }) {
const { t } = useTranslation('payment');

return (
<div>
<h1>{t('success.title')}</h1>
<p>{t('success.message', { amount, recipient })}</p>
<p>{t('success.confirmation', { number: confirmationNumber })}</p>
</div>
);
}

// Angular (ngx-translate)
<h1>{{ 'payment.success.title' | translate }}</h1>
<p>{{ 'payment.success.message' | translate: {amount: amount, recipient: recipient} }}</p>
<p>{{ 'payment.success.confirmation' | translate: {number: confirmationNumber} }}</p>

// Spring Boot (MessageSource)
String message = messageSource.getMessage(
"payment.success.message",
new Object[]{amount, recipient},
LocaleContextHolder.getLocale()
);

Interpolation Best Practices:

  • Use descriptive variable names: {{userName}} not {{x}}
  • Keep variables to minimum necessary
  • [BAD] Never concatenate translated strings: t('hello') + name
  • Let translators control word order: translators can rearrange {{amount}} and {{recipient}} as needed for their language's grammar

Pluralization

Different languages have different pluralization rules. English has two forms (one vs many), but Arabic has six, Polish has four, and Japanese has one.

//  BAD: Hardcoded pluralization (doesn't work for many languages)
{
"items": "{{count}} item(s)"
}

// GOOD: ICU Message Format with plural rules
{
"items": {
"one": "{{count}} item",
"other": "{{count}} items"
}
}

// More complex example with zero case
{
"notifications": {
"zero": "No new notifications",
"one": "1 new notification",
"other": "{{count}} new notifications"
}
}

// Usage (react-i18next with ICU support)
{t('items', { count: items.length })}
// count=0: "0 items"
// count=1: "1 item"
// count=2: "2 items"

ICU Message Format: Use ICU (International Components for Unicode) message syntax for complex pluralization and conditional text. Most i18n libraries support ICU syntax.

For languages with complex pluralization (Arabic, Russian, Polish), the library handles the rules automatically based on the locale:

// Arabic (ar-SA) has six plural forms
{
"items": {
"zero": "لا توجد عناصر",
"one": "عنصر واحد",
"two": "عنصران",
"few": "{{count}} عناصر",
"many": "{{count}} عنصرًا",
"other": "{{count}} عنصر"
}
}

The i18n library selects the correct form based on the count and language rules. Developers don't need to know these rules - just use the library's pluralization API.


i18n Frameworks and Libraries

Frontend Frameworks

React: react-i18next

// i18n configuration (i18n.ts)
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import HttpBackend from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector';

i18n
.use(HttpBackend) // Load translations via HTTP
.use(LanguageDetector) // Detect user language
.use(initReactI18next) // Bind to React
.init({
fallbackLng: 'en-US',
supportedLngs: ['en-US', 'es-MX', 'pt-BR', 'fr-FR'],
load: 'currentOnly', // Only load current language, not fallbacks
debug: false,

interpolation: {
escapeValue: false, // React already escapes
format: (value, format, lng) => {
// Custom formatters for currency, date, etc.
if (format === 'currency') {
return new Intl.NumberFormat(lng, {
style: 'currency',
currency: 'USD',
}).format(value);
}
return value;
},
},

backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},

detection: {
// Order of detection methods
order: ['querystring', 'cookie', 'localStorage', 'navigator'],
caches: ['localStorage', 'cookie'],
},
});

export default i18n;

// Usage in components
import { useTranslation } from 'react-i18next';

function PaymentForm() {
const { t, i18n } = useTranslation('payment');

const changeLanguage = (lng: string) => {
i18n.changeLanguage(lng);
};

return (
<form>
<h1>{t('form.title')}</h1>
<label htmlFor="amount">{t('form.amount_label')}</label>
<input
id="amount"
type="number"
placeholder={t('form.amount_placeholder')}
/>
<button type="submit">{t('form.submit_button')}</button>

{/* Language switcher */}
<select value={i18n.language} onChange={(e) => changeLanguage(e.target.value)}>
<option value="en-US">English</option>
<option value="es-MX">Español</option>
<option value="pt-BR">Português</option>
</select>
</form>
);
}

Angular: @angular/localize

Angular's built-in i18n extracts translations at build time, creating separate bundles per locale for optimal performance:

// Mark strings for translation (inline)
<h1 i18n="@@payment.form.title">New Payment</h1>
<label i18n="@@payment.form.amount_label">Amount</label>

// With description and meaning for translators
<h1 i18n="Payment form title|The title of the payment creation form@@payment.form.title">
New Payment
</h1>

// With interpolation
<p i18n>Welcome, {{userName}}</p>

// With pluralization
<span i18n>{notifications.length, plural, =0 {No notifications} one {1 notification} other {{{notifications.length}} notifications}}</span>

// Programmatic translation (component)
import { Component } from '@angular/core';
import { $localize } from '@angular/localize/init';

@Component({
selector: 'app-payment',
template: `...`
})
export class PaymentComponent {
successMessage() {
return $localize`:Payment success message:Payment of ${this.amount}:amount: submitted successfully`;
}
}

// angular.json configuration for multiple locales
{
"projects": {
"myapp": {
"i18n": {
"sourceLocale": "en-US",
"locales": {
"es-MX": {
"translation": "src/i18n/messages.es-MX.xlf",
"baseHref": "/es/"
},
"pt-BR": {
"translation": "src/i18n/messages.pt-BR.xlf",
"baseHref": "/pt/"
}
}
},
"architect": {
"build": {
"configurations": {
"es-MX": {
"localize": ["es-MX"]
},
"pt-BR": {
"localize": ["pt-BR"]
}
}
}
}
}
}
}

Angular's approach creates separate build artifacts per locale. Extract translations with ng extract-i18n, translate the resulting XLIFF files, then build locale-specific bundles with ng build --localize. See Angular i18n documentation for the complete workflow.

Runtime i18n for Angular (ngx-translate)

For runtime translation switching without separate builds:

import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
import { TranslateHttpLoader } from '@ngx-translate/http-loader';
import { HttpClient } from '@angular/common/http';

export function HttpLoaderFactory(http: HttpClient) {
return new TranslateHttpLoader(http, './assets/i18n/', '.json');
}

@NgModule({
imports: [
TranslateModule.forRoot({
defaultLanguage: 'en-US',
loader: {
provide: TranslateLoader,
useFactory: HttpLoaderFactory,
deps: [HttpClient]
}
})
]
})
export class AppModule {}

// Usage in component
import { TranslateService } from '@ngx-translate/core';

export class PaymentComponent {
constructor(private translate: TranslateService) {
translate.setDefaultLang('en-US');
translate.use('es-MX');
}

submitPayment() {
const message = this.translate.instant('payment.success.message', {
amount: this.amount
});
console.log(message);
}
}

// Template usage
<h1>{{ 'payment.form.title' | translate }}</h1>
<p>{{ 'payment.success.message' | translate: {amount: amount} }}</p>

Backend Frameworks

Spring Boot: MessageSource

// Configuration
@Configuration
public class InternationalizationConfig {

@Bean
public MessageSource messageSource() {
ReloadableResourceBundleMessageSource messageSource =
new ReloadableResourceBundleMessageSource();

messageSource.setBasename("classpath:i18n/messages");
messageSource.setDefaultEncoding("UTF-8");
messageSource.setCacheSeconds(3600); // Cache for 1 hour
messageSource.setFallbackToSystemLocale(false);
messageSource.setDefaultLocale(Locale.US);

return messageSource;
}

@Bean
public LocaleResolver localeResolver() {
CookieLocaleResolver resolver = new CookieLocaleResolver();
resolver.setDefaultLocale(Locale.US);
resolver.setCookieName("APP_LOCALE");
resolver.setCookieMaxAge(31536000); // 1 year
return resolver;
}

@Bean
public LocaleChangeInterceptor localeChangeInterceptor() {
LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor();
interceptor.setParamName("lang"); // ?lang=es-MX changes locale
return interceptor;
}

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(localeChangeInterceptor());
}
}

// Message properties files
// src/main/resources/i18n/messages_en_US.properties
payment.success.message=Payment of {0} to {1} submitted successfully
payment.validation.amount_minimum=Amount must be at least {0}

// src/main/resources/i18n/messages_es_MX.properties
payment.success.message=Pago de {0} a {1} enviado exitosamente
payment.validation.amount_minimum=El monto debe ser al menos {0}

// Usage in service
@Service
public class PaymentService {

private final MessageSource messageSource;

@Autowired
public PaymentService(MessageSource messageSource) {
this.messageSource = messageSource;
}

public String createPayment(Payment payment) {
// Get current locale from request context
Locale locale = LocaleContextHolder.getLocale();

// Translate message
String message = messageSource.getMessage(
"payment.success.message",
new Object[]{
formatCurrency(payment.getAmount(), locale),
payment.getRecipient()
},
locale
);

return message;
}

private String formatCurrency(BigDecimal amount, Locale locale) {
return NumberFormat.getCurrencyInstance(locale).format(amount);
}
}

// Usage in REST controller
@RestController
public class PaymentController {

@Autowired
private MessageSource messageSource;

@PostMapping("/api/payments")
public ResponseEntity<PaymentResponse> createPayment(
@RequestBody PaymentRequest request,
Locale locale) {

// locale parameter automatically injected by Spring

Payment payment = paymentService.create(request);

String message = messageSource.getMessage(
"payment.success.message",
new Object[]{payment.getAmount(), payment.getRecipient()},
locale
);

return ResponseEntity.ok(new PaymentResponse(payment, message));
}
}

Accept-Language Header: Spring Boot automatically resolves locale from the Accept-Language HTTP header. Clients should send Accept-Language: es-MX to request Spanish (Mexico) translations. Use LocaleChangeInterceptor to allow overriding via query parameter (?lang=es-MX) or cookie.

For comprehensive Spring Boot REST API internationalization patterns, see Spring Boot API Design.


Locale-Aware Data Formatting

Never format dates, numbers, or currencies manually with string concatenation. Use locale-aware formatting libraries that handle regional conventions automatically.

Date and Time Formatting

Date formats vary dramatically by locale:

  • en-US: 12/25/2025 (MM/DD/YYYY)
  • en-GB: 25/12/2025 (DD/MM/YYYY)
  • de-DE: 25.12.2025 (DD.MM.YYYY)
  • ja-JP: 2025年12月25日 (YYYY年MM月DD日)
  • ar-SA: ٢٥/١٢/٢٠٢٥ (right-to-left with Arabic numerals)
// JavaScript/TypeScript: Intl.DateTimeFormat
const date = new Date('2025-12-25T14:30:00Z');

// BAD: Manual formatting (wrong for most locales)
const badFormat = `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`;
// "12/25/2025" - wrong format for UK, Germany, Japan, etc.

// GOOD: Locale-aware formatting
const enUS = new Intl.DateTimeFormat('en-US').format(date);
// "12/25/2025"

const enGB = new Intl.DateTimeFormat('en-GB').format(date);
// "25/12/2025"

const deDE = new Intl.DateTimeFormat('de-DE').format(date);
// "25.12.2025"

const jaJP = new Intl.DateTimeFormat('ja-JP').format(date);
// "2025/12/25"

// With time
const dateTime = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
}).format(date);
// "December 25, 2025 at 2:30 PM"

// Relative time (requires Intl.RelativeTimeFormat)
const rtf = new Intl.RelativeTimeFormat('en-US', { numeric: 'auto' });
rtf.format(-1, 'day'); // "yesterday"
rtf.format(0, 'day'); // "today"
rtf.format(1, 'day'); // "tomorrow"
rtf.format(3, 'day'); // "in 3 days"

Java: DateTimeFormatter

import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.Locale;

ZonedDateTime dateTime = ZonedDateTime.now();

// BAD: Manual formatting
String badFormat = dateTime.getMonthValue() + "/" +
dateTime.getDayOfMonth() + "/" +
dateTime.getYear();

// GOOD: Locale-aware formatting
DateTimeFormatter usFormatter =
DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
.withLocale(Locale.US);
String usDate = dateTime.format(usFormatter);
// "12/25/25"

DateTimeFormatter gbFormatter =
DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
.withLocale(Locale.UK);
String gbDate = dateTime.format(gbFormatter);
// "25/12/25"

// Custom patterns (use sparingly, predefined formats preferred)
DateTimeFormatter customFormatter =
DateTimeFormatter.ofPattern("EEEE, MMMM d, yyyy", Locale.US);
String customDate = dateTime.format(customFormatter);
// "Thursday, December 25, 2025"

Timezone Considerations: Always store dates in UTC in the database. Convert to user's timezone only for display. Never store dates in local time without timezone information - it causes ambiguity during Daylight Saving Time transitions.

Number Formatting

Number formatting varies by locale (thousands separators, decimal separators):

  • en-US: 1,234.56 (comma thousands, period decimal)
  • de-DE: 1.234,56 (period thousands, comma decimal)
  • fr-FR: 1 234,56 (space thousands, comma decimal)
  • ar-SA: ١٬٢٣٤٫٥٦ (Arabic-Indic digits)
// JavaScript/TypeScript: Intl.NumberFormat
const number = 1234.56;

// BAD: Manual formatting
const badFormat = number.toFixed(2); // "1234.56" - no thousands separator

// GOOD: Locale-aware formatting
const enUS = new Intl.NumberFormat('en-US').format(number);
// "1,234.56"

const deDE = new Intl.NumberFormat('de-DE').format(number);
// "1.234,56"

const frFR = new Intl.NumberFormat('fr-FR').format(number);
// "1 234,56"

// With minimum/maximum fraction digits
const precise = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 4
}).format(1234.5);
// "1,234.50" (at least 2 decimals)

// Percentages
const percent = new Intl.NumberFormat('en-US', {
style: 'percent',
minimumFractionDigits: 2
}).format(0.1234);
// "12.34%"

Java: NumberFormat

import java.text.NumberFormat;
import java.util.Locale;

double number = 1234.56;

// GOOD: Locale-aware formatting
NumberFormat usFormat = NumberFormat.getInstance(Locale.US);
String usNumber = usFormat.format(number);
// "1,234.56"

NumberFormat deFormat = NumberFormat.getInstance(Locale.GERMANY);
String deNumber = deFormat.format(number);
// "1.234,56"

// Percentages
NumberFormat percentFormat = NumberFormat.getPercentInstance(Locale.US);
String percent = percentFormat.format(0.1234);
// "12%"

Currency Formatting

Currency formatting includes locale-specific symbol placement and formatting:

  • en-US: $1,234.56 (symbol before, space sometimes present)
  • de-DE: 1.234,56 € (symbol after, space present)
  • ja-JP: ¥1,235 (no decimals for yen)
  • ar-SA: ١٬٢٣٥ ر.س.‏ (Arabic-Indic digits, SAR symbol after)
// JavaScript/TypeScript: Intl.NumberFormat with currency
const amount = 1234.56;

// GOOD: Locale-aware currency formatting
const usd = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount);
// "$1,234.56"

const eur = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(amount);
// "1.234,56 €"

const jpy = new Intl.NumberFormat('ja-JP', {
style: 'currency',
currency: 'JPY'
}).format(amount);
// "¥1,235" (yen doesn't use decimals)

const brl = new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL'
}).format(amount);
// "R$ 1.234,56"

Currency vs Locale: Currency and locale are independent. You might display USD formatted for German locale (de-DE + USD), or EUR formatted for US locale (en-US + EUR). The locale determines number formatting, while currency determines the symbol and decimal places.

Java: Currency Formatting

import java.text.NumberFormat;
import java.util.Currency;
import java.util.Locale;

double amount = 1234.56;

// GOOD: Locale-aware currency formatting
NumberFormat usdFormat = NumberFormat.getCurrencyInstance(Locale.US);
usdFormat.setCurrency(Currency.getInstance("USD"));
String usd = usdFormat.format(amount);
// "$1,234.56"

NumberFormat eurFormat = NumberFormat.getCurrencyInstance(Locale.GERMANY);
eurFormat.setCurrency(Currency.getInstance("EUR"));
String eur = eurFormat.format(amount);
// "1.234,56 €"

NumberFormat jpyFormat = NumberFormat.getCurrencyInstance(Locale.JAPAN);
jpyFormat.setCurrency(Currency.getInstance("JPY"));
String jpy = jpyFormat.format(amount);
// "¥1,235"

For comprehensive currency handling in APIs, see API Design.


Right-to-Left (RTL) Language Support

Languages like Arabic, Hebrew, and Persian are written right-to-left, requiring mirrored layouts and special considerations.

CSS for RTL Support

Use logical CSS properties that adapt to text direction automatically:

/*  BAD: Physical properties don't reverse */
.button {
margin-left: 16px; /* Always left margin, even in RTL */
float: left; /* Always floats left */
text-align: left; /* Always left-aligned */
}

/* GOOD: Logical properties reverse in RTL */
.button {
margin-inline-start: 16px; /* Left in LTR, right in RTL */
float: inline-start; /* Floats based on direction */
text-align: start; /* Aligns based on direction */
}

/* Logical property mappings */
margin-inline-start /* margin-left (LTR), margin-right (RTL) */
margin-inline-end /* margin-right (LTR), margin-left (RTL) */
padding-inline-start /* padding-left (LTR), padding-right (RTL) */
padding-inline-end /* padding-right (LTR), padding-left (RTL) */
border-inline-start /* border-left (LTR), border-right (RTL) */
border-inline-end /* border-right (LTR), border-left (RTL) */
inset-inline-start /* left (LTR), right (RTL) for positioning */
inset-inline-end /* right (LTR), left (RTL) for positioning */

HTML Direction Attribute:

<!-- Set direction on HTML element based on locale -->
<html lang="ar-SA" dir="rtl">
<head>...</head>
<body>...</body>
</html>

<!-- Or dynamically -->
<html lang="{{ currentLocale }}" dir="{{ textDirection }}">

Most CSS automatically reverses when dir="rtl" is set, but you must use logical properties for margins, padding, and positioning. Flexbox and Grid reverse automatically.

Mirroring Icons and Images

Some icons should mirror in RTL, others shouldn't:

Should Mirror:

  • Arrows (← → reversed to → ←)
  • Back/Forward navigation buttons
  • Directional indicators (chevrons, carets)
  • Media controls (play, forward, backward)

Should NOT Mirror:

  • Logos and brand marks
  • Clocks and watches (clockwise is universal)
  • Checkmarks and X icons
  • Media elements (photos, video thumbnails)
  • Numbers and mathematical symbols
/* Flip icons that should mirror in RTL */
[dir="rtl"] .icon-arrow {
transform: scaleX(-1); /* Flip horizontally */
}

/* Prevent mirroring for icons that shouldn't flip */
[dir="rtl"] .icon-logo {
transform: none;
}
// React example with conditional styling
function NavigationButton({ direction }: { direction: 'back' | 'forward' }) {
const { i18n } = useTranslation();
const isRTL = i18n.dir() === 'rtl';

const iconClass = direction === 'back'
? 'icon-arrow-left'
: 'icon-arrow-right';

// Icons automatically mirror with CSS when dir="rtl"
return (
<button className={iconClass}>
{direction === 'back' ? 'Back' : 'Forward'}
</button>
);
}

Text Rendering in RTL

Bidirectional Text (Bidi): Mixed LTR and RTL text in the same line requires special handling:

<!-- English text with Arabic -->
<p dir="ltr">User balance: <span lang="ar">٥٠٠ ريال</span></p>
<!-- "User balance: ٥٠٠ ريال" (Arabic numerals RTL within LTR context) -->

<!-- Arabic text with English -->
<p dir="rtl" lang="ar">
الرصيد: <span dir="ltr" lang="en">$500.00</span>
</p>
<!-- "الرصيد: $500.00" (USD LTR within RTL context) -->

Unicode Bidirectional Algorithm: Modern browsers implement the Unicode Bidi algorithm automatically. Use dir attributes to set overall direction, and <bdo> (bidirectional override) or <bdi> (bidirectional isolate) for complex cases:

<!-- Override default direction -->
<bdo dir="rtl">This text is forced RTL</bdo>

<!-- Isolate bidirectional text -->
<p>User: <bdi>مُحَمَّد</bdi> transferred $500</p>
<!-- Isolates the Arabic name so it doesn't affect surrounding text -->

Testing RTL Layouts

  1. Set browser direction: Add ?lang=ar-SA to URL or use browser language settings to switch to Arabic
  2. Visual inspection: Verify layout mirrors correctly, margins/padding are correct, navigation flows RTL
  3. Text wrapping: Ensure long text wraps correctly in RTL
  4. Forms: Verify labels align correctly (right-aligned in RTL)
  5. Icons: Check which icons should vs shouldn't mirror
  6. Mixed content: Test pages with both RTL and LTR content (usernames, technical terms)

For comprehensive frontend RTL implementation, see Angular Guidelines and React Guidelines.


Translation Management Workflow

Effective translation requires a structured workflow that separates developer work from translator work.

Translation Extraction

Extract strings from code for translation:

# React (i18next-parser)
npm install --save-dev i18next-parser
npx i18next-parser --config i18next-parser.config.js

# Angular (extract-i18n)
ng extract-i18n --output-path src/i18n --format xlf

# Spring Boot (manual extraction from messages.properties)
# Send messages_en_US.properties to translators
# Receive messages_es_MX.properties, messages_pt_BR.properties back

i18next-parser configuration (i18next-parser.config.js):

module.exports = {
locales: ['en-US', 'es-MX', 'pt-BR', 'fr-FR'],
output: 'src/i18n/locales/$LOCALE/$NAMESPACE.json',
input: ['src/**/*.{ts,tsx}'],
sort: true,
createOldCatalogs: false,
defaultValue: (locale, namespace, key) => {
// Return empty string for non-source locales
return locale === 'en-US' ? key : '';
},
keySeparator: '.',
namespaceSeparator: ':',
};

Translation Platforms

Translation Management Systems (TMS) streamline collaboration between developers and translators:

Crowdin: Cloud-based TMS with GitHub/GitLab integration, translation memory, glossaries, and context screenshots. Supports 50+ file formats.

Lokalise: Developer-focused TMS with API, CLI, and CI/CD integration. Offers translation memory, QA checks, and in-context editing.

Phrase: Enterprise TMS with advanced workflow management, machine translation integration, and translation analytics.

POEditor: Lightweight TMS for small teams with API access and collaboration features.

Weblate: Open-source translation platform with Git integration and built-in checks for translation quality.

Integration Example (Crowdin with CI/CD):

# .github/workflows/translations.yml
name: Sync Translations

on:
push:
branches: [main]
paths:
- 'src/i18n/locales/en-US/**'

jobs:
upload-sources:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3

- name: Crowdin Upload
uses: crowdin/github-action@v1
with:
upload_sources: true
upload_translations: false
download_translations: false
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_TOKEN }}

download-translations:
runs-on: ubuntu-latest
if: github.event_name == 'schedule'
steps:
- uses: actions/checkout@v3

- name: Crowdin Download
uses: crowdin/github-action@v1
with:
upload_sources: false
upload_translations: false
download_translations: true
create_pull_request: true
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_TOKEN }}

This workflow automatically uploads new English strings to Crowdin when merged to main, and creates pull requests with completed translations daily.

Providing Context to Translators

Translators need context to produce accurate translations:

//  BAD: No context
{
"button": "Book" // Does this mean "reserve" or "a physical book"?
}

// GOOD: Context provided in key name and comments
{
// Action button to reserve a hotel room
"booking.reserve_button": "Book Room",

// Noun: a physical book in the library
"library.item.book": "Book"
}

// With i18next-parser
{
// i18next-parser-options: { "context": "Confirmation button after user completes payment form" }
"payment.confirm_button": "Confirm"
}

Screenshot Annotations: Many TMS platforms allow uploading screenshots showing where each string appears. This visual context is invaluable for translators.

Glossaries: Maintain a glossary of domain-specific terms (technical jargon, product names, brand terms) that should be translated consistently. Example:

Term: Account Balance
Translation (es-MX): Saldo de la Cuenta
Translation (pt-BR): Saldo da Conta
Notes: Always capitalize both words. Do not use "Balance" alone.

Term: Routing Number
Translation (es-MX): Número de Ruta
Translation (pt-BR): Número de Roteamento
Notes: US banking term for ABA routing number. Do not translate as "routing" in networking sense.

Handling Pluralization and Gender

Pluralization Rules

As mentioned earlier, different languages have different pluralization rules. The i18n library handles this automatically, but you must provide all plural forms:

// English (2 forms: one, other)
{
"items": {
"one": "{{count}} item",
"other": "{{count}} items"
}
}

// Arabic (6 forms: zero, one, two, few, many, other)
{
"items": {
"zero": "لا توجد عناصر",
"one": "عنصر واحد",
"two": "عنصران",
"few": "{{count}} عناصر",
"many": "{{count}} عنصرًا",
"other": "{{count}} عنصر"
}
}

// Polish (4 forms: one, few, many, other)
{
"items": {
"one": "{{count}} przedmiot",
"few": "{{count}} przedmioty",
"many": "{{count}} przedmiotów",
"other": "{{count}} przedmiotu"
}
}

// Japanese (1 form: other)
{
"items": {
"other": "{{count}}個のアイテム"
}
}

Use ICU Message Format for complex pluralization with conditions:

{
"cart_summary": "{itemCount, plural, =0 {Your cart is empty} one {You have 1 item in your cart} other {You have # items in your cart}}"
}

Gender-Specific Translations

Some languages have gender-specific word forms. Use context or separate keys:

// French: "Welcome" differs by gender
{
"welcome_male": "Bienvenu, {{name}}",
"welcome_female": "Bienvenue, {{name}}"
}

// ICU Message Format with select
{
"welcome": "{gender, select, male {Bienvenu, {name}} female {Bienvenue, {name}} other {Bienvenue, {name}}}"
}

// Usage
t('welcome', { gender: user.gender, name: user.name })

Most modern applications avoid gender-specific language where possible by using gender-neutral phrasing. Consult with native speakers and translators for culturally appropriate solutions.


Testing Internationalized Applications

Pseudo-Localization

Pseudo-localization (pseudo-l10n) is a testing method that simulates translated text without actual translation. It helps identify i18n issues early:

Original: "Submit Payment"
Pseudo: "[!!! Šüßɱîţ Þåýɱéñţ !!!]"

Original: "Account"
Pseudo: "[!!! Áççøûñţ !!!]"

What Pseudo-Localization Tests:

  • Text expansion: Adds characters to simulate longer translations (German, Finnish are ~30% longer than English)
  • Unicode handling: Uses accented characters to verify UTF-8 support
  • Hard-coded strings: Anything not wrapped in pseudo shows untranslated text
  • Layout issues: Longer text reveals truncation or layout problems
  • String concatenation: Broken pseudo reveals concatenation bugs

Implementing Pseudo-Localization:

// Add pseudo locale to i18n config
i18n.init({
lng: 'en-US',
fallbackLng: 'en-US',
supportedLngs: ['en-US', 'es-MX', 'pt-BR', 'pseudo'],
resources: {
pseudo: {
translation: pseudoLocalizeStrings(enUSTranslations)
}
}
});

function pseudoLocalizeStrings(strings: Record<string, any>): Record<string, any> {
const pseudoMap: Record<string, string> = {
'a': 'å', 'e': 'é', 'i': 'î', 'o': 'ø', 'u': 'û',
'A': 'Å', 'E': 'É', 'I': 'Î', 'O': 'Ø', 'U': 'Û',
'n': 'ñ', 's': 'š', 'y': 'ý', 'z': 'ž'
};

function pseudolocalizeString(str: string): string {
let pseudo = str.split('').map(char =>
pseudoMap[char] || char
).join('');

// Add expansion (30% longer)
const expansion = '~'.repeat(Math.ceil(str.length * 0.3));

// Add brackets to identify i18n strings
return `[!!! ${pseudo}${expansion} !!!]`;
}

function traverse(obj: any): any {
if (typeof obj === 'string') {
return pseudolocalizeString(obj);
} else if (typeof obj === 'object') {
const result: Record<string, any> = {};
for (const key in obj) {
result[key] = traverse(obj[key]);
}
return result;
}
return obj;
}

return traverse(strings);
}

Run your application with ?lang=pseudo to enable pseudo-localization and verify all strings are externalized and layouts accommodate expansion.

Automated i18n Testing

// Jest test for missing translations
describe('i18n completeness', () => {
const sourceLocale = 'en-US';
const targetLocales = ['es-MX', 'pt-BR', 'fr-FR'];

test('all target locales have same keys as source', () => {
const sourceKeys = getAllKeys(sourceLocale);

targetLocales.forEach(locale => {
const targetKeys = getAllKeys(locale);
const missingKeys = sourceKeys.filter(key => !targetKeys.includes(key));

expect(missingKeys).toEqual([]);
// If fails, these keys need translation: missingKeys
});
});

test('no empty translations', () => {
targetLocales.forEach(locale => {
const translations = loadTranslations(locale);
const emptyKeys = findEmptyValues(translations);

expect(emptyKeys).toEqual([]);
// If fails, these keys have empty translations: emptyKeys
});
});
});

// Test date formatting
describe('date formatting', () => {
const date = new Date('2025-12-25T14:30:00Z');

test('formats date correctly for en-US', () => {
const formatted = new Intl.DateTimeFormat('en-US').format(date);
expect(formatted).toBe('12/25/2025');
});

test('formats date correctly for en-GB', () => {
const formatted = new Intl.DateTimeFormat('en-GB').format(date);
expect(formatted).toBe('25/12/2025');
});

test('formats date correctly for de-DE', () => {
const formatted = new Intl.DateTimeFormat('de-DE').format(date);
expect(formatted).toBe('25.12.2025');
});
});

// Test currency formatting
describe('currency formatting', () => {
const amount = 1234.56;

test('formats USD for en-US', () => {
const formatted = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount);
expect(formatted).toBe('$1,234.56');
});

test('formats EUR for de-DE', () => {
const formatted = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(amount);
expect(formatted).toBe('1.234,56 €');
});
});

Manual Testing Checklist

Test each locale thoroughly:

  1. Text Display: All strings translated (no English fallbacks visible in production)
  2. Text Expansion: Longer translations don't cause truncation or layout breaks
  3. Date Formats: Dates display in correct regional format
  4. Number Formats: Numbers use correct thousands/decimal separators
  5. Currency: Currency symbols and formatting correct for locale
  6. Pluralization: Correct plural forms for different counts (0, 1, 2, 5, 100)
  7. RTL Layouts (if applicable): Mirrored layout, correct text direction, appropriate icon mirroring
  8. Images and Icons: Culturally appropriate, no text in images (or localized versions provided)
  9. Input Validation: Accepts locale-specific input formats (date pickers, number inputs)
  10. Sort Order: Lists sorted correctly for locale (e.g., accented characters in Spanish)

Testing with Real Users: Whenever possible, have native speakers of target languages test the application. They'll catch translation errors, cultural inappropriateness, and UX issues that automated tests miss.


Performance Considerations

Lazy Loading Translations

Don't load all locales upfront - load only the active locale:

//  BAD: Load all locales (large bundle)
import enUS from './locales/en-US.json';
import esMX from './locales/es-MX.json';
import ptBR from './locales/pt-BR.json';
import frFR from './locales/fr-FR.json';

// GOOD: Lazy load on demand
i18n.use(HttpBackend).init({
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
// Translations loaded via HTTP when needed
});

// Or with dynamic imports
async function loadLocale(locale: string) {
const translations = await import(`./locales/${locale}.json`);
i18n.addResourceBundle(locale, 'translation', translations);
i18n.changeLanguage(locale);
}

Namespace Splitting

Split translations into namespaces to avoid loading unused strings:

locales/
en-US/
common.json # Shared UI elements (nav, buttons)
payment.json # Payment feature
dashboard.json # Dashboard feature
admin.json # Admin panel (not needed for regular users)
// Load only needed namespaces
const { t } = useTranslation(['common', 'payment']); // Don't load dashboard, admin

// Use namespaced keys
t('common:navigation.menu.dashboard')
t('payment:form.submit_button')

Caching Strategies

// Cache translations in service worker for offline access
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/locales/')) {
event.respondWith(
caches.match(event.request).then(cached => {
return cached || fetch(event.request).then(response => {
const cache = await caches.open('translations-v1');
cache.put(event.request, response.clone());
return response;
});
})
);
}
});

// Or use localStorage caching
i18n.use(LocalStorageBackend).init({
backend: {
expirationTime: 7 * 24 * 60 * 60 * 1000, // 7 days
},
});

For comprehensive performance optimization, see Performance Optimization.


Accessibility and i18n

Internationalization intersects with accessibility:

Language Declaration

Always declare content language for screen readers:

<!-- Page language -->
<html lang="en-US">

<!-- Mixed language content -->
<p>The French word for hello is <span lang="fr">bonjour</span>.</p>

<!-- Dynamic language -->
<html lang="{{ currentLocale }}">

Screen readers use the lang attribute to select the correct pronunciation rules. Without it, English screen readers will mispronounce foreign words.

Text Direction

Declare text direction for RTL languages:

<html lang="ar-SA" dir="rtl">

This ensures screen readers announce content in the correct order and assistive technologies navigate appropriately.

Translated ARIA Labels

ARIA labels must be translated:

//  BAD: Hardcoded English in ARIA
<button aria-label="Close dialog">×</button>

// GOOD: Translated ARIA
<button aria-label={t('common.close_dialog')}>×</button>

For comprehensive accessibility guidance, see Accessibility Guidelines.


Summary

Key Takeaways:

  1. i18n vs l10n: Internationalization (i18n) is development-time architecture for supporting multiple locales. Localization (l10n) is translation and adaptation for specific markets.

  2. Externalize all strings: Never hardcode user-facing text. Use translation keys and resource files.

  3. Locale-aware formatting: Use Intl API (JavaScript) or DateTimeFormatter/NumberFormat (Java) for dates, numbers, and currencies. Never format manually.

  4. Translation keys should be descriptive: Use namespaced, contextual keys like payment.form.submit_button, not generic keys like button1.

  5. Pluralization is complex: Different languages have different plural rules (English: 2, Arabic: 6, Polish: 4). Use ICU Message Format and let libraries handle it.

  6. RTL requires mirroring: Use logical CSS properties (margin-inline-start instead of margin-left) and mirror appropriate icons.

  7. Translation workflow: Extract strings → send to translators → receive translations → integrate → test. Use Translation Management Systems (Crowdin, Lokalise) to automate this.

  8. Provide translator context: Include comments, screenshots, and glossaries so translators understand meaning and usage.

  9. Test with pseudo-localization: Pseudo-l10n catches hard-coded strings, layout issues, and text expansion problems before real translation.

  10. Performance matters: Lazy load locales, split by namespace, cache translations. Don't bundle all locales in main JavaScript.

  11. Accessibility integration: Declare language with lang attribute, translate ARIA labels, support screen readers in all locales.

  12. Unicode everywhere: Use UTF-8 encoding in database, API, and frontend. Support emoji, accented characters, and non-Latin scripts.

Related Topics:


External Resources