Internationalization: i18n and l10n Patterns

The terms get thrown around interchangeably, but they represent fundamentally different concerns. **Internationalization (i18n)** is the engineering work: designing your application architecture to...

Key Insights

  • Internationalization (i18n) is the architectural foundation you build once; localization (l10n) is the ongoing process of adapting content for specific locales—conflating them leads to costly rewrites.
  • ICU MessageFormat is the industry standard for handling pluralization and gender across languages—avoid building custom plural logic that will inevitably fail for languages like Arabic or Polish.
  • Pseudo-localization during development catches hardcoded strings and layout issues before they become expensive production bugs in markets you can’t easily test.

Introduction: i18n vs l10n Defined

The terms get thrown around interchangeably, but they represent fundamentally different concerns. Internationalization (i18n) is the engineering work: designing your application architecture to support multiple locales without code changes. Localization (l10n) is the content work: translating text, adapting images, and adjusting formats for specific regions.

Think of i18n as building a house with modular electrical systems that work with any country’s voltage. L10n is plugging in the right adapters for each country you ship to.

This distinction matters because i18n is expensive to retrofit. If you hardcode strings, concatenate translated fragments, or assume left-to-right layouts, you’ll face a painful rewrite when expanding to new markets. The engineering challenges include text extraction, plural handling across grammatically complex languages, bidirectional text support, and locale-aware formatting—all while maintaining performance and developer experience.

Text Externalization and Resource Bundles

The foundation of any i18n strategy is getting strings out of your code. Every user-facing string should live in external resource files, keyed by identifiers that your code references.

// locales/en-US.json
{
  "common": {
    "buttons": {
      "submit": "Submit",
      "cancel": "Cancel",
      "save": "Save changes"
    },
    "errors": {
      "networkError": "Unable to connect. Please try again.",
      "validationFailed": "Please check the highlighted fields."
    }
  },
  "dashboard": {
    "welcomeMessage": "Welcome back, {userName}",
    "lastLogin": "Last login: {date}"
  }
}

Key naming conventions matter. Use namespaces to organize by feature or page (dashboard.welcomeMessage), not by UI element (button1Text). Names should describe the semantic purpose, not the current English text—errors.networkError survives translation better than errors.unableToConnect.

Here’s a practical loader pattern for React applications:

// lib/i18n.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';

const loadLocale = async (locale: string) => {
  const resources = await import(`../locales/${locale}.json`);
  return resources.default;
};

export const initI18n = async (locale: string = 'en-US') => {
  const resources = await loadLocale(locale);
  
  await i18n
    .use(initReactI18next)
    .init({
      resources: { [locale]: { translation: resources } },
      lng: locale,
      fallbackLng: 'en-US',
      interpolation: { escapeValue: false },
      returnNull: false,
    });
    
  return i18n;
};

Handling Pluralization and Gender

This is where naive i18n implementations fall apart. English has two plural forms: singular and plural. Russian has four. Arabic has six. Building custom logic for each language is a maintenance nightmare.

ICU MessageFormat is the solution. It’s a standard syntax that encodes plural and selection logic directly in your translation strings:

{
  "items": {
    "count": "{count, plural, =0 {No items} one {# item} other {# items}}",
    "selected": "{count, plural, =0 {Nothing selected} one {# item selected} other {# items selected}}"
  },
  "notifications": {
    "newMessages": "{count, plural, =0 {No new messages} one {You have # new message} other {You have # new messages}}"
  },
  "greeting": "{gender, select, male {He liked your post} female {She liked your post} other {They liked your post}}"
}

For Russian, translators provide all necessary forms without any code changes:

{
  "items": {
    "count": "{count, plural, =0 {Нет элементов} one {# элемент} few {# элемента} many {# элементов} other {# элемента}}"
  }
}

Implementation with FormatJS:

import { IntlProvider, FormattedMessage } from 'react-intl';

