
Software Localisation
- 162 installs
- 73 repo stars
- Updated July 13, 2026
- vasilyu1983/ai-agents-public
Helps with ai & agent building tasks.
About
software-localisation is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- software-localisation
- AI & Agent Building
- AI-coding skill
Software Localisation by the numbers
- 162 all-time installs (skills.sh)
- +9 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #3,219 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/vasilyu1983/ai-agents-public --skill software-localisationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 162 |
|---|---|
| repo stars | ★ 73 |
| Last updated | July 13, 2026 |
| Repository | vasilyu1983/ai-agents-public ↗ |
What it does
Helps with ai & agent building tasks.
Files
Software Localisation - Quick Reference
Production patterns for internationalisation (i18n) and localisation (l10n) in modern web applications. Covers library selection, translation management, ICU message format, RTL support, and CI/CD workflows.
Snapshot (2026-02): i18next 25.x, react-i18next 16.x, react-intl 8.x, vue-i18n 11.x, next-intl 4.x, @angular/localize 21.x. Always verify current versions in the target repo (see Currency Check Protocol).
Authoritative References:
Quick Reference
| Task | Tool/Library | Command | When to Use |
|---|---|---|---|
| React i18n | react-i18next | npm i i18next react-i18next | Most React apps, flexibility |
| React i18n (ICU) | react-intl (FormatJS) | npm i react-intl | ICU-first message catalog + tooling |
| Vue i18n | vue-i18n | npm i vue-i18n | Vue 3 apps |
| Angular i18n | @angular/localize | ng add @angular/localize | Angular apps |
| Next.js i18n | next-intl | npm i next-intl | Next.js App Router |
| Minimal bundle | LinguiJS | npm i @lingui/core @lingui/react | Bundle size critical |
| Type-safe | typesafe-i18n | npm i typesafe-i18n | TypeScript-first projects |
| String extraction | i18next-parser | npx i18next-parser | Extract keys from code |
| ICU linting | @formatjs/cli | npx formatjs extract | Validate ICU messages |
Decision Tree: Library Selection
Project requirements:
│
├─ React/Next.js project?
│ ├─ ICU-first message catalogs + FormatJS tooling?
│ │ └─ react-intl (FormatJS)
│ │
│ ├─ Flexibility, plugins, lazy loading?
│ │ └─ react-i18next
│ │
│ ├─ Bundle size critical?
│ │ └─ LinguiJS (ICU syntax)
│ │
│ └─ TypeScript-first, compile-time safety?
│ └─ typesafe-i18n
│
├─ Vue/Nuxt project?
│ └─ vue-i18n (Composition API)
│
├─ Angular project?
│ ├─ Built-in solution preferred?
│ │ └─ @angular/localize (first-party, AOT support)
│ │
│ └─ Need i18next ecosystem?
│ └─ angular-i18next (wrapper)
│
└─ Framework-agnostic / Node.js?
└─ i18next core (works everywhere)Library Comparison
| Library | ICU Support | Lazy Loading | TypeScript | Best For |
|---|---|---|---|---|
| react-i18next | Plugin/optional | Native | Good | Flexible, popular React choice |
| react-intl | Native | Manual | Good | ICU-first catalogs + tooling |
| LinguiJS | Native | Native | Excellent | Bundle-conscious apps |
| typesafe-i18n | Limited | Manual | Excellent | Compile-time key safety |
| vue-i18n | Native | Native | Good | Vue 3 apps |
| @angular/localize | Native | AOT | Native | Angular apps |
Core Concepts
Character Encoding (Critical)
Always use UTF-8 across your entire stack to prevent text corruption:
PASS Required: UTF-8 everywhere
- Database: utf8mb4 (MySQL) or UTF-8 (PostgreSQL)
- HTML: <meta charset="UTF-8">
- HTTP headers: Content-Type: text/html; charset=utf-8
- File encoding: Save all source files as UTF-8
- API responses: JSON with UTF-8 encodingUTF-8 supports all Unicode characters including emojis, mathematical symbols, and all language scripts. Inconsistent encoding causes: corrupted characters (�), failed searches for accented names, and rejected international input.
Translation Key Patterns
// Flat keys (simple)
"welcome": "Welcome to our app"
"user.greeting": "Hello, {name}"
// Nested keys (organised)
{
"user": {
"greeting": "Hello, {name}",
"profile": {
"title": "Your Profile"
}
}
}
// Namespace separation (scalable)
// common.json, auth.json, dashboard.jsonICU Message Format Essentials
// Simple interpolation
"Hello, {name}!"
// Pluralisation
"{count, plural, one {# item} other {# items}}"
// Select (gender, category)
"{gender, select, male {He} female {She} other {They}} liked your post"
// Number formatting
"Price: {price, number, currency}"
// Date formatting
"Posted: {date, date, medium}"Locale Detection Strategy
Priority order:
1. User preference (stored in profile/localStorage)
2. URL parameter or path (/en/about, ?lang=de)
3. Cookie (NEXT_LOCALE, i18next)
4. Accept-Language header
5. Default locale fallbackLocale Quality Gates (SEO/AEO-Safe)
Use these gates for locale-routed, indexable pages (for example /vi/*, /de/*):
- Do not ship mixed-language content on a single locale route.
- Do not silently fall back to English for indexable page content.
- Keep metadata, breadcrumbs, and JSON-LD in the same locale as visible content.
- Prefer explicit missing-key handling in CI over runtime fallback in production SEO pages.
- If fallback is unavoidable, use locale-safe neutral copy and track missing keys.
Missing Translation Decision Rule
- Marketing/SEO pages: block publish or replace with locale-safe copy; never inject English fragments.
- Product UI (non-indexed surfaces): fallback is acceptable with telemetry and follow-up fix.
EN/RU Mixed-Language Regression Protocol
Use this when users report locale mixing (for example RU screens showing EN fragments).
1) Key-Parity Diff (Base vs Target Locale)
Compare key sets between source and target locale files; treat missing keys as release blockers on user-facing pages.
jq -r 'paths(scalars) | join(".")' app/src/messages/en/*.json | sort -u > /tmp/en.keys
jq -r 'paths(scalars) | join(".")' app/src/messages/ru/*.json | sort -u > /tmp/ru.keys
comm -23 /tmp/en.keys /tmp/ru.keys # present in EN, missing in RU2) Hardcoded-String Sweep in UI
Search for user-visible literals in components/pages that should use i18n keys.
rg -n '>[A-Za-z][^<]{2,}<' app/src -g '*.tsx'
rg -n '"[A-Za-z][^"]{2,}"' app/src -g '*.tsx' -g '*.ts'3) Route-Level Locale Smoke Check
For target locale routes, verify rendered text is consistently localized and no fallback EN fragments appear in critical UI regions.
4) Engine Text Audit
Ensure computed/engine-driven messages (not just static labels) pass through translation mapping instead of returning raw EN strings.
5) CI Gate
Add a lightweight gate that fails when:
- required target-locale keys are missing,
- newly added UI literals bypass the i18n layer,
- locale-routed smoke pages include mixed-language sentinel terms.
Runtime Constraint Note
If an agent runtime has no external translation connector, do not block on auto-translation tools. Enforce key completeness + placeholder strategy, then backfill approved translations in a separate tracked pass.
Engine Output i18n (_i18n Metadata Pattern)
Server-generated engine content (astrology calculations, ML outputs, computed reports) needs localisation without making the engine locale-aware.
Pattern
- Engine attaches
_i18n: { key: "transits.neptune_trine.description", params: { planet: "Neptune" } }alongside the English string - Client resolves:
_i18n ? t(_i18n.key, _i18n.params) : englishFallback - Backward-compatible: old cached responses without
_i18ngracefully degrade to English - Server caches once; every locale resolves on the client
- Use
t.has(key)beforet(key)for graceful fallback
| Pattern | Status | Why |
|---|---|---|
{ text: "Neptune trine Jupiter", _i18n: { key: "transits.neptune_trine", params: { p1: "Neptune", p2: "Jupiter" } } } | PASS | Client resolves per locale; server caches once |
{ text_en: "...", text_ru: "...", text_de: "..." } | FAIL | Server bloat, cache per locale |
t(meaning.theme) | FAIL | Using raw engine output as translation key |
t.has('meanings.4.theme') ? t('meanings.4.theme') : meaning.theme | PASS | Graceful fallback when key missing |
Locale Key Design Anti-Patterns
"1 Field, N Slots"
Using one locale key for multiple distinct UI purposes. Each UI slot (badge, card title, modal description, affirmation) needs its own semantically distinct key, even if the English text happens to be similar.
| Pattern | Status | Why |
|---|---|---|
meanings.4.advice used for karmic debt, life path, birthday guidance, and advanced cycles (same text 6x) | FAIL | Coupling breaks when any slot needs a different translation |
meanings.4.advice, karmicDebt.4.lifeLesson, lifePath.4.affirmation, birthday.4.guidance | PASS | Distinct keys per slot — independent translation |
Short vs. Long Variants
Plan for both from the start.
| Pattern | Status | Why |
|---|---|---|
nodeGrowth (full: "North Node — growth and new beginnings") + nodeGrowthShort (badge: "Growth") | PASS | Each UI context gets appropriate length |
Single nodeGrowth key that's too long for badge UI, requires substring hacks | FAIL | Substring breaks in non-English locales |
Static Key Maps over String Transforms
When API output format doesn't match locale key naming, use a hardcoded map instead of string manipulation.
// PASS: Explicit map — handles all edge cases
const PHASE_TO_KEY: Record<string, string> = {
"New Moon": "new", "Waxing Crescent": "waxingCrescent",
"First Quarter": "firstQuarter", "Full Moon": "full"
};
// FAIL: String transform — breaks on "New Moon" → "newMoon" vs actual key "new"
const key = phaseName.replace(/\s+/g, '').replace(/^./, c => c.toLowerCase());Machine Translation Quality Gates
Short, domain-specific terms trip up automated MT. Known examples:
| Locale | Expected | MT Output | Term |
|---|---|---|---|
| Arabic | أرض (earth as element) | أذن (ear) | "earth" |
| Japanese | 火 (fire as element) | 樅 (fir tree) | "fire" |
| Hindi | भू (earth) | कान (ear) | "earth" |
Rules
1. Maintain a curated dictionary of domain terms (zodiac signs, elements, planetary names, astronomical terms) per locale 2. Never auto-translate terms shorter than 3 words without dictionary lookup 3. Post-MT audit: grep for known bad translations (compile a blocklist per locale) 4. For new locales, translate domain terms first, then use them as glossary constraints for MT
Locale Propagation Protocol
Every commit adding EN keys MUST propagate to all target locales. This is the #1 recurring i18n bug — hit in 4+ independent sessions.
Steps
1. Before commit: diff EN locale files against target locales for missing keys 2. Script-based propagation: inject missing keys from EN into all other locales with EN fallback values 3. CI gate: fail builds when target locale files have fewer keys than EN (configurable threshold) 4. Pre-commit hook (optional): auto-run propagation script on staged locale files
Quick Key-Parity Check
jq -r 'paths(scalars) | join(".")' messages/en/*.json | sort -u > /tmp/en.keys
for locale in ar de es fr hi it ja ko pt-BR ru tr vi zh; do
jq -r 'paths(scalars) | join(".")' messages/$locale/*.json | sort -u > /tmp/$locale.keys
echo "=== $locale missing ==="
comm -23 /tmp/en.keys /tmp/$locale.keys | head -20
doneBatch Translation Approach
Find all gaps first (diff-based), then translate systematically file-by-file. One-at-a-time discovery is 5x slower than batching:
1. Run key-parity check across all locales to produce the full gap list 2. Group missing keys by namespace/file 3. Translate one file at a time for each locale, using existing translations as glossary context 4. Verify parity after the batch completes
Duplicate JSON Key Detection
Large hand-edited JSON locale files can have duplicate keys. Per JSON spec, last-writer-wins — keys are silently dropped.
CI Check
# Detect duplicate keys in JSON locale files
node -e "
const fs = require('fs');
const file = process.argv[1];
const text = fs.readFileSync(file, 'utf8');
const keys = [];
JSON.parse(text, (key, value) => { if (key) keys.push(key); return value; });
const dupes = keys.filter((k, i) => keys.indexOf(k) !== i);
if (dupes.length) { console.error('DUPLICATE KEYS in', file, ':', [...new Set(dupes)]); process.exit(1); }
" "$FILE"Run this on every locale file in CI to catch silent key collisions before they reach production.
Navigation
Resources (Deep Dives)
- references/framework-guides.md - React, Vue, Angular, Next.js implementation
- references/icu-message-format.md - Pluralisation, select, formatting
- references/translation-workflows.md - TMS, CI/CD, string extraction
- references/rtl-support.md - Right-to-left language support
- references/locale-handling.md - Dates, numbers, currencies
- references/testing-i18n.md - Pseudo-localisation, visual regression, plural testing, missing translation CI detection
- references/accessibility-i18n.md - Screen readers across languages, ARIA in multilingual contexts, BiDi accessibility, IME
- references/content-management-patterns.md - Translation memory, glossaries, context for translators, MTPE workflows, cost optimisation
- references/ops-runbook.md - LLM-safe triage scripts for large catalogs, key-parity checks, CI gate patterns
Templates (Production Starters)
- assets/react-i18next-setup.md - React + i18next complete setup
- assets/vue-i18n-setup.md - Vue 3 + vue-i18n setup
- assets/nextjs-i18n-setup.md - Next.js App Router i18n
Data
- data/sources.json - 60+ curated external references
Related Skills
- ../software-frontend/SKILL.md - Frontend architecture patterns (React, Vue, Angular, Next.js)
- ../marketing-seo/SKILL.md - Hreflang, international SEO
Common Patterns
Namespace Organisation
locales/
├── en/
│ ├── common.json # Shared: buttons, errors, nav
│ ├── auth.json # Login, register, password
│ ├── dashboard.json # Dashboard-specific
│ └── validation.json # Form validation messages
├── de/
│ └── ... (same structure)
└── ar/
└── ... (same structure)Lazy Loading and TypeScript Integration
Load namespaces on demand (i18n.loadNamespaces) and use CustomTypeOptions in i18next to get compile-time key safety. See references/framework-guides.md for per-framework setup with code examples.
Anti-Patterns to Avoid
| Anti-Pattern | Problem | Fix |
|---|---|---|
| Hardcoded strings | Not translatable | Extract all user-facing text |
| String concatenation | Breaks translation context | Use interpolation {name} |
| Manual pluralisation | Wrong for many languages | Use ICU plural rules |
| Inline styles for RTL | Doesn't scale | Use CSS logical properties |
| Storing locale in URL only | Lost on navigation | Also persist to cookie/storage |
| No fallback locale | Blank text for missing keys | Always set fallbackLng |
| Silent English fallback on indexable non-English pages | Mixed-language output harms UX and can weaken locale SEO/AEO quality | Use locale-safe copy or fail build on missing keys for indexable routes |
| Loading all locales upfront | Slow initial load | Lazy load per namespace/locale |
Operational Checklist
Initial Setup
- REQUIRED: Choose i18n library based on decision tree
- REQUIRED: Set up directory structure for translations
- REQUIRED: Configure fallback locale chain
- REQUIRED: Set up locale detection strategy
- REQUIRED: Add TypeScript types for translation keys
- REQUIRED: Configure lazy loading for namespaces
Translation Workflow
- REQUIRED: Set up string extraction (i18next-parser, formatjs, Lingui)
- REQUIRED: Integrate with a TMS when needed (Phrase, Lokalise, Crowdin, Locize)
- REQUIRED: Configure CI/CD for translation sync
- REQUIRED: Set up translation review process (glossary + style guide + QA gates)
- REQUIRED: Add missing key detection in development
- REQUIRED: Add hardcoded string detection for locale-routed pages
- REQUIRED: Verify metadata + JSON-LD locale parity with visible content
- REQUIRED: Add locale QA for mixed-language regressions on high-intent pages
i18n Key Validation (Per-Change Gate)
When adding new t() / useTranslations() calls or new message keys:
1. Verify the key exists in the base locale file (e.g., messages/en/*.json). 2. Add the key to the base locale file before using it in code. 3. For multi-locale projects, add placeholder entries in all locale files or confirm the fallback chain handles missing keys gracefully. 4. Run the project's missing-key detection (e.g., npm run build or next-intl compile check) before committing.
Missing i18n keys cause blank text or fallback-language bleed on localized pages — a silent, user-facing regression.
RTL Support
- REQUIRED: Use CSS logical properties (margin-inline-start)
- REQUIRED: Set
dir="rtl"for RTL locales - REQUIRED: Test with real RTL content (Arabic, Hebrew)
- REQUIRED: Handle bidirectional text (BiDi) in mixed strings
- REQUIRED: Mirror directional icons and images where appropriate
Testing
- REQUIRED: Test pluralisation with 0, 1, 2, 5, 21 (language-specific)
- REQUIRED: Test date/number/currency formatting per locale
- REQUIRED: Test RTL layout in key screens/components
- REQUIRED: Test missing translation key handling (dev-only warnings)
- REQUIRED: Test locale switching and persistence (cookie/storage/url)
Currency Check Protocol
When recommending libraries, versions, or tooling, verify what is current for the target ecosystem and project constraints. Prefer package registries and release notes over stale hard-coded numbers.
Package versions (Node/npm)
npm view i18next version
npm view react-i18next version
npm view react-intl version
npm view vue-i18n version
npm view next-intl version
npm view @angular/localize version
npm view @lingui/core version
npm view typesafe-i18n version"Is X still recommended?" checks
- Check the project's last release date, open issues, and maintenance activity (GitHub releases/issues).
- Check framework compatibility (Next.js App Router/RSC, React 19, Vue 3, Angular current major).
- For bundle concerns, measure in the real app with a bundle analyzer instead of relying on published size claims.
Ops Runbook: Large Locale Catalogs (LLM-Safe)
Use this when locale catalogs are too large for single reads, mixed-language UI appears, or missing keys are reported. See references/ops-runbook.md for triage scripts, key-parity checks, hardcoded string sweeps, and CI gate patterns.
Operational Rules:
- Never read large locale files in one shot; always chunk.
- Use key diff first, translation pass second.
- Treat marketing/SEO locale key gaps as release blockers.
- Do not auto-insert machine translations without a tracked review pass.
Fact-Checking
- Use web search/web fetch to verify current external facts, versions, pricing, deadlines, regulations, or platform behavior before final answers.
- Prefer primary sources; report source links and dates for volatile information.
- If web access is unavailable, state the limitation and mark guidance as unverified.
Next.js App Router + next-intl Complete Setup
Production-ready i18n setup for Next.js 14+ App Router with Server Components, TypeScript, and static generation.
---
Project Structure
├── app/
│ ├── [locale]/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── dashboard/
│ │ │ └── page.tsx
│ │ └── (auth)/
│ │ ├── login/
│ │ │ └── page.tsx
│ │ └── register/
│ │ └── page.tsx
│ ├── layout.tsx
│ └── not-found.tsx
├── messages/
│ ├── en.json
│ ├── de.json
│ └── ar.json
├── i18n/
│ ├── request.ts
│ ├── routing.ts
│ └── navigation.ts
├── middleware.ts
└── components/
└── LanguageSwitcher.tsx---
Installation
npm install next-intl---
Configuration
i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['en', 'de', 'fr', 'ar'],
defaultLocale: 'en',
localePrefix: 'always', // or 'as-needed'
});
export type Locale = (typeof routing.locales)[number];
export const RTL_LOCALES: Locale[] = ['ar'];i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
// Validate locale
if (!locale || !routing.locales.includes(locale as any)) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});i18n/navigation.ts
import { createNavigation } from 'next-intl/navigation';
import { routing } from './routing';
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);middleware.ts
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
export default createMiddleware(routing);
export const config = {
matcher: [
// Match all pathnames except for
// - API routes
// - _next (Next.js internals)
// - Static files (images, etc.)
'/((?!api|_next|.*\\..*).*)',
],
};next.config.js
const createNextIntlPlugin = require('next-intl/plugin');
const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
/** @type {import('next').NextConfig} */
const nextConfig = {
// Your other Next.js config
};
module.exports = withNextIntl(nextConfig);---
Translation Files
messages/en.json
{
"Metadata": {
"title": "My Application",
"description": "Welcome to my application"
},
"Navigation": {
"home": "Home",
"dashboard": "Dashboard",
"settings": "Settings",
"logout": "Log out"
},
"Home": {
"title": "Welcome",
"description": "Get started with our platform",
"cta": "Get Started"
},
"Dashboard": {
"title": "Dashboard",
"welcome": "Welcome back, {name}!",
"stats": {
"users": "{count, plural, one {# user} other {# users}}",
"revenue": "Revenue: {amount, number, currency}"
}
},
"Auth": {
"login": {
"title": "Sign In",
"email": "Email Address",
"password": "Password",
"submit": "Sign In",
"forgotPassword": "Forgot password?",
"noAccount": "Don't have an account?",
"signUp": "Sign up"
},
"register": {
"title": "Create Account",
"name": "Full Name",
"submit": "Create Account"
}
},
"Common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"loading": "Loading..."
},
"Errors": {
"notFound": "Page not found",
"serverError": "Something went wrong"
}
}messages/de.json
{
"Metadata": {
"title": "Meine Anwendung",
"description": "Willkommen bei meiner Anwendung"
},
"Navigation": {
"home": "Startseite",
"dashboard": "Dashboard",
"settings": "Einstellungen",
"logout": "Abmelden"
},
"Home": {
"title": "Willkommen",
"description": "Starten Sie mit unserer Plattform",
"cta": "Loslegen"
},
"Dashboard": {
"title": "Dashboard",
"welcome": "Willkommen zurück, {name}!",
"stats": {
"users": "{count, plural, one {# Benutzer} other {# Benutzer}}",
"revenue": "Umsatz: {amount, number, currency}"
}
},
"Auth": {
"login": {
"title": "Anmelden",
"email": "E-Mail-Adresse",
"password": "Passwort",
"submit": "Anmelden",
"forgotPassword": "Passwort vergessen?",
"noAccount": "Noch kein Konto?",
"signUp": "Registrieren"
},
"register": {
"title": "Konto erstellen",
"name": "Vollständiger Name",
"submit": "Konto erstellen"
}
},
"Common": {
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"loading": "Laden..."
},
"Errors": {
"notFound": "Seite nicht gefunden",
"serverError": "Etwas ist schief gelaufen"
}
}---
App Structure
app/layout.tsx
import { ReactNode } from 'react';
type Props = {
children: ReactNode;
};
// Root layout without locale-specific content
export default function RootLayout({ children }: Props) {
return children;
}app/[locale]/layout.tsx
import { ReactNode } from 'react';
import { NextIntlClientProvider } from 'next-intl';
import { getMessages, setRequestLocale } from 'next-intl/server';
import { routing, RTL_LOCALES } from '@/i18n/routing';
import type { Locale } from '@/i18n/routing';
import Navigation from '@/components/Navigation';
type Props = {
children: ReactNode;
params: { locale: string };
};
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({ children, params: { locale } }: Props) {
setRequestLocale(locale);
const messages = await getMessages();
const isRTL = RTL_LOCALES.includes(locale as Locale);
return (
<html lang={locale} dir={isRTL ? 'rtl' : 'ltr'}>
<body>
<NextIntlClientProvider messages={messages}>
<Navigation />
<main>{children}</main>
</NextIntlClientProvider>
</body>
</html>
);
}app/[locale]/page.tsx
import { useTranslations } from 'next-intl';
import { setRequestLocale } from 'next-intl/server';
import { Link } from '@/i18n/navigation';
type Props = {
params: { locale: string };
};
export default function HomePage({ params: { locale } }: Props) {
setRequestLocale(locale);
const t = useTranslations('Home');
return (
<div>
<h1>{t('title')}</h1>
<p>{t('description')}</p>
<Link href="/dashboard">{t('cta')}</Link>
</div>
);
}app/[locale]/dashboard/page.tsx
import { useTranslations } from 'next-intl';
import { setRequestLocale, getTranslations } from 'next-intl/server';
type Props = {
params: { locale: string };
};
// Generate metadata
export async function generateMetadata({ params: { locale } }: Props) {
const t = await getTranslations({ locale, namespace: 'Dashboard' });
return {
title: t('title'),
};
}
export default function DashboardPage({ params: { locale } }: Props) {
setRequestLocale(locale);
const t = useTranslations('Dashboard');
const stats = {
users: 1234,
revenue: 50000,
};
return (
<div>
<h1>{t('title')}</h1>
<p>{t('welcome', { name: 'Alice' })}</p>
<div className="stats">
<p>{t('stats.users', { count: stats.users })}</p>
<p>
{t('stats.revenue', {
amount: stats.revenue,
currency: 'USD',
})}
</p>
</div>
</div>
);
}app/not-found.tsx
'use client';
import { useTranslations } from 'next-intl';
export default function NotFound() {
const t = useTranslations('Errors');
return (
<div>
<h1>404</h1>
<p>{t('notFound')}</p>
</div>
);
}---
Components
components/Navigation.tsx
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import LanguageSwitcher from './LanguageSwitcher';
export default function Navigation() {
const t = useTranslations('Navigation');
return (
<nav>
<Link href="/">{t('home')}</Link>
<Link href="/dashboard">{t('dashboard')}</Link>
<Link href="/settings">{t('settings')}</Link>
<LanguageSwitcher />
</nav>
);
}components/LanguageSwitcher.tsx
'use client';
import { useLocale } from 'next-intl';
import { useRouter, usePathname } from '@/i18n/navigation';
import { routing, type Locale } from '@/i18n/routing';
const languageNames: Record<Locale, string> = {
en: 'English',
de: 'Deutsch',
fr: 'Français',
ar: 'العربية',
};
export default function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const handleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const newLocale = event.target.value as Locale;
router.replace(pathname, { locale: newLocale });
};
return (
<select value={locale} onChange={handleChange} aria-label="Select language">
{routing.locales.map((loc) => (
<option key={loc} value={loc}>
{languageNames[loc]}
</option>
))}
</select>
);
}---
Server Components vs Client Components
Server Component (Default)
// app/[locale]/products/page.tsx
import { useTranslations } from 'next-intl';
import { setRequestLocale } from 'next-intl/server';
export default function ProductsPage({ params: { locale } }: Props) {
setRequestLocale(locale);
const t = useTranslations('Products');
// Can fetch data directly
// const products = await fetchProducts();
return <h1>{t('title')}</h1>;
}Client Component
'use client';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
export default function Counter() {
const t = useTranslations('Counter');
const [count, setCount] = useState(0);
return (
<div>
<p>{t('count', { count })}</p>
<button onClick={() => setCount(count + 1)}>{t('increment')}</button>
</div>
);
}---
Server Actions
With Translations
'use server';
import { getTranslations } from 'next-intl/server';
export async function submitForm(formData: FormData) {
const t = await getTranslations('Validation');
const email = formData.get('email') as string;
if (!email) {
return { error: t('required') };
}
// Process form...
return { success: true };
}---
Static Generation
Generate All Locale Paths
// app/[locale]/blog/[slug]/page.tsx
import { routing } from '@/i18n/routing';
export async function generateStaticParams() {
const posts = await fetchAllPosts();
return routing.locales.flatMap((locale) =>
posts.map((post) => ({
locale,
slug: post.slug,
}))
);
}---
API Routes with i18n
// app/api/messages/route.ts
import { getTranslations } from 'next-intl/server';
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
const locale = request.headers.get('Accept-Language')?.split(',')[0] || 'en';
const t = await getTranslations({ locale, namespace: 'API' });
return Response.json({
message: t('welcome'),
});
}---
SEO and Metadata
Dynamic Metadata
// app/[locale]/layout.tsx
import { getTranslations } from 'next-intl/server';
import { routing } from '@/i18n/routing';
export async function generateMetadata({ params: { locale } }: Props) {
const t = await getTranslations({ locale, namespace: 'Metadata' });
return {
title: {
default: t('title'),
template: `%s | ${t('title')}`,
},
description: t('description'),
alternates: {
languages: Object.fromEntries(
routing.locales.map((loc) => [loc, `/${loc}`])
),
},
};
}Hreflang Tags
Next-intl automatically generates hreflang tags. For custom control:
Locale Purity for SEO Pages (Critical)
For indexable locale routes, avoid mixed-language rendering:
- Do not use English fallback strings in non-English metadata or body copy.
- Keep
title,description, breadcrumbs, and JSON-LD in the same locale. - If a key is missing, fail CI or use locale-safe neutral copy (not English fragments).
- Validate rendered HTML for locale consistency before deploy.
// app/[locale]/layout.tsx
export async function generateMetadata({ params: { locale } }: Props) {
const baseUrl = 'https://example.com';
return {
alternates: {
canonical: `${baseUrl}/${locale}`,
languages: {
en: `${baseUrl}/en`,
de: `${baseUrl}/de`,
fr: `${baseUrl}/fr`,
ar: `${baseUrl}/ar`,
'x-default': `${baseUrl}/en`,
},
},
};
}---
Environment Variables
# .env.local
NEXT_PUBLIC_DEFAULT_LOCALE=en// i18n/routing.ts
export const routing = defineRouting({
locales: ['en', 'de', 'fr', 'ar'],
defaultLocale: process.env.NEXT_PUBLIC_DEFAULT_LOCALE || 'en',
});---
Testing
Test Setup
// jest.setup.ts
import { NextIntlClientProvider } from 'next-intl';
const messages = require('./messages/en.json');
global.renderWithIntl = (ui: React.ReactElement) => {
return render(
<NextIntlClientProvider locale="en" messages={messages}>
{ui}
</NextIntlClientProvider>
);
};Component Test
// components/LanguageSwitcher.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import LanguageSwitcher from './LanguageSwitcher';
const messages = {};
describe('LanguageSwitcher', () => {
it('renders language options', () => {
render(
<NextIntlClientProvider locale="en" messages={messages}>
<LanguageSwitcher />
</NextIntlClientProvider>
);
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
});---
Deployment
Vercel
next-intl works out of the box with Vercel. Middleware runs at the edge for fast locale detection.
Static Export
For static export, use localePrefix: 'always' and generate all locale paths:
// next.config.js
module.exports = withNextIntl({
output: 'export',
trailingSlash: true,
});---
Checklist
- REQUIRED: Install next-intl
- REQUIRED: Create routing configuration
- REQUIRED: Set up request configuration
- REQUIRED: Add middleware for locale detection
- REQUIRED: Update
next.config.js - REQUIRED: Create message files for each locale
- REQUIRED: Set up
[locale]directory structure - REQUIRED: Create LanguageSwitcher component
- REQUIRED: Handle RTL languages
- REQUIRED: Add metadata with translations
- REQUIRED: Set up hreflang tags (if SEO requirements apply)
- REQUIRED: Prevent mixed-language output on locale-routed pages
- REQUIRED: Avoid silent English fallback for indexable non-English content
- REQUIRED: Include JSON-LD locale parity checks in QA
- REQUIRED: Configure static generation (if applicable)
- REQUIRED: Set up testing utilities
React + i18next Complete Setup
Production-ready i18n setup for React applications with TypeScript, lazy loading, and namespace organisation.
---
Project Structure
src/
├── i18n/
│ ├── config.ts # i18next configuration
│ ├── resources.d.ts # TypeScript types
│ └── index.ts # Export
├── locales/
│ ├── en/
│ │ ├── common.json # Shared strings
│ │ ├── auth.json # Authentication
│ │ ├── dashboard.json # Dashboard
│ │ └── validation.json # Form validation
│ ├── de/
│ │ └── ... (same structure)
│ └── ar/
│ └── ... (same structure)
├── components/
│ ├── LanguageSwitcher.tsx
│ └── ...
└── App.tsx---
Installation
npm install i18next react-i18next i18next-http-backend i18next-browser-languagedetector---
Configuration
i18n/config.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import Backend from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector';
export const supportedLngs = ['en', 'de', 'fr', 'ar'] as const;
export type SupportedLocale = (typeof supportedLngs)[number];
export const defaultNS = 'common';
export const namespaces = ['common', 'auth', 'dashboard', 'validation'] as const;
i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
// Supported languages
supportedLngs,
fallbackLng: 'en',
// Namespaces
ns: namespaces,
defaultNS,
// Debug in development
debug: process.env.NODE_ENV === 'development',
// Interpolation
interpolation: {
escapeValue: false, // React already escapes
},
// Backend configuration
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
// Detection configuration
detection: {
order: ['querystring', 'cookie', 'localStorage', 'navigator', 'htmlTag'],
lookupQuerystring: 'lang',
lookupCookie: 'i18next',
lookupLocalStorage: 'i18nextLng',
caches: ['localStorage', 'cookie'],
},
// React specific
react: {
useSuspense: true,
},
});
export default i18n;i18n/resources.d.ts (TypeScript Types)
import common from '../locales/en/common.json';
import auth from '../locales/en/auth.json';
import dashboard from '../locales/en/dashboard.json';
import validation from '../locales/en/validation.json';
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'common';
resources: {
common: typeof common;
auth: typeof auth;
dashboard: typeof dashboard;
validation: typeof validation;
};
}
}i18n/index.ts
export { default } from './config';
export { supportedLngs, defaultNS, namespaces } from './config';
export type { SupportedLocale } from './config';---
Translation Files
locales/en/common.json
{
"app_name": "My Application",
"welcome": "Welcome, {{name}}!",
"loading": "Loading...",
"error": "An error occurred",
"retry": "Try again",
"nav": {
"home": "Home",
"dashboard": "Dashboard",
"settings": "Settings",
"logout": "Log out"
},
"actions": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"confirm": "Confirm"
},
"items_count": "{{count, number}} {{count, plural, one {item} other {items}}}"
}locales/en/auth.json
{
"login": {
"title": "Sign In",
"email_label": "Email Address",
"email_placeholder": "Enter your email",
"password_label": "Password",
"password_placeholder": "Enter your password",
"submit": "Sign In",
"forgot_password": "Forgot password?",
"no_account": "Don't have an account?",
"sign_up_link": "Sign up"
},
"register": {
"title": "Create Account",
"name_label": "Full Name",
"submit": "Create Account",
"has_account": "Already have an account?",
"sign_in_link": "Sign in"
},
"errors": {
"invalid_credentials": "Invalid email or password",
"email_in_use": "This email is already registered",
"weak_password": "Password is too weak"
}
}locales/en/validation.json
{
"required": "This field is required",
"email": {
"invalid": "Please enter a valid email address",
"required": "Email is required"
},
"password": {
"required": "Password is required",
"min_length": "Password must be at least {{min}} characters",
"mismatch": "Passwords do not match"
},
"name": {
"required": "Name is required",
"min_length": "Name must be at least {{min}} characters"
}
}---
Components
App.tsx
import { Suspense } from 'react';
import { useTranslation } from 'react-i18next';
import './i18n';
function AppContent() {
const { t, i18n } = useTranslation();
// Set document direction for RTL languages
const isRTL = ['ar', 'he', 'fa'].includes(i18n.language);
return (
<div dir={isRTL ? 'rtl' : 'ltr'}>
<header>
<h1>{t('app_name')}</h1>
<LanguageSwitcher />
</header>
<main>
{/* App content */}
</main>
</div>
);
}
export default function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<AppContent />
</Suspense>
);
}
function LoadingSpinner() {
return <div className="loading">Loading...</div>;
}components/LanguageSwitcher.tsx
import { useTranslation } from 'react-i18next';
import { supportedLngs, SupportedLocale } from '../i18n';
const languageNames: Record<SupportedLocale, string> = {
en: 'English',
de: 'Deutsch',
fr: 'Français',
ar: 'العربية',
};
export function LanguageSwitcher() {
const { i18n } = useTranslation();
const handleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
i18n.changeLanguage(event.target.value);
};
return (
<select
value={i18n.language}
onChange={handleChange}
aria-label="Select language"
>
{supportedLngs.map((lng) => (
<option key={lng} value={lng}>
{languageNames[lng]}
</option>
))}
</select>
);
}Namespace-Specific Hook Usage
import { useTranslation } from 'react-i18next';
// Dashboard component - loads dashboard namespace
function Dashboard() {
const { t } = useTranslation('dashboard');
return (
<div>
<h1>{t('title')}</h1>
<p>{t('welcome_message')}</p>
</div>
);
}
// Auth component - loads auth namespace
function LoginForm() {
const { t } = useTranslation('auth');
const { t: tValidation } = useTranslation('validation');
return (
<form>
<h1>{t('login.title')}</h1>
<input
type="email"
placeholder={t('login.email_placeholder')}
aria-label={t('login.email_label')}
/>
{/* Validation messages from validation namespace */}
<span className="error">{tValidation('email.invalid')}</span>
</form>
);
}Trans Component for Rich Text
import { Trans } from 'react-i18next';
function TermsNotice() {
return (
<p>
<Trans i18nKey="terms_notice" ns="common">
By continuing, you agree to our <a href="/terms">Terms of Service</a> and{' '}
<a href="/privacy">Privacy Policy</a>.
</Trans>
</p>
);
}// common.json
{
"terms_notice": "By continuing, you agree to our <0>Terms of Service</0> and <1>Privacy Policy</1>."
}---
Lazy Loading
Load Namespace on Demand
import { useTranslation } from 'react-i18next';
import { Suspense, lazy } from 'react';
// Lazy load dashboard component
const Dashboard = lazy(() => import('./Dashboard'));
function App() {
return (
<Suspense fallback={<Loading />}>
<Dashboard />
</Suspense>
);
}
// Dashboard.tsx
function Dashboard() {
// This will automatically load 'dashboard' namespace
const { t, ready } = useTranslation('dashboard');
if (!ready) return <Loading />;
return <h1>{t('title')}</h1>;
}Preload Namespaces
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
function App() {
const { i18n } = useTranslation();
useEffect(() => {
// Preload namespaces for better UX
i18n.loadNamespaces(['dashboard', 'settings']);
}, [i18n]);
return <AppContent />;
}---
Form Validation Integration
With React Hook Form
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
interface LoginFormData {
email: string;
password: string;
}
function LoginForm() {
const { t } = useTranslation('validation');
const { register, handleSubmit, formState: { errors } } = useForm<LoginFormData>();
const onSubmit = (data: LoginFormData) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register('email', {
required: t('email.required'),
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: t('email.invalid'),
},
})}
type="email"
/>
{errors.email && <span>{errors.email.message}</span>}
<input
{...register('password', {
required: t('password.required'),
minLength: {
value: 8,
message: t('password.min_length', { min: 8 }),
},
})}
type="password"
/>
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">Submit</button>
</form>
);
}---
Testing
Test Setup
// test/i18n-test-config.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
i18n.use(initReactI18next).init({
lng: 'en',
fallbackLng: 'en',
ns: ['common', 'auth'],
defaultNS: 'common',
resources: {
en: {
common: require('../locales/en/common.json'),
auth: require('../locales/en/auth.json'),
},
},
interpolation: {
escapeValue: false,
},
});
export default i18n;Component Test
// components/LanguageSwitcher.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import i18n from '../test/i18n-test-config';
import { LanguageSwitcher } from './LanguageSwitcher';
describe('LanguageSwitcher', () => {
it('switches language', async () => {
render(
<I18nextProvider i18n={i18n}>
<LanguageSwitcher />
</I18nextProvider>
);
const select = screen.getByRole('combobox');
fireEvent.change(select, { target: { value: 'de' } });
expect(i18n.language).toBe('de');
});
});---
Build Configuration
Vite
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks: {
// Separate i18n into its own chunk
i18n: ['i18next', 'react-i18next'],
},
},
},
},
});Copy Locales to Build
// package.json
{
"scripts": {
"build": "vite build && cp -r public/locales dist/locales"
}
}---
Checklist
- REQUIRED: Install dependencies
- REQUIRED: Create i18n configuration
- REQUIRED: Set up TypeScript types
- REQUIRED: Create namespace JSON files
- REQUIRED: Add LanguageSwitcher component
- REQUIRED: Wrap app with Suspense (if using i18next-http-backend)
- REQUIRED: Handle RTL direction
- REQUIRED: Configure lazy loading
- REQUIRED: Set up test configuration
- REQUIRED: Configure build to include locales
Vue 3 + vue-i18n Complete Setup
Production-ready i18n setup for Vue 3 applications with Composition API, TypeScript, and lazy loading.
---
Project Structure
src/
├── i18n/
│ ├── index.ts # vue-i18n configuration
│ └── types.ts # TypeScript types
├── locales/
│ ├── en.json # English translations
│ ├── de.json # German translations
│ └── ar.json # Arabic translations
├── composables/
│ └── useLocale.ts # Locale utilities
├── components/
│ └── LanguageSwitcher.vue
└── App.vue---
Installation
npm install vue-i18n---
Configuration
i18n/index.ts
import { createI18n } from 'vue-i18n';
import type { I18nOptions } from 'vue-i18n';
// Import default locale eagerly
import en from '../locales/en.json';
export const SUPPORTED_LOCALES = ['en', 'de', 'fr', 'ar'] as const;
export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
export const RTL_LOCALES: SupportedLocale[] = ['ar'];
// Lazy load other locales
const localeMessages: Record<string, () => Promise<{ default: Record<string, unknown> }>> = {
de: () => import('../locales/de.json'),
fr: () => import('../locales/fr.json'),
ar: () => import('../locales/ar.json'),
};
export async function loadLocaleMessages(locale: SupportedLocale): Promise<void> {
if (locale === 'en') return; // Already loaded
const messages = await localeMessages[locale]();
i18n.global.setLocaleMessage(locale, messages.default);
}
const options: I18nOptions = {
legacy: false, // Use Composition API
locale: 'en',
fallbackLocale: 'en',
messages: { en },
missingWarn: process.env.NODE_ENV === 'development',
fallbackWarn: process.env.NODE_ENV === 'development',
};
export const i18n = createI18n(options);
export default i18n;i18n/types.ts
import type en from '../locales/en.json';
// Type-safe message keys
export type MessageSchema = typeof en;
declare module 'vue-i18n' {
export interface DefineLocaleMessage extends MessageSchema {}
}---
Translation Files
locales/en.json
{
"app": {
"name": "My Application",
"loading": "Loading..."
},
"nav": {
"home": "Home",
"dashboard": "Dashboard",
"settings": "Settings",
"logout": "Log out"
},
"auth": {
"login": {
"title": "Sign In",
"email": "Email Address",
"password": "Password",
"submit": "Sign In",
"forgot": "Forgot password?"
},
"register": {
"title": "Create Account",
"name": "Full Name",
"submit": "Create Account"
}
},
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit"
},
"messages": {
"welcome": "Welcome, {name}!",
"items": "{count} {count, plural, one {item} other {items}}"
},
"validation": {
"required": "This field is required",
"email": "Please enter a valid email",
"minLength": "Must be at least {min} characters"
}
}locales/de.json
{
"app": {
"name": "Meine Anwendung",
"loading": "Laden..."
},
"nav": {
"home": "Startseite",
"dashboard": "Dashboard",
"settings": "Einstellungen",
"logout": "Abmelden"
},
"auth": {
"login": {
"title": "Anmelden",
"email": "E-Mail-Adresse",
"password": "Passwort",
"submit": "Anmelden",
"forgot": "Passwort vergessen?"
},
"register": {
"title": "Konto erstellen",
"name": "Vollständiger Name",
"submit": "Konto erstellen"
}
},
"common": {
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"edit": "Bearbeiten"
},
"messages": {
"welcome": "Willkommen, {name}!",
"items": "{count} {count, plural, one {Artikel} other {Artikel}}"
},
"validation": {
"required": "Dieses Feld ist erforderlich",
"email": "Bitte geben Sie eine gültige E-Mail ein",
"minLength": "Mindestens {min} Zeichen erforderlich"
}
}---
Composables
composables/useLocale.ts
import { computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import {
SUPPORTED_LOCALES,
RTL_LOCALES,
loadLocaleMessages,
type SupportedLocale,
} from '../i18n';
export function useLocale() {
const { locale, availableLocales } = useI18n();
const currentLocale = computed(() => locale.value as SupportedLocale);
const isRTL = computed(() => RTL_LOCALES.includes(currentLocale.value));
const setLocale = async (newLocale: SupportedLocale) => {
if (!SUPPORTED_LOCALES.includes(newLocale)) {
console.warn(`Locale ${newLocale} is not supported`);
return;
}
// Load locale messages if not already loaded
await loadLocaleMessages(newLocale);
// Update locale
locale.value = newLocale;
// Persist preference
localStorage.setItem('preferredLocale', newLocale);
// Update document attributes
document.documentElement.lang = newLocale;
document.documentElement.dir = isRTL.value ? 'rtl' : 'ltr';
};
// Watch for locale changes
watch(
currentLocale,
(newLocale) => {
document.documentElement.lang = newLocale;
document.documentElement.dir = RTL_LOCALES.includes(newLocale) ? 'rtl' : 'ltr';
},
{ immediate: true }
);
return {
currentLocale,
isRTL,
setLocale,
supportedLocales: SUPPORTED_LOCALES,
availableLocales,
};
}---
Components
main.ts
import { createApp } from 'vue';
import App from './App.vue';
import i18n from './i18n';
const app = createApp(App);
app.use(i18n);
app.mount('#app');App.vue
<script setup lang="ts">
import { useLocale } from './composables/useLocale';
import LanguageSwitcher from './components/LanguageSwitcher.vue';
const { isRTL } = useLocale();
</script>
<template>
<div :dir="isRTL ? 'rtl' : 'ltr'">
<header>
<h1>{{ $t('app.name') }}</h1>
<LanguageSwitcher />
</header>
<main>
<router-view />
</main>
</div>
</template>components/LanguageSwitcher.vue
<script setup lang="ts">
import { useLocale } from '../composables/useLocale';
import type { SupportedLocale } from '../i18n';
const { currentLocale, setLocale, supportedLocales } = useLocale();
const languageNames: Record<SupportedLocale, string> = {
en: 'English',
de: 'Deutsch',
fr: 'Français',
ar: 'العربية',
};
const handleChange = async (event: Event) => {
const target = event.target as HTMLSelectElement;
await setLocale(target.value as SupportedLocale);
};
</script>
<template>
<select
:value="currentLocale"
@change="handleChange"
aria-label="Select language"
>
<option v-for="locale in supportedLocales" :key="locale" :value="locale">
{{ languageNames[locale] }}
</option>
</select>
</template>---
Usage Examples
Basic Usage
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>
<template>
<div>
<h1>{{ t('auth.login.title') }}</h1>
<p>{{ t('messages.welcome', { name: 'Alice' }) }}</p>
</div>
</template>Pluralisation
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { ref } from 'vue';
const { t } = useI18n();
const count = ref(5);
</script>
<template>
<p>{{ t('messages.items', { count }) }}</p>
<!-- Output: "5 items" -->
</template>Per-Component Messages
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
const { t } = useI18n({
useScope: 'local',
messages: {
en: {
greeting: 'Hello from component!',
},
de: {
greeting: 'Hallo von der Komponente!',
},
},
});
</script>
<template>
<p>{{ t('greeting') }}</p>
</template>DateTime and Number Formatting
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
const { d, n, locale } = useI18n();
const date = new Date();
const price = 1234.56;
</script>
<template>
<div>
<p>Date: {{ d(date, 'long') }}</p>
<p>Price: {{ n(price, 'currency', { currency: 'EUR' }) }}</p>
</div>
</template>---
Vue Router Integration
router/index.ts
import { createRouter, createWebHistory } from 'vue-router';
import { loadLocaleMessages, SUPPORTED_LOCALES, i18n } from '../i18n';
import type { SupportedLocale } from '../i18n';
const routes = [
{
path: '/:locale',
beforeEnter: async (to) => {
const locale = to.params.locale as SupportedLocale;
// Validate locale
if (!SUPPORTED_LOCALES.includes(locale)) {
return `/${i18n.global.locale.value}`;
}
// Load locale if not already loaded
await loadLocaleMessages(locale);
i18n.global.locale.value = locale;
},
children: [
{ path: '', name: 'home', component: () => import('../views/Home.vue') },
{ path: 'dashboard', name: 'dashboard', component: () => import('../views/Dashboard.vue') },
],
},
{
path: '/',
redirect: () => `/${i18n.global.locale.value}`,
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
export default router;Locale-Aware Navigation
<script setup lang="ts">
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
const router = useRouter();
const { locale } = useI18n();
const navigateTo = (path: string) => {
router.push(`/${locale.value}${path}`);
};
</script>
<template>
<nav>
<a @click="navigateTo('/')">{{ $t('nav.home') }}</a>
<a @click="navigateTo('/dashboard')">{{ $t('nav.dashboard') }}</a>
</nav>
</template>---
Form Validation with VeeValidate
<script setup lang="ts">
import { useForm, useField } from 'vee-validate';
import { useI18n } from 'vue-i18n';
import * as yup from 'yup';
const { t } = useI18n();
// Create schema with translated messages
const schema = yup.object({
email: yup
.string()
.required(t('validation.required'))
.email(t('validation.email')),
password: yup
.string()
.required(t('validation.required'))
.min(8, t('validation.minLength', { min: 8 })),
});
const { handleSubmit } = useForm({ validationSchema: schema });
const { value: email, errorMessage: emailError } = useField('email');
const { value: password, errorMessage: passwordError } = useField('password');
const onSubmit = handleSubmit((values) => {
console.log(values);
});
</script>
<template>
<form @submit="onSubmit">
<div>
<label>{{ t('auth.login.email') }}</label>
<input v-model="email" type="email" />
<span v-if="emailError" class="error">{{ emailError }}</span>
</div>
<div>
<label>{{ t('auth.login.password') }}</label>
<input v-model="password" type="password" />
<span v-if="passwordError" class="error">{{ passwordError }}</span>
</div>
<button type="submit">{{ t('auth.login.submit') }}</button>
</form>
</template>---
Vite Configuration
vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import vueI18n from '@intlify/vite-plugin-vue-i18n';
import { resolve } from 'path';
export default defineConfig({
plugins: [
vue(),
vueI18n({
include: resolve(__dirname, './src/locales/**'),
strictMessage: false,
}),
],
});---
Testing
Test Setup
// test/setup.ts
import { config } from '@vue/test-utils';
import { createI18n } from 'vue-i18n';
import en from '../src/locales/en.json';
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en },
});
config.global.plugins = [i18n];Component Test
// components/LanguageSwitcher.spec.ts
import { mount } from '@vue/test-utils';
import { createI18n } from 'vue-i18n';
import LanguageSwitcher from './LanguageSwitcher.vue';
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {}, de: {} },
});
describe('LanguageSwitcher', () => {
it('renders language options', () => {
const wrapper = mount(LanguageSwitcher, {
global: { plugins: [i18n] },
});
const options = wrapper.findAll('option');
expect(options.length).toBeGreaterThan(0);
});
});---
Checklist
- REQUIRED: Install vue-i18n
- REQUIRED: Create i18n configuration with Composition API
- REQUIRED: Set up TypeScript types
- REQUIRED: Create locale JSON files
- REQUIRED: Create
useLocalecomposable - REQUIRED: Add LanguageSwitcher component
- REQUIRED: Handle RTL direction
- REQUIRED: Integrate with Vue Router (if routing locales)
- REQUIRED: Configure Vite plugin (if using i18n resource transforms)
- REQUIRED: Set up test configuration
{
"metadata": {
"skill": "software-localisation",
"updated": "2026-01-17",
"total_sources": 61,
"description": "Curated resources for i18n/l10n in modern web applications covering libraries, standards, TMS platforms, AI translation, and best practices."
},
"categories": {
"official_documentation": [
{
"name": "i18next Documentation",
"url": "https://www.i18next.com/",
"type": "documentation",
"relevance": "Primary i18n framework documentation. Covers configuration, plugins, and integration patterns.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "react-i18next Documentation",
"url": "https://react.i18next.com/",
"type": "documentation",
"relevance": "React-specific i18next bindings. Hooks, components, and SSR patterns.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "FormatJS / react-intl",
"url": "https://formatjs.github.io/",
"type": "documentation",
"relevance": "ICU-focused i18n library. Official react-intl documentation and ICU message syntax.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "vue-i18n Documentation",
"url": "https://vue-i18n.intlify.dev/",
"type": "documentation",
"relevance": "Official Vue 3 i18n solution. Composition API integration and tooling.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "Angular Internationalization",
"url": "https://angular.dev/guide/i18n",
"type": "documentation",
"relevance": "Official Angular i18n guide. @angular/localize setup and AOT compilation.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "next-intl Documentation",
"url": "https://next-intl-docs.vercel.app/",
"type": "documentation",
"relevance": "Next.js App Router i18n. Server components, middleware, and static generation.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "LinguiJS Documentation",
"url": "https://lingui.dev/",
"type": "documentation",
"relevance": "Minimal bundle i18n library. ICU syntax with React, Vue, and Svelte support.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "typesafe-i18n Documentation",
"url": "https://github.com/ivanhofer/typesafe-i18n",
"type": "documentation",
"relevance": "TypeScript-first i18n with compile-time type checking. Minimal runtime.",
"update_frequency": "monthly",
"access": "free",
"add_as_web_search": false
}
],
"standards_specifications": [
{
"name": "ICU Message Format",
"url": "https://unicode-org.github.io/icu/userguide/format_parse/messages/",
"type": "specification",
"relevance": "Canonical ICU message syntax specification. Pluralisation, select, formatting.",
"update_frequency": "quarterly",
"access": "free",
"add_as_web_search": true
},
{
"name": "Unicode CLDR",
"url": "https://cldr.unicode.org/",
"type": "specification",
"relevance": "Common Locale Data Repository. Plural rules, date/number formats by locale.",
"update_frequency": "quarterly",
"access": "free",
"add_as_web_search": false
},
{
"name": "BCP 47 Language Tags",
"url": "https://www.rfc-editor.org/info/bcp47",
"type": "specification",
"relevance": "IETF standard for language tags (en-US, de-DE). Used in Accept-Language headers.",
"update_frequency": "static",
"access": "free",
"add_as_web_search": false
},
{
"name": "MDN Intl API",
"url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl",
"type": "reference",
"relevance": "JavaScript Internationalization API. DateTimeFormat, NumberFormat, Collator.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "ECMAScript Internationalization API",
"url": "https://tc39.es/ecma402/",
"type": "specification",
"relevance": "ECMA-402 specification. Defines Intl object and locale-sensitive operations.",
"update_frequency": "quarterly",
"access": "free",
"add_as_web_search": false
}
],
"tms_platforms": [
{
"name": "Phrase (formerly Memsource)",
"url": "https://phrase.com/",
"type": "tool",
"relevance": "Enterprise TMS with developer tools. GitHub/GitLab integration, CLI, API.",
"update_frequency": "continuous",
"access": "paid",
"add_as_web_search": true
},
{
"name": "Lokalise",
"url": "https://lokalise.com/",
"type": "tool",
"relevance": "Developer-friendly TMS. Figma plugin, GitHub integration, collaborative translation.",
"update_frequency": "continuous",
"access": "paid",
"add_as_web_search": true
},
{
"name": "Crowdin",
"url": "https://crowdin.com/",
"type": "tool",
"relevance": "Community and enterprise TMS. Open source free tier, crowdsourcing support.",
"update_frequency": "continuous",
"access": "freemium",
"add_as_web_search": true
},
{
"name": "Transifex",
"url": "https://www.transifex.com/",
"type": "tool",
"relevance": "Localization platform with API-first approach. CI/CD integration, string detection.",
"update_frequency": "continuous",
"access": "paid",
"add_as_web_search": false
},
{
"name": "POEditor",
"url": "https://poeditor.com/",
"type": "tool",
"relevance": "Simple TMS for smaller teams. GitHub integration, reasonable pricing.",
"update_frequency": "continuous",
"access": "freemium",
"add_as_web_search": false
},
{
"name": "Locize",
"url": "https://locize.com/",
"type": "tool",
"relevance": "i18next-native TMS by i18next creators. Real-time sync, versioning.",
"update_frequency": "continuous",
"access": "paid",
"add_as_web_search": false
}
],
"guides_tutorials": [
{
"name": "W3C i18n Best Practices for Spec Developers",
"url": "https://w3c.github.io/bp-i18n-specdev/",
"type": "specification",
"relevance": "W3C guidelines for internationalisation in web specifications. Authoritative reference for standards compliance.",
"update_frequency": "quarterly",
"access": "free",
"add_as_web_search": true
},
{
"name": "Shopify i18n Best Practices for Front-End",
"url": "https://shopify.engineering/internationalization-i18n-best-practices-front-end-developers",
"type": "guide",
"relevance": "Enterprise i18n patterns from Shopify engineering. UTF-8 encoding, text expansion, concatenation avoidance.",
"update_frequency": "static",
"access": "free",
"add_as_web_search": true
},
{
"name": "Lingui vs i18next Comparison",
"url": "https://lingui.dev/misc/i18next",
"type": "guide",
"relevance": "Official LinguiJS comparison with i18next. Bundle size, TypeScript, extraction differences.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "react-i18next vs react-intl Comparison",
"url": "https://www.locize.com/blog/react-intl-vs-react-i18next",
"type": "guide",
"relevance": "Detailed comparison by i18next maintainers. Bundle size, features, use cases.",
"update_frequency": "static",
"access": "free",
"add_as_web_search": true
},
{
"name": "Best React i18n Libraries - Phrase",
"url": "https://phrase.com/blog/posts/react-i18n-best-libraries/",
"type": "guide",
"relevance": "Comprehensive React i18n library comparison with code examples.",
"update_frequency": "quarterly",
"access": "free",
"add_as_web_search": true
},
{
"name": "ICU Message Format Guide - Phrase",
"url": "https://phrase.com/blog/posts/guide-to-the-icu-message-format/",
"type": "guide",
"relevance": "Practical ICU syntax guide with pluralisation and select examples.",
"update_frequency": "static",
"access": "free",
"add_as_web_search": true
},
{
"name": "ICU Message Format Guide - Lokalise",
"url": "https://lokalise.com/blog/complete-guide-to-icu-message-format/",
"type": "guide",
"relevance": "Complete ICU guide with CLDR plural rules and escaping patterns.",
"update_frequency": "static",
"access": "free",
"add_as_web_search": false
},
{
"name": "TypeScript i18n Best Practices",
"url": "https://caisy.io/blog/typescript-i18n",
"type": "guide",
"relevance": "Type-safe i18n patterns in TypeScript. Type generation and validation.",
"update_frequency": "static",
"access": "free",
"add_as_web_search": false
},
{
"name": "Angular Localization Guide - Centus",
"url": "https://centus.com/blog/angular-localization",
"type": "guide",
"relevance": "Comprehensive Angular i18n setup with @angular/localize and alternatives.",
"update_frequency": "static",
"access": "free",
"add_as_web_search": false
},
{
"name": "i18n Benefits and Best Practices - Smartling",
"url": "https://www.smartling.com/blog/i18n",
"type": "guide",
"relevance": "Business case for i18n. Planning strategies and workflow optimization.",
"update_frequency": "static",
"access": "free",
"add_as_web_search": false
},
{
"name": "ICU Message Formats Best Practices - Lingoport",
"url": "https://lingoport.com/blog/mastering-icu-message-formats-best-practices-and-pitfalls-to-avoid/",
"type": "guide",
"relevance": "ICU pitfalls and enterprise patterns. Nested arguments and translator-friendly syntax.",
"update_frequency": "static",
"access": "free",
"add_as_web_search": false
}
],
"tools_cli": [
{
"name": "i18next-parser",
"url": "https://github.com/i18next/i18next-parser",
"type": "tool",
"relevance": "Extract translation keys from code. Supports React, Vue, TypeScript.",
"update_frequency": "monthly",
"access": "free",
"add_as_web_search": false
},
{
"name": "@formatjs/cli",
"url": "https://formatjs.github.io/docs/tooling/cli",
"type": "tool",
"relevance": "FormatJS extraction and compilation. ICU message validation.",
"update_frequency": "monthly",
"access": "free",
"add_as_web_search": false
},
{
"name": "Lingui CLI",
"url": "https://lingui.dev/ref/cli",
"type": "tool",
"relevance": "LinguiJS string extraction and compilation. Catalog management.",
"update_frequency": "monthly",
"access": "free",
"add_as_web_search": false
},
{
"name": "i18next-http-backend",
"url": "https://github.com/i18next/i18next-http-backend",
"type": "library",
"relevance": "Load translations from backend/CDN. Lazy loading support.",
"update_frequency": "monthly",
"access": "free",
"add_as_web_search": false
},
{
"name": "i18next-browser-languagedetector",
"url": "https://github.com/i18next/i18next-browser-languageDetector",
"type": "library",
"relevance": "Browser locale detection. Cookie, localStorage, navigator, query string.",
"update_frequency": "monthly",
"access": "free",
"add_as_web_search": false
}
],
"vscode_extensions": [
{
"name": "i18n Ally",
"url": "https://marketplace.visualstudio.com/items?itemName=Lokalise.i18n-ally",
"type": "tool",
"relevance": "VS Code extension for i18n. Inline annotations, auto-extraction, framework detection.",
"update_frequency": "monthly",
"access": "free",
"add_as_web_search": false
},
{
"name": "vscode-i18next",
"url": "https://marketplace.visualstudio.com/items?itemName=AdrienDeperetti.vscode-i18next",
"type": "tool",
"relevance": "i18next key navigation and inline preview in VS Code.",
"update_frequency": "quarterly",
"access": "free",
"add_as_web_search": false
}
],
"rtl_resources": [
{
"name": "RTL Styling 101",
"url": "https://rtlstyling.com/",
"type": "guide",
"relevance": "Comprehensive RTL CSS guide. Logical properties, BiDi, testing strategies.",
"update_frequency": "static",
"access": "free",
"add_as_web_search": true
},
{
"name": "MDN CSS Logical Properties",
"url": "https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_logical_properties_and_values",
"type": "reference",
"relevance": "CSS logical properties reference. margin-inline-start, padding-block-end.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": false
},
{
"name": "Material Design Bidirectionality",
"url": "https://m2.material.io/design/usability/bidirectionality.html",
"type": "guide",
"relevance": "Google Material Design RTL guidelines. Icons, mirroring, layout patterns.",
"update_frequency": "static",
"access": "free",
"add_as_web_search": false
}
],
"testing": [
{
"name": "Testing Library i18n Guide",
"url": "https://testing-library.com/docs/react-testing-library/setup#configuring-jest-with-test-utils",
"type": "guide",
"relevance": "Testing React components with i18n. Provider wrapping patterns.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": false
},
{
"name": "Pseudolocalization",
"url": "https://phrase.com/blog/posts/pseudolocalization/",
"type": "guide",
"relevance": "Testing i18n readiness without real translations. Accent characters, expansion.",
"update_frequency": "static",
"access": "free",
"add_as_web_search": false
}
],
"framework_specific": [
{
"name": "Next.js Internationalization Docs",
"url": "https://nextjs.org/docs/app/building-your-application/routing/internationalization",
"type": "documentation",
"relevance": "Official Next.js i18n routing. App Router patterns and middleware.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "Nuxt i18n Module",
"url": "https://i18n.nuxtjs.org/",
"type": "documentation",
"relevance": "Official Nuxt.js i18n module. Auto-routing, SEO, lazy loading.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": false
},
{
"name": "Remix i18n Guide",
"url": "https://remix.run/docs/en/main/guides/i18n",
"type": "documentation",
"relevance": "Remix framework i18n patterns. Loader-based translation loading.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": false
}
],
"npm_comparisons": [
{
"name": "npm compare: react-i18next vs react-intl",
"url": "https://npm-compare.com/react-i18next,react-intl",
"type": "reference",
"relevance": "Live npm download stats and comparison. Bundle size, popularity trends.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": false
},
{
"name": "Bundlephobia: i18next",
"url": "https://bundlephobia.com/package/i18next",
"type": "reference",
"relevance": "Bundle size analysis for i18next and dependencies.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": false
}
],
"ai_translation": [
{
"name": "i18n-ai-translate",
"url": "https://github.com/taahamahdi/i18n-ai-translate",
"type": "tool",
"relevance": "AI-powered translation for i18next JSON using ChatGPT, Gemini, Claude, or local Ollama. Preserves file structure and variables.",
"update_frequency": "monthly",
"access": "free",
"add_as_web_search": false
},
{
"name": "i18nexus",
"url": "https://i18nexus.com/",
"type": "tool",
"relevance": "AI translation management platform for React and Next.js. Automated workflows and CI/CD integration.",
"update_frequency": "continuous",
"access": "freemium",
"add_as_web_search": true
},
{
"name": "Locize Blog - What is i18n (2026)",
"url": "https://www.locize.com/blog/what-is-i18n/",
"type": "guide",
"relevance": "Modern i18n practices including AI translation, RSC patterns, and metadata for context.",
"update_frequency": "quarterly",
"access": "free",
"add_as_web_search": true
},
{
"name": "AI Translation Trends 2026",
"url": "https://www.machinetranslation.com/blog/best-trends-in-ai-translation-2026",
"type": "guide",
"relevance": "Industry trends including consensus-based translation, edge translation, and domain-specific models.",
"update_frequency": "annual",
"access": "free",
"add_as_web_search": true
},
{
"name": "Forrester Wave TMS Q3 2025",
"url": "https://www.forrester.com/blogs/announcing-the-first-forrester-wave-translation-management-systems-q3-2025/",
"type": "report",
"relevance": "Enterprise TMS market evaluation covering Phrase, Lokalise, Crowdin, and 9 other vendors.",
"update_frequency": "annual",
"access": "gated",
"add_as_web_search": true
},
{
"name": "MessageFormat 2.0 Documentation",
"url": "https://unicode-org.github.io/icu/userguide/format_parse/messages/mf2.html",
"type": "specification",
"relevance": "ICU MessageFormat 2.0 specification (tech preview). New syntax for future adoption.",
"update_frequency": "quarterly",
"access": "free",
"add_as_web_search": false
}
]
}
}
Accessibility and Internationalisation
Patterns for building applications that are both accessible and multilingual. Covers screen reader behaviour across languages, ARIA in multilingual contexts, bidirectional text accessibility, dynamic type across scripts, keyboard navigation for RTL, and WCAG requirements for multilingual content.
---
Why This Intersection Matters
Accessibility and internationalisation are often treated as separate concerns, but they interact in ways that can create compounding failures. An app that passes WCAG in English may be unusable with a screen reader in Arabic, or illegible in Hindi at larger font sizes. Testing each in isolation misses the overlap.
| Overlap Area | a11y Concern | i18n Concern | Combined Risk |
|---|---|---|---|
| Screen readers | Reading order, labels | RTL direction, language | Wrong reading order in RTL |
| Font scaling | Dynamic Type, zoom | Script height variance | Clipped text in tall scripts |
| Keyboard nav | Tab order, focus | RTL reversal | Backwards navigation in RTL |
| Forms | Labels, errors, ARIA | Translated messages | Untranslated error messages |
| Colour | Contrast ratios | Cultural colour meaning | Inaccessible + culturally wrong |
---
Screen Readers and RTL Languages
Screen Reader Behaviour by Platform
| Screen Reader | Platform | RTL Support | Notes |
|---|---|---|---|
| VoiceOver | iOS/macOS | Excellent | Follows lang and dir attributes accurately |
| NVDA | Windows | Good | Reads RTL correctly when dir="rtl" is set |
| JAWS | Windows | Good | Best when lang attribute matches content |
| TalkBack | Android | Good | Follows system language and dir attribute |
| Narrator | Windows | Adequate | Improving; test with each Windows update |
Critical: The lang Attribute
Screen readers use the lang attribute to select the correct pronunciation engine. Missing or wrong lang causes Arabic text to be read with English phonetics.
<!-- CORRECT: Language declared at document level -->
<html lang="ar" dir="rtl">
<!-- CORRECT: Inline language switch for mixed content -->
<p lang="ar">مرحبا <span lang="en">React</span> عالم</p>
<!-- WRONG: No lang attribute — screen reader guesses -->
<html>
<body dir="rtl">...</body>
</html>hreflang for Alternate Language Links
<!-- Help screen readers announce language alternatives -->
<link rel="alternate" hreflang="en" href="/en/about" />
<link rel="alternate" hreflang="ar" href="/ar/about" />
<link rel="alternate" hreflang="ja" href="/ja/about" />
<link rel="alternate" hreflang="x-default" href="/en/about" />VoiceOver Behaviour with RTL
VoiceOver on iOS and macOS:
- Reads right-to-left when
dir="rtl"is set - Swipe right moves to the next element (visually left in RTL)
- Numbers embedded in RTL text are read left-to-right (correct)
- Punctuation follows the base paragraph direction
// iOS: Set accessibility language explicitly
label.accessibilityLanguage = "ar"
// SwiftUI
Text("مرحبا بالعالم")
.accessibilityLanguage(Locale(identifier: "ar"))---
ARIA Labels in Multiple Languages
Translating ARIA Attributes
All ARIA text content must be translated. This includes aria-label, aria-placeholder, aria-description, and aria-roledescription.
// WRONG: ARIA label hardcoded in English on Arabic page
<button aria-label="Close dialog">×</button>
// CORRECT: ARIA label translated
<button aria-label={t('dialog.close')}>×</button>Common ARIA Attributes Requiring Translation
| Attribute | Purpose | Translation Required |
|---|---|---|
aria-label | Accessible name | Yes — always |
aria-placeholder | Input placeholder | Yes — always |
aria-description | Extended description | Yes — always |
aria-roledescription | Custom role name | Yes — always |
aria-valuetext | Slider/progress text | Yes — always |
aria-live | Live region type | No — keyword value |
aria-expanded | Expansion state | No — boolean |
role | Element role | No — keyword value |
Language-Specific ARIA Patterns
// Navigation landmark with translated label
<nav aria-label={t('nav.main')}>
{/* ... */}
</nav>
// Form with translated error messages
<input
aria-invalid={hasError}
aria-errormessage={hasError ? 'email-error' : undefined}
/>
<span id="email-error" role="alert" lang={locale}>
{t('form.email.invalid')}
</span>
// Live region announces in the correct language
<div aria-live="polite" lang={locale}>
{statusMessage}
</div>---
Bidirectional Text Accessibility
BiDi Marks and Isolation
When mixing LTR and RTL text, use Unicode BiDi marks or the <bdi> element to prevent garbled reading order.
| Mark | Unicode | Purpose |
|---|---|---|
| LRM | U+200E | Forces left-to-right at insertion point |
| RLM | U+200F | Forces right-to-left at insertion point |
| LRI | U+2066 | Left-to-right isolate (start) |
| RLI | U+2067 | Right-to-left isolate (start) |
| PDI | U+2069 | Pop directional isolate (end) |
HTML bdi Element
<!-- User-generated content with unknown direction -->
<p lang="ar">
المستخدم <bdi>@john_doe</bdi> أرسل رسالة
</p>
<!-- Without bdi: @john_doe may display incorrectly in RTL context -->Screen Reader Impact
Screen readers interpret BiDi marks as direction changes. Excessive or incorrect marks cause:
- Pauses and stuttering during reading
- Wrong reading order for numbers and punctuation
- Confusion when navigating by character
// CORRECT: Use CSS direction isolation instead of Unicode marks when possible
// CSS approach is cleaner for screen readers
function UserMention({ username }: { username: string }) {
return (
<bdi className="inline-block" dir="ltr">
@{username}
</bdi>
);
}ICU Messages and BiDi
// ICU messages with mixed-direction interpolation
// Use Unicode isolates around interpolated values
"greeting": "مرحبا \u2068{name}\u2069!"---
Dynamic Type and Font Scaling Across Scripts
Script Height Variance
Different scripts have different natural heights and line spacing requirements. A font size that works for Latin may clip Devanagari or be tiny in CJK.
| Script | Baseline Height | Ascender/Descender | Min Line Height |
|---|---|---|---|
| Latin | 1x | Moderate | 1.4-1.5 |
| Arabic | 1.1-1.3x | Tall ascenders | 1.6-1.8 |
| Devanagari | 1.2-1.4x | Headline + descenders | 1.7-2.0 |
| CJK | 1x (square) | Uniform | 1.5-1.7 |
| Thai | 1.3-1.5x | Tall stacking marks | 1.8-2.0 |
iOS Dynamic Type with Multilingual Fonts
// Use system font — it automatically selects the correct script variant
let label = UILabel()
label.font = UIFont.preferredFont(forTextStyle: .body)
label.adjustsFontForContentSizeCategory = true
// For custom fonts, provide script-specific variants
extension UIFont {
static func customFont(
forTextStyle style: UIFont.TextStyle,
locale: Locale
) -> UIFont {
let metrics = UIFontMetrics(forTextStyle: style)
let baseFont: UIFont
switch locale.script?.scriptCode {
case "Arab":
baseFont = UIFont(name: "CustomArabic", size: 17)!
case "Deva":
baseFont = UIFont(name: "CustomDevanagari", size: 17)!
default:
baseFont = UIFont(name: "CustomLatin", size: 16)!
}
return metrics.scaledFont(for: baseFont)
}
}CSS Font Scaling for Multilingual
/* Base responsive text */
body {
font-size: clamp(1rem, 1rem + 0.5vw, 1.25rem);
line-height: 1.5;
}
/* Arabic needs more line height */
:lang(ar), :lang(fa), :lang(ur) {
line-height: 1.8;
font-size: 1.1em; /* Slightly larger for readability */
}
/* Devanagari needs even more */
:lang(hi), :lang(mr), :lang(ne) {
line-height: 2.0;
}
/* CJK: uniform height, tighter letter spacing */
:lang(ja), :lang(zh), :lang(ko) {
line-height: 1.7;
letter-spacing: 0.02em;
}---
Colour and Contrast Across Cultural Contexts
Cultural Colour Associations
| Colour | Western | East Asian | Middle Eastern | South Asian |
|---|---|---|---|---|
| Red | Danger, stop | Luck, prosperity | Danger | Purity (vermillion) |
| White | Purity, clean | Death, mourning | Purity | Death, mourning |
| Green | Nature, go | Youth | Islam, paradise | Fertility |
| Black | Death, formal | Power | Death | Evil |
| Yellow | Caution | Imperial, sacred | Happiness | Sacred (saffron) |
Contrast Requirements Are Universal
WCAG contrast ratios apply regardless of script or language:
| Level | Normal Text | Large Text | UI Components |
|---|---|---|---|
| AA | 4.5:1 | 3:1 | 3:1 |
| AAA | 7:1 | 4.5:1 | 4.5:1 |
/* Test contrast with different scripts — some fonts render thinner */
/* Arabic calligraphic fonts may need higher contrast than Latin */
.arabic-body {
color: #1a1a1a; /* Darker than typical #333 for thin strokes */
background: #ffffff;
/* Contrast ratio: 16.3:1 — well above AA */
}---
Keyboard Navigation for RTL Interfaces
Tab Order in RTL
The DOM order determines tab order, not visual order. In RTL layouts where CSS changes visual position, tab order may become confusing.
<!-- DOM order matches visual RTL order -->
<nav dir="rtl">
<!-- Tab: right → left (matches RTL visual flow) -->
<a href="/ar/home">الرئيسية</a> <!-- Tab 1: rightmost -->
<a href="/ar/about">عن الموقع</a> <!-- Tab 2 -->
<a href="/ar/contact">اتصل بنا</a> <!-- Tab 3: leftmost -->
</nav>Arrow Key Behaviour
| Key | LTR Context | RTL Context |
|---|---|---|
| Left Arrow | Previous item | Next item |
| Right Arrow | Next item | Previous item |
| Home | First item | First item (rightmost) |
| End | Last item | Last item (leftmost) |
// Handle arrow keys in RTL-aware component
function handleKeyDown(event: KeyboardEvent, isRTL: boolean) {
const forward = isRTL ? 'ArrowLeft' : 'ArrowRight';
const backward = isRTL ? 'ArrowRight' : 'ArrowLeft';
switch (event.key) {
case forward:
focusNext();
break;
case backward:
focusPrevious();
break;
}
}---
Form Labels and Error Messages in Localised Contexts
Label Association
// Always use explicit label association — never rely on visual proximity
<div>
<label htmlFor="email">{t('form.email.label')}</label>
<input
id="email"
type="email"
dir="ltr" // Email addresses are always LTR
aria-describedby="email-hint"
aria-errormessage={errors.email ? 'email-error' : undefined}
aria-invalid={!!errors.email}
/>
<span id="email-hint">{t('form.email.hint')}</span>
{errors.email && (
<span id="email-error" role="alert">
{t(errors.email.messageKey)}
</span>
)}
</div>Error Message Translation Patterns
// WRONG: Hardcoded error messages in validation schema
const schema = z.object({
email: z.string().email('Invalid email address'),
});
// CORRECT: Use message keys, translate at render time
const schema = z.object({
email: z.string().email({ message: 'form.email.invalid' }),
});
// In component
{errors.email && (
<span role="alert">{t(errors.email.message)}</span>
)}---
Input Method Editor (IME) Accessibility
IME for CJK Languages
CJK input requires an Input Method Editor that converts keystrokes into characters through a composition window.
| Platform | IME | Languages |
|---|---|---|
| macOS/iOS | Built-in | Japanese, Chinese, Korean |
| Windows | Microsoft IME, Google IME | Japanese, Chinese, Korean |
| Android | Gboard, Samsung Keyboard | CJK + Indic scripts |
| Linux | IBus, Fcitx | CJK + Indic scripts |
Composition Events
// Handle IME composition correctly
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
const [isComposing, setIsComposing] = useState(false);
return (
<input
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={(e) => {
setIsComposing(false);
onSearch(e.currentTarget.value);
}}
onKeyDown={(e) => {
// Do NOT trigger search on Enter during IME composition
if (e.key === 'Enter' && !isComposing) {
onSearch(e.currentTarget.value);
}
}}
onChange={(e) => {
// Do not trigger live search during composition
if (!isComposing) {
onSearch(e.target.value);
}
}}
/>
);
}Indic Script IME Considerations
Indic scripts (Devanagari, Tamil, Bengali, etc.) use transliteration-based IMEs where Latin keystrokes produce native script characters.
- Composition may produce multiple characters from a single keystroke
- Conjunct characters (ligatures) form during composition
- Cursor position may jump as ligatures form
maxLengthon inputs may be unreliable (one visual character = multiple Unicode code points)
// Use grapheme-aware length counting for Indic scripts
function graphemeLength(text: string): number {
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
return [...segmenter.segment(text)].length;
}
// Instead of: input.maxLength = 50
// Use: validation with grapheme count
if (graphemeLength(value) > 50) {
setError(t('form.maxLength', { max: 50 }));
}---
WCAG Requirements for Multilingual Content
WCAG 3.1: Language of Page and Parts
| Criterion | Level | Requirement |
|---|---|---|
| 3.1.1 Language of Page | A | lang attribute on <html> element |
| 3.1.2 Language of Parts | AA | lang attribute on elements in a different language |
| 3.1.3 Unusual Words | AAA | Mechanism to identify jargon/idioms |
| 3.1.4 Abbreviations | AAA | Mechanism to identify abbreviations |
| 3.1.5 Reading Level | AAA | Supplemental content for complex text |
| 3.1.6 Pronunciation | AAA | Mechanism for pronunciation (ruby text for CJK) |
Implementation Checklist
<!-- 3.1.1: Language of page -->
<html lang="ar" dir="rtl">
<!-- 3.1.2: Language of parts -->
<p lang="ar">هذا النص بالعربية <span lang="en">with English</span> مرة أخرى</p>
<!-- 3.1.6: Ruby annotation for CJK pronunciation -->
<ruby lang="ja">
漢字 <rp>(</rp><rt>かんじ</rt><rp>)</rp>
</ruby>Automated WCAG Checking Per Locale
// tests/wcag-i18n.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
const LOCALES = ['en', 'ar', 'ja', 'de'];
for (const locale of LOCALES) {
test(`WCAG 3.1 compliance: ${locale}`, async ({ page }) => {
await page.goto(`/${locale}/dashboard`);
// Check lang attribute
const lang = await page.locator('html').getAttribute('lang');
expect(lang).toBe(locale);
// Run axe for language-related rules
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.withRules(['html-has-lang', 'html-lang-valid', 'valid-lang'])
.analyze();
expect(results.violations).toHaveLength(0);
});
}---
Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
Missing lang attribute | Screen reader uses wrong pronunciation | Set lang on <html> and on inline language switches |
| Untranslated ARIA labels | Screen reader reads English on Arabic page | Translate all aria-label, aria-description |
| Fixed line-height for all scripts | Devanagari/Arabic text clips | Use script-aware line-height values |
| Ignoring IME composition events | Search triggers on every keystroke during CJK input | Check isComposing before acting on input |
| Same contrast ratio for all scripts | Thin Arabic strokes become illegible | Test contrast with actual script samples |
| Hardcoded tab order | Confusing navigation in RTL | Let DOM order match visual order |
Missing <bdi> for user content | Garbled display of mixed-direction text | Wrap user-generated content in <bdi> |
---
Cross-References
- rtl-support.md — CSS logical properties, Tailwind RTL, icon mirroring
- testing-i18n.md — i18n test matrix, visual regression, pseudo-localisation
- icu-message-format.md — Plural rules, select, formatting
- locale-handling.md — Date, number, currency formatting by locale
- framework-guides.md — Framework-specific i18n setup
Translation Content Management Patterns
Strategies for managing translation content at scale: translation memory, glossary management, context for translators, string key conventions, dynamic content, version control, linguistic QA, cost optimisation, and machine translation workflows.
---
Overview
Translation management is an operational discipline, not just a file format problem. At scale, the difference between a well-managed translation pipeline and a chaotic one is measured in months of delay, thousands of dollars in rework, and degraded user experience. This reference covers the content management layer that sits between your codebase and your translators.
---
Translation Memory (TM)
How It Works
Translation Memory is a database of previously translated segments (source + target pairs). When a new string matches or partially matches an existing entry, the TM suggests the previous translation.
| Match Type | Definition | Typical Discount |
|---|---|---|
| 100% match | Exact same source text | 70-90% discount |
| Context match (101%) | Exact match + same surrounding context | 80-95% discount |
| Fuzzy match (75-99%) | Similar but not identical | 30-60% discount |
| Repetition | Same string appearing multiple times in the same batch | Same as 100% match |
| No match (new) | No prior translation exists | Full price |
Leveraging TM for Cost Savings
Strategy: Maximise reuse across projects
1. Maintain a master TM per language pair (en → de, en → ar, etc.)
2. Pre-translate new content against TM before sending to translators
3. Use TM across projects — "Save changes" translates the same everywhere
4. Clean TM periodically — remove outdated or low-quality entries
5. Import client/domain-specific TMs when onboarding a new vendorTM Quality Management
| Action | Frequency | Purpose |
|---|---|---|
| Export and back up TM | Monthly | Disaster recovery |
| Remove duplicate entries | Quarterly | Reduce noise in suggestions |
| Review low-rated segments | Quarterly | Improve future match quality |
| Merge project TMs into master | Per release | Centralise knowledge |
| Validate against glossary | Quarterly | Ensure term consistency |
TM File Formats
| Format | Extension | Standard | Used By |
|---|---|---|---|
| TMX | .tmx | LISA/OASIS | Most TMS, SDL Trados, memoQ |
| XLIFF | .xliff | OASIS | Apple, many TMS |
| TBX | .tbx | ISO 30042 | Terminology exchange |
| CSV | .csv | None | Simple import/export |
---
Glossary Management
Why Glossaries Matter
Without a glossary, different translators translate the same term differently. "Dashboard" might become "Armaturenbrett" in one place and "Übersicht" in another.
Glossary Structure
| Field | Required | Example |
|---|---|---|
| Source term | Yes | Dashboard |
| Target term | Yes | Tableau de bord (fr) |
| Part of speech | Recommended | Noun |
| Definition | Recommended | Main application overview page |
| Context | Recommended | Navigation, page title |
| Do Not Translate | Optional | TRUE (for brand names) |
| Notes | Optional | Always capitalised in UI |
Example Glossary Entries
[
{
"source": "Dashboard",
"translations": {
"de": "Dashboard",
"fr": "Tableau de bord",
"ja": "ダッシュボード",
"ar": "لوحة المعلومات"
},
"pos": "noun",
"definition": "Main application overview page showing key metrics",
"doNotTranslate": false,
"notes": "Always capitalised in UI context"
},
{
"source": "API key",
"translations": {
"de": "API-Schlüssel",
"fr": "Clé API",
"ja": "APIキー",
"ar": "مفتاح API"
},
"pos": "noun",
"definition": "Authentication token for programmatic access",
"doNotTranslate": false,
"notes": "API is always in Latin characters"
}
]Domain-Specific Terminology
| Domain | Example Terms | Challenge |
|---|---|---|
| Finance | Invoice, credit, debit | Legal precision required |
| Healthcare | Diagnosis, prescription | Regulatory compliance |
| Legal | Terms of service, liability | Must match local legal language |
| Gaming | Achievement, quest, guild | Cultural adaptation needed |
| E-commerce | Cart, checkout, wishlist | Varies widely by market |
Glossary Maintenance Workflow
1. Extract new terms from each release's string diff
2. Review with subject matter experts (product, legal, domain)
3. Send to terminologist or senior translator for target language terms
4. Import approved terms into TMS glossary
5. Enable glossary enforcement in TMS (flag violations during translation)
6. Review glossary violations in QA step before release---
Context for Translators
Why Context Reduces Rework
Translators working without context make assumptions that are often wrong. "Save" could mean "Save to disk" (Speichern) or "Save money" (Sparen) in German. Providing context eliminates round-trip corrections.
Context Types
| Context Type | Format | When to Use |
|---|---|---|
| Screenshots | PNG/URL | Always for UI strings |
| Description | Text | Always for ambiguous strings |
| Character limit | Number | Buttons, headers, labels |
| Placeholder example | Text | Interpolated strings |
| Gender context | Text | Gendered languages |
| Plural context | Text | Strings with count variables |
Providing Context in Code
// i18next: Use context and description in extraction config
// i18next-parser extracts these as developer comments
t('save_button', {
// i18next-extract-mark-context-next-line description: "Button to save user profile changes"
// i18next-extract-mark-context-next-line maxLength: 10
defaultValue: 'Save',
});
// FormatJS: Use description in message descriptor
const messages = defineMessages({
saveButton: {
id: 'profile.save',
defaultMessage: 'Save',
description: 'Button to save user profile changes. Max 10 characters.',
},
});Screenshot Automation
// Generate translator screenshots in CI
// tests/translator-screenshots.spec.ts
import { test } from '@playwright/test';
const SCREENSHOT_TARGETS = [
{ page: '/settings', elements: ['save-button', 'cancel-button', 'settings-header'] },
{ page: '/dashboard', elements: ['metric-card', 'nav-item', 'search-input'] },
];
for (const target of SCREENSHOT_TARGETS) {
test(`translator screenshots: ${target.page}`, async ({ page }) => {
await page.goto(target.page);
for (const elementId of target.elements) {
const el = page.locator(`[data-testid="${elementId}"]`);
await el.screenshot({
path: `translator-context/${target.page.slice(1)}-${elementId}.png`,
});
}
});
}---
String Key Naming Conventions
Convention Comparison
| Convention | Example | Pros | Cons |
|---|---|---|---|
| Namespaced | auth.login.button | Clear scope | Verbose |
| Hierarchical | auth:login.submitButton | Namespace separation | Mixed separators |
| Semantic | action.save | Reusable | Ambiguous without context |
| Page-based | dashboard.metrics.title | Easy to locate | Limits reuse |
| Content hash | abc123 | No key conflicts | Unreadable |
Recommended Convention
{namespace}.{component}.{element}[.{variant}]
Examples:
common.button.save
common.button.cancel
common.button.delete
auth.login.title
auth.login.emailLabel
auth.login.emailPlaceholder
auth.login.error.invalidCredentials
dashboard.metrics.totalUsers
dashboard.metrics.revenue.monthlyKey Naming Rules
| Rule | Example | Rationale |
|---|---|---|
| Use camelCase for segments | auth.loginButton | Consistent with JS conventions |
| Keep keys stable across releases | Never rename without migration | Breaks TM matching |
| No UI text in keys | auth.login.title not auth.login.welcomeBack | Text changes, keys should not |
| Group by feature, not page | billing.invoice.title | Enables code splitting |
| Prefix shared strings | common.button.save | Clear reuse intent |
---
Handling Dynamic Content
Content Types and Strategies
| Content Type | Translation Strategy | Update Frequency |
|---|---|---|
| UI strings | Standard i18n files | Per release |
| CMS content | TMS API integration | Per publish |
| User-generated | MT + moderation | Real-time |
| Legal/compliance | Certified translation | Per regulation change |
| Marketing | Creative translation | Per campaign |
| Transactional email | Template + variables | Per release |
| Help articles | Full translation | Per update |
CMS Integration Patterns
// Pattern: CMS webhook triggers translation
// Contentful → Webhook → TMS → Translated → Publish
// Contentful webhook handler
export async function handleContentUpdate(entry: ContentfulEntry) {
const sourceLocale = 'en-US';
const targetLocales = ['de', 'fr', 'ja', 'ar'];
// Extract translatable fields
const translatableFields = extractTranslatableFields(entry);
// Send to TMS via API
await tmsClient.createTranslationJob({
sourceLocale,
targetLocales,
content: translatableFields,
callbackUrl: `${API_URL}/webhooks/translation-complete`,
context: {
contentType: entry.sys.contentType.sys.id,
entryId: entry.sys.id,
screenshot: `${CMS_PREVIEW_URL}/${entry.sys.id}`,
},
});
}Legal and Compliance Content
Legal translation requirements:
1. Use certified/sworn translators (not general translators)
2. Maintain version history with effective dates
3. Back-translation for verification (translate back to source, compare)
4. Legal review in target jurisdiction
5. Store signed-off versions separately from general translations
6. Track regulatory changes per market---
Version Control for Translations
Branching Strategies
| Strategy | How It Works | Best For |
|---|---|---|
| Release-based | Translations branch with code release | Fixed release cycles |
| Continuous | Translations merged to main continuously | Continuous deployment |
| Feature-branch | New strings on feature branch, translated before merge | Feature teams |
Release-Based Workflow
1. Feature development on feature branches
└─ New strings added with default English values
2. Strings extracted at release branch cut
└─ npx i18next-parser → diff shows new/changed keys
3. New keys sent to TMS for translation
└─ Translators work from branch snapshot
4. Translations returned and committed to release branch
└─ PR with translated files
5. Release ships with complete translations
└─ Missing keys blocked by CI check
6. Hotfix: emergency strings get expedited translation
└─ Use MT + human review for speedGit Workflow for Translation Files
# .gitattributes — mark translation files for merge strategy
locales/**/*.json merge=ours # Prefer local changes (TMS is source of truth)
# Alternatively, exclude translation files from normal diff
locales/**/*.json linguist-generated=true---
Quality Assurance
Linguistic QA Tools
| Tool | Type | What It Checks |
|---|---|---|
| Xbench | Desktop | Consistency, glossary, formatting, untranslated |
| Verifika | Desktop | Similar to Xbench, CAT integration |
| QA Distiller | Desktop | Advanced QA rules, regex |
| TMS built-in QA | Cloud | Most TMS have basic QA (Phrase, Lokalise, Crowdin) |
| Custom scripts | CI | Project-specific rules |
QA Check Categories
| Check | What It Detects | Severity |
|---|---|---|
| Untranslated segments | Source text left as-is | Critical |
| Glossary violations | Wrong term used | High |
| Placeholder mismatch | {name} missing in target | Critical |
| Punctuation consistency | Missing period, extra space | Medium |
| Number formatting | Wrong decimal/thousand separator | High |
| Tag integrity | Missing/broken HTML tags | Critical |
| Length violation | Exceeds character limit | High |
| Consistency | Same source, different target | Medium |
In-Context Review
In-context review workflow:
1. Deploy translated build to staging/preview environment
2. Provide reviewers with locale-specific URLs
3. Reviewers flag issues directly in context (screenshot + annotation)
4. Issues routed back to translators with visual context
5. Fixes verified in next preview buildBack-Translation
Back-translation is the process of translating the target text back into the source language by an independent translator. It is used for:
- Legal and medical content verification
- Detecting meaning shifts that a bilingual reviewer might miss
- Compliance requirements in regulated industries
---
Cost Optimisation
Reuse Rate Benchmarks
| Reuse Rate | Assessment | Action |
|---|---|---|
| < 20% | Low | Review key naming, check for duplicate strings |
| 20-40% | Average | Standardise common UI patterns |
| 40-60% | Good | Healthy codebase with shared components |
| 60-80% | Excellent | Strong design system, stable UI |
| > 80% | Exceptional | Mature product with incremental updates |
Cost Reduction Strategies
| Strategy | Savings | Trade-off |
|---|---|---|
| Pre-translate with TM | 30-60% | Requires TM maintenance |
| Reuse common strings | 10-20% | Needs string deduplication discipline |
| Batch translations | 10-15% | Delays vs continuous delivery |
| MT + post-editing | 40-60% | Quality depends on language pair |
| Tiered quality | 20-40% | Marketing = creative; UI = standard; legal = certified |
| Reduce source word count | Variable | Shorter strings = lower cost + better UX |
Batch vs Continuous Translation
| Approach | Latency | Cost | Best For |
|---|---|---|---|
| Batch (weekly/bi-weekly) | 3-7 days | Lower per-word (volume discount) | Fixed release cycles |
| Continuous (per-commit) | 1-24 hours | Higher per-word | Continuous deployment |
| Hybrid | 1-3 days | Moderate | Most teams |
---
Machine Translation + Human Review (MTPE)
MTPE Quality Tiers
| Tier | Process | Use Case | Quality |
|---|---|---|---|
| Raw MT | Machine only | Internal, developer docs | Low-medium |
| Light PE | MT + quick human scan | Help articles, low-visibility UI | Medium |
| Full PE | MT + thorough human review | Product UI, marketing | High |
| Creative | Human from scratch | Brand, legal, marketing headlines | Highest |
MT Engine Comparison
| Engine | Strengths | Weaknesses | Integration |
|---|---|---|---|
| Google Translate API | Wide language coverage | Variable quality for rare pairs | REST API, TMS plugins |
| DeepL API | European language quality | Limited language coverage | REST API, TMS plugins |
| AWS Translate | Good for technical content | Fewer languages than Google | AWS SDK, TMS plugins |
| Azure Translator | Microsoft ecosystem | Variable quality | REST API, TMS plugins |
| Custom NMT | Domain-specific quality | Training data required | Self-hosted or cloud |
MTPE Workflow
1. Extract new strings (CI/CD)
2. Pre-translate with TM (100% and fuzzy matches)
3. Run MT on remaining new strings
4. Route to human reviewers:
- 100% TM matches → skip review (unless quality flag)
- Fuzzy matches → review required
- MT output → full post-edit
- Creative/legal → human translation from scratch
5. QA checks (automated + human)
6. Commit translated files
7. In-context review on staging
8. ReleaseCost Model Example
Scenario: 10,000 new words per month, 10 target languages
Without MTPE:
10,000 words × 10 languages × $0.12/word = $12,000/month
With MTPE (assuming 40% TM match, 40% MT+PE, 20% human):
TM matches: 4,000 × 10 × $0.02 = $800
MT + PE: 4,000 × 10 × $0.06 = $2,400
Human: 2,000 × 10 × $0.12 = $2,400
Total: $5,600/month (53% savings)---
Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
| No translation memory | Paying to translate the same string twice | Set up TM from day one |
| No glossary | Inconsistent terminology across screens | Create and enforce glossary |
| Sending strings without context | Rework rate of 20-30% | Attach screenshots and descriptions |
| Key names based on English text | Keys change when text changes, breaking TM | Use semantic, stable key names |
| Translating everything at the same quality tier | Overspending on low-visibility content | Tier content by quality requirement |
| No in-context review | Translations that look wrong in the UI | Deploy to preview environment for review |
| Ignoring TM maintenance | Stale/incorrect suggestions | Clean TM quarterly |
| Manual translation file management | Merge conflicts, lost translations | Use TMS with API integration |
---
Cross-References
- translation-workflows.md — CI/CD pipelines, string extraction, TMS integration
- icu-message-format.md — Plural rules, select, number/date formatting
- testing-i18n.md — Missing translation CI detection, visual regression
- framework-guides.md — Framework-specific i18n setup
- rtl-support.md — RTL support patterns
ICU Message Format
Production patterns for pluralisation, select statements, and locale-aware formatting.
Reference: ICU Message Format Specification
Status (Jan 2026): ICU MessageFormat 2.0 was finalized in March 2025 but remains in technology preview in ICU 78. Java implementation is more mature (core API at "draft" status), C++ is still catching up. For production use, continue with MessageFormat 1.0 syntax documented below. See MessageFormat 2.0 docs for the new syntax when ready.
---
Core Syntax
Simple Interpolation
Hello, {name}!// i18next
t('greeting', { name: 'Alice' }) // "Hello, Alice!"
// react-intl
<FormattedMessage id="greeting" values={{ name: 'Alice' }} />Variable Types
| Type | Syntax | Example |
|---|---|---|
| String | {name} | Hello, {name} |
| Number | {count, number} | {count, number} -> "1,234" |
| Date | {date, date} | {date, date, medium} -> "Jan 1, 2025" |
| Time | {time, time} | {time, time, short} -> "3:45 PM" |
| Plural | {count, plural, ...} | See below |
| Select | {gender, select, ...} | See below |
| Selectordinal | {position, selectordinal, ...} | See below |
---
Pluralisation
Basic Plural
{count, plural,
one {# item}
other {# items}
}| Count | Output |
|---|---|
| 0 | "0 items" |
| 1 | "1 item" |
| 2 | "2 items" |
| 100 | "100 items" |
CLDR Plural Categories
Different languages have different plural rules:
| Language | Categories | Example |
|---|---|---|
| English | one, other | 1 item, 2 items |
| French | one, other | 1 élément, 2 éléments |
| Russian | one, few, many, other | 1 товар, 2 товара, 5 товаров, 21 товар |
| Arabic | zero, one, two, few, many, other | Complex rules |
| Japanese | other (only) | No plural forms |
| Polish | one, few, many, other | 1 plik, 2 pliki, 5 plików |
Complete Plural Example
{count, plural,
=0 {No messages}
one {# message}
other {# messages}
}// locales/en/common.json
{
"messages_count": "{count, plural, =0 {No messages} one {# message} other {# messages}}"
}
// locales/ru/common.json
{
"messages_count": "{count, plural, =0 {Нет сообщений} one {# сообщение} few {# сообщения} many {# сообщений} other {# сообщения}}"
}Exact Match (=N)
{count, plural,
=0 {Cart is empty}
=1 {One item in cart}
=2 {A pair of items}
other {# items in cart}
}Rule: =N takes precedence over category keywords.
---
Select Statements
Gender Selection
{gender, select,
male {He liked your post}
female {She liked your post}
other {They liked your post}
}Category Selection
{type, select,
error {An error occurred: {message}}
warning {Warning: {message}}
info {Info: {message}}
other {{message}}
}Nested Select + Plural
{gender, select,
male {{count, plural,
one {He has # new message}
other {He has # new messages}
}}
female {{count, plural,
one {She has # new message}
other {She has # new messages}
}}
other {{count, plural,
one {They have # new message}
other {They have # new messages}
}}
}Best Practice: Place select on the outside, plural on the inside.
---
Selectordinal (Ordinal Numbers)
For ordinal positions: 1st, 2nd, 3rd, 4th...
{position, selectordinal,
one {#st place}
two {#nd place}
few {#rd place}
other {#th place}
}| Position | Output |
|---|---|
| 1 | "1st place" |
| 2 | "2nd place" |
| 3 | "3rd place" |
| 4 | "4th place" |
| 21 | "21st place" |
---
Number Formatting
Basic Number
Price: {price, number}Output: "Price: 1,234.56" (locale-aware)
Currency
{price, number, currency}Note: Currency code must be passed separately in most libraries.
// react-intl
<FormattedNumber value={99.99} style="currency" currency="USD" />
// Output: "$99.99"
// i18next with Intl
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(99.99);Percentage
{ratio, number, percent}// 0.75 -> "75%"Custom Number Formats
{value, number, ::currency/EUR unit-width-narrow}Skeleton syntax (advanced):
::currency/EUR- Currency with EUR::percent scale/100- Percentage scaled::compact-short- "1.2K"
---
Date and Time Formatting
Date Styles
{date, date, short} // 1/1/25
{date, date, medium} // Jan 1, 2025
{date, date, long} // January 1, 2025
{date, date, full} // Wednesday, January 1, 2025Time Styles
{time, time, short} // 3:45 PM
{time, time, medium} // 3:45:30 PM
{time, time, long} // 3:45:30 PM EST
{time, time, full} // 3:45:30 PM Eastern Standard TimeCustom Date/Time
{date, date, ::yyyy-MM-dd} // 2025-01-01
{date, date, ::EEEE} // Wednesday
{date, date, ::MMM} // JanRelative Time
// Use Intl.RelativeTimeFormat directly
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
rtf.format(-1, 'day'); // "yesterday"
rtf.format(2, 'hour'); // "in 2 hours"
rtf.format(-3, 'week'); // "3 weeks ago"---
Escaping Special Characters
Apostrophe Handling
// Single quote escapes syntax characters
'{name}' -> "{name}" (literal braces)
// Double apostrophe for literal apostrophe
It''s working -> "It's working"
// Recommended: Use curly apostrophe (U+2019)
It's working -> "It's working" (no escaping needed)When to Escape
| Character | Escape | Example |
|---|---|---|
{ | '{' | '{' is a brace |
} | '}' | '}' is a brace |
' | '' | It''s a test |
# (in plural) | '#' | '#' is a hash |
Best Practice
Use curly quotes for human-readable text:
'(U+2019) instead of'(U+0027)- Avoids ICU escaping issues
- Better typography
---
Translator-Friendly Patterns
Full Sentences in Sub-messages
// FAIL Fragments (hard to translate)
{count, plural, one {item} other {items}}
// Translator sees: "item" and "items" without context
// PASS Complete sentences
{count, plural,
one {You have # item in your cart}
other {You have # items in your cart}
}
// Translator sees full contextAvoid Concatenation
// FAIL Concatenation (breaks in other languages)
"welcome": "Welcome",
"to_site": "to our site"
// Used as: t('welcome') + ' ' + t('to_site')
// PASS Single key
"welcome_message": "Welcome to our site"Context Comments
// With description for translators
{
"items_count": {
"message": "{count, plural, one {# item} other {# items}}",
"description": "Count of items in shopping cart"
}
}---
Library-Specific Implementation
react-intl (FormatJS)
import { FormattedMessage, FormattedPlural } from 'react-intl';
// ICU in message
<FormattedMessage
id="items_count"
defaultMessage="{count, plural, one {# item} other {# items}}"
values={{ count: 5 }}
/>
// FormattedPlural component
<FormattedPlural
value={count}
one="# item"
other="# items"
/>i18next
// Enable ICU format
import i18n from 'i18next';
import ICU from 'i18next-icu';
i18n.use(ICU).init({
// ...
});
// JSON file
{
"items_count": "{count, plural, one {# item} other {# items}}"
}
// Usage
t('items_count', { count: 5 }) // "5 items"vue-i18n
import { createI18n } from 'vue-i18n';
const i18n = createI18n({
// ...
messageCompiler: (message, locale) => {
// Custom ICU compiler if needed
},
});<template>
{{ $t('items_count', { count: 5 }) }}
</template>LinguiJS
import { Plural, Trans } from '@lingui/macro';
<Plural
value={count}
one="# item"
other="# items"
/>
// Or inline
<Trans>
{count, plural, one {# item} other {# items}}
</Trans>---
Common Patterns
Zero Handling
{count, plural,
=0 {No items}
one {# item}
other {# items}
}Range Plurals
{count, plural,
=0 {No results}
one {# result}
=2 {A couple of results}
few {A few results (#)}
other {# results}
}Nested with HTML (react-intl)
<FormattedMessage
id="welcome_user"
defaultMessage="Welcome, <bold>{name}</bold>!"
values={{
name: user.name,
bold: (chunks) => <strong>{chunks}</strong>,
}}
/>{
"welcome_user": "Welcome, <bold>{name}</bold>!"
}---
Validation and Linting
FormatJS CLI
# Validate ICU syntax
npx formatjs compile messages/en.json --ast
# Extract and validate
npx formatjs extract 'src/**/*.tsx' --out-file messages/en.json --throwsESLint Plugin
npm install -D eslint-plugin-formatjs// .eslintrc.js
module.exports = {
plugins: ['formatjs'],
rules: {
'formatjs/enforce-default-message': 'error',
'formatjs/enforce-placeholders': 'error',
'formatjs/no-multiple-whitespaces': 'error',
'formatjs/no-offset': 'error',
},
};---
Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
{count} item(s) | Wrong for most languages | Use {count, plural, ...} |
| String concatenation | Word order varies | Single ICU message |
choice argument | Deprecated, limited | Use plural instead |
| Hardcoded plural rules | English-centric | Use CLDR categories |
Missing other clause | Required fallback | Always include other |
Nesting plural outside select | Complex, error-prone | select outside, plural inside |
---
Testing ICU Messages
Unit Tests
import { createIntl, createIntlCache } from 'react-intl';
const cache = createIntlCache();
const intl = createIntl({ locale: 'en', messages: {} }, cache);
describe('ICU messages', () => {
it('handles plural correctly', () => {
const message = '{count, plural, one {# item} other {# items}}';
expect(intl.formatMessage({ id: 'test', defaultMessage: message }, { count: 0 })).toBe(
'0 items'
);
expect(intl.formatMessage({ id: 'test', defaultMessage: message }, { count: 1 })).toBe(
'1 item'
);
expect(intl.formatMessage({ id: 'test', defaultMessage: message }, { count: 5 })).toBe(
'5 items'
);
});
});Snapshot Testing
import messages from '../messages/en.json';
describe('Message syntax', () => {
Object.entries(messages).forEach(([key, message]) => {
it(`${key} is valid ICU`, () => {
expect(() => new IntlMessageFormat(message, 'en')).not.toThrow();
});
});
});Ops Runbook: Large Locale Catalogs (LLM-Safe)
Use this when locale catalogs are too large for single reads, mixed-language UI appears, or missing keys are reported.
90-Second Triage
# 1) Confirm locale file layout
rg --files src/messages | sort
# 2) Detect oversized catalogs before reading
wc -l src/messages/en/*.json src/messages/*/*.json | sort -nr | head
# 3) Chunk reads for large files (avoid tool limits)
sed -n '1,200p' src/messages/en/landing.json
sed -n '201,400p' src/messages/en/landing.jsonKey Parity Check (Base vs Target Locale)
BASE=en
TARGET=ru
jq -r 'paths(scalars) | join(".")' src/messages/$BASE/*.json | sort -u > /tmp/$BASE.keys
jq -r 'paths(scalars) | join(".")' src/messages/$TARGET/*.json | sort -u > /tmp/$TARGET.keys
# Missing in target
comm -23 /tmp/$BASE.keys /tmp/$TARGET.keys
# Extra in target
comm -13 /tmp/$BASE.keys /tmp/$TARGET.keysHardcoded UI String Sweep
# TSX/TS hardcoded literals (quick heuristic)
rg -n --pcre2 '"[A-Za-z][^"\n]{2,}"' src --glob '*.tsx' --glob '*.ts'
# JSX text nodes
rg -n --pcre2 '>[A-Za-z][^<]{2,}<' src --glob '*.tsx'CI Gate Pattern (No Mixed Language)
# Fail build if known missing-key sentinel appears
rg -n '__MISSING_I18N__|TODO_TRANSLATE' src/messages && exit 1 || true
# Optional: block English fallback on localized, indexable routes
rg -n 'fallback.*en|defaultLocale.*en' src/app src/libOperational Rules
- Never read large locale files in one shot; always chunk.
- Use key diff first, translation pass second.
- Treat marketing/SEO locale key gaps as release blockers.
- Do not auto-insert machine translations without a tracked review pass.