// Component usage
function ItemCount({ count }: { count: number }) {
  return (
    <FormattedMessage
      id="items.count"
      defaultMessage="{count, plural, =0 {No items} one {# item} other {# items}}"
      values={{ count }}
    />
  );
}

// Or with i18next and its ICU plugin
import { useTranslation } from 'react-i18next';

function ItemCount({ count }: { count: number }) {
  const { t } = useTranslation();
  return <span>{t('items.count', { count })}</span>;
}

Date, Time, Number, and Currency Formatting

Never format dates or numbers manually. The Intl namespace provides locale-aware formatting that handles the countless variations across regions:

// Date formatting
const formatDate = (date: Date, locale: string) => {
  return new Intl.DateTimeFormat(locale, {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  }).format(date);
};

formatDate(new Date('2024-03-15'), 'en-US'); // "March 15, 2024"
formatDate(new Date('2024-03-15'), 'de-DE'); // "15. März 2024"
formatDate(new Date('2024-03-15'), 'ja-JP'); // "2024年3月15日"

// Number formatting
const formatNumber = (value: number, locale: string) => {
  return new Intl.NumberFormat(locale).format(value);
};

formatNumber(1234567.89, 'en-US'); // "1,234,567.89"
formatNumber(1234567.89, 'de-DE'); // "1.234.567,89"
formatNumber(1234567.89, 'fr-FR'); // "1 234 567,89"

// Currency formatting
const formatCurrency = (
  amount: number, 
  currency: string, 
  locale: string
) => {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency,
  }).format(amount);
};

formatCurrency(99.99, 'USD', 'en-US'); // "$99.99"
formatCurrency(99.99, 'EUR', 'de-DE'); // "99,99 €"
formatCurrency(99.99, 'JPY', 'ja-JP'); // "¥100"

Timezone handling deserves special attention. Store timestamps in UTC, transmit them in ISO 8601 format, and format for display using the user’s timezone:

const formatWithTimezone = (
  isoString: string, 
  locale: string, 
  timezone: string
) => {
  return new Intl.DateTimeFormat(locale, {
    dateStyle: 'medium',
    timeStyle: 'short',
    timeZone: timezone,
  }).format(new Date(isoString));
};

// Same instant, different displays
const timestamp = '2024-03-15T14:30:00Z';
formatWithTimezone(timestamp, 'en-US', 'America/New_York'); 
// "Mar 15, 2024, 10:30 AM"
formatWithTimezone(timestamp, 'en-GB', 'Europe/London');    
// "15 Mar 2024, 14:30"

Right-to-Left (RTL) and Bidirectional Text

Supporting RTL languages like Arabic and Hebrew requires more than flipping your CSS. Use logical properties instead of physical directions:

/* Bad: Physical properties require RTL overrides */
.card {
  margin-left: 16px;
  padding-right: 24px;
  text-align: left;
  border-left: 2px solid blue;
}

/* Good: Logical properties adapt automatically */
.card {
  margin-inline-start: 16px;
  padding-inline-end: 24px;
  text-align: start;
  border-inline-start: 2px solid blue;
}

/* Logical property mappings */
.container {
  /* Block axis (vertical in horizontal writing modes) */
  margin-block-start: 1rem;   /* margin-top in LTR */
  margin-block-end: 1rem;     /* margin-bottom in LTR */
  
  /* Inline axis (horizontal in horizontal writing modes) */
  margin-inline-start: 1rem;  /* margin-left in LTR, margin-right in RTL */
  margin-inline-end: 1rem;    /* margin-right in LTR, margin-left in RTL */
  
  padding-inline: 2rem;       /* Shorthand for start and end */
}

Set the dir attribute at the document or component level:

function App({ locale }: { locale: string }) {
  const direction = ['ar', 'he', 'fa'].includes(locale.split('-')[0]) 
    ? 'rtl' 
    : 'ltr';
    
  return (
    <html lang={locale} dir={direction}>
      <body>{/* ... */}</body>
    </html>
  );
}

Architecture Patterns for i18n at Scale

Loading all translations upfront kills performance. Implement lazy loading with namespace splitting:

// Locale loader with dynamic imports
const localeLoaders: Record<string, () => Promise<any>> = {
  'en-US': () => import('../locales/en-US.json'),
  'de-DE': () => import('../locales/de-DE.json'),
  'ja-JP': () => import('../locales/ja-JP.json'),
  'ar-SA': () => import('../locales/ar-SA.json'),
};

// Namespace-based loading for large apps
const loadNamespace = async (locale: string, namespace: string) => {
  const module = await import(`../locales/${locale}/${namespace}.json`);
  return module.default;
};

// Usage in route-based code splitting
async function loadDashboardRoute(locale: string) {
  const [component, translations] = await Promise.all([
    import('./pages/Dashboard'),
    loadNamespace(locale, 'dashboard'),
  ]);
  
  i18n.addResourceBundle(locale, 'dashboard', translations);
  return component;
}

For translation management, integrate with your CI/CD pipeline. Export source strings automatically, import translations before deployment, and fail builds on missing critical translations.

Testing and Quality Assurance

Pseudo-localization is your first line of defense. It transforms strings to expose i18n issues while remaining readable:

const pseudoLocalize = (str: string): string => {
  const charMap: Record<string, string> = {
    'a': 'α', 'b': 'β', 'c': 'ç', 'd': 'δ', 'e': 'є',
    'f': 'ƒ', 'g': 'ǥ', 'h': 'ħ', 'i': 'ï', 'j': 'ĵ',
    'k': 'ķ', 'l': 'ļ', 'm': 'ɱ', 'n': 'ñ', 'o': 'ø',
    'p': 'ρ', 'q': 'ǫ', 'r': 'ŗ', 's': 'š', 't': 'ţ',
    'u': 'ü', 'v': 'ν', 'w': 'ŵ', 'x': 'χ', 'y': 'ý', 'z': 'ž',
  };
  
  const transformed = str
    .split('')
    .map(char => charMap[char.toLowerCase()] || char)
    .join('');
  
  // Add length padding (translations are often 30% longer)
  const padding = '~'.repeat(Math.ceil(str.length * 0.3));
  
  return `[${transformed}${padding}]`;
};

pseudoLocalize("Submit"); // "[Šüβɱïţ~~]"

Automate missing translation detection in CI:

// scripts/check-translations.ts
import { readdirSync, readFileSync } from 'fs';
import { join } from 'path';

const LOCALES_DIR = './locales';
const SOURCE_LOCALE = 'en-US';

function getAllKeys(obj: any, prefix = ''): string[] {
  return Object.entries(obj).flatMap(([key, value]) => {
    const path = prefix ? `${prefix}.${key}` : key;
    return typeof value === 'object' && value !== null
      ? getAllKeys(value, path)
      : [path];
  });
}

const sourceKeys = new Set(
  getAllKeys(JSON.parse(readFileSync(
    join(LOCALES_DIR, `${SOURCE_LOCALE}.json`), 'utf-8'
  )))
);

const locales = readdirSync(LOCALES_DIR)
  .filter(f => f.endsWith('.json') && f !== `${SOURCE_LOCALE}.json`);

let hasErrors = false;

for (const locale of locales) {
  const translations = JSON.parse(
    readFileSync(join(LOCALES_DIR, locale), 'utf-8')
  );
  const translatedKeys = new Set(getAllKeys(translations));
  
  const missing = [...sourceKeys].filter(k => !translatedKeys.has(k));
  
  if (missing.length > 0) {
    console.error(`\n${locale}: ${missing.length} missing translations`);
    missing.forEach(k => console.error(`  - ${k}`));
    hasErrors = true;
  }
}

process.exit(hasErrors ? 1 : 0);

Internationalization done right is invisible to users—they simply see an application that speaks their language and respects their conventions. Done wrong, it’s a constant source of embarrassing bugs and expensive retrofits. Invest in the architecture early, use standard formats like ICU MessageFormat, and automate quality checks. Your future self expanding into new markets will thank you.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.