Internationalization Architecture That Doesn't Cripple Your App
Internationalization Architecture That Doesn't Cripple Your App
Most i18n guides stop at "install i18next, create JSON files, wrap strings in t()." That's the easy part. The hard part—the part that determines whether your app scales to 50 locales or collapses under its own weight—is architectural: namespace organization, bundle splitting, RTL support, routing strategy, and the hundred small decisions that compound into either a maintainable system or unmaintainable chaos.
This is a guide to i18n architecture that survives growth: the patterns that scale, the decisions that haunt you, and how to build a foundation that doesn't become tech debt.
The I18n Architecture Landscape
┌─────────────────────────────────────────────────────────────────────────────┐
│ I18N ARCHITECTURAL DECISIONS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Translation Storage │
│ ├── JSON files (static) │
│ ├── Database/CMS (dynamic) │
│ └── Hybrid (static + overrides) │
│ │
│ Loading Strategy │
│ ├── All upfront (simple, heavy) │
│ ├── Per-locale (medium) │
│ ├── Per-namespace (granular) │
│ └── On-demand (complex, optimal) │
│ │
│ Routing Model │
│ ├── Subdomain (de.example.com) │
│ ├── Path prefix (/de/products) │
│ ├── Query param (?locale=de) │
│ └── Cookie/header only (no URL) │
│ │
│ Key Organization │
│ ├── Flat (common.welcomeMessage) │
│ ├── Nested (pages.home.hero.title) │
│ └── Component-scoped (Button.submit) │
│ │
│ Layout Direction │
│ ├── LTR only │
│ ├── RTL support (CSS logical properties) │
│ └── Bidirectional (mixed content) │
│ │
│ Content Types │
│ ├── UI strings only │
│ ├── + Formatted dates/numbers │
│ ├── + Pluralization │
│ ├── + Rich text/HTML │
│ └── + User-generated content │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Namespace Strategy: The Foundation
Namespaces determine how translations are organized, loaded, and maintained. Get this wrong and you'll pay for it at scale.
Anti-Pattern: The Monolith
// ❌ Single namespace with everything
// locales/en/translation.json (grows to 50,000 lines)
{
"welcome": "Welcome",
"nav.home": "Home",
"nav.products": "Products",
"products.list.title": "Our Products",
"products.detail.addToCart": "Add to Cart",
"checkout.title": "Checkout",
"checkout.payment.cardNumber": "Card Number",
"settings.profile.name": "Name",
"errors.network": "Network error",
"errors.validation.required": "This field is required",
// ... 49,988 more keys
}
// Problems:
// 1. Every page loads ALL translations
// 2. Merge conflicts between teams
// 3. No clear ownership
// 4. Bundle size disaster
// 5. Key naming becomes inconsistent
Pattern: Domain-Based Namespaces
┌─────────────────────────────────────────────────────────────────────────────┐
│ NAMESPACE ORGANIZATION │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ locales/ │
│ ├── en/ │
│ │ ├── common.json # Shared: nav, footer, errors │
│ │ ├── auth.json # Login, register, password reset │
│ │ ├── products.json # Product listing, detail, search │
│ │ ├── checkout.json # Cart, payment, confirmation │
│ │ ├── account.json # Profile, settings, orders │
│ │ ├── marketing.json # Landing pages, campaigns │
│ │ └── admin.json # Admin panel (if applicable) │
│ ├── de/ │
│ │ └── ... (same structure) │
│ └── ja/ │
│ └── ... (same structure) │
│ │
│ Sizing guidelines: │
│ • common.json: < 200 keys (loaded everywhere) │
│ • Feature namespaces: 100-500 keys each │
│ • Split if namespace > 500 keys │
│ │
│ Ownership: │
│ • common.json → Platform team │
│ • Feature namespaces → Feature teams │
│ • CODEOWNERS file for review requirements │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Implementation: Namespace Configuration
// lib/i18n/config.ts
export const i18nConfig = {
locales: ['en', 'de', 'fr', 'ja', 'ar', 'he'] as const,
defaultLocale: 'en' as const,
// Namespace definitions with metadata
namespaces: {
common: {
// Always load - keep small!
preload: true,
maxKeys: 200,
},
auth: {
routes: ['/login', '/register', '/forgot-password'],
preload: false,
},
products: {
routes: ['/products', '/products/[id]', '/search'],
preload: false,
},
checkout: {
routes: ['/cart', '/checkout', '/checkout/success'],
preload: false,
},
account: {
routes: ['/account', '/account/*'],
preload: false,
// Requires auth - might never load for anonymous users
requiresAuth: true,
},
admin: {
routes: ['/admin/*'],
preload: false,
requiresAuth: true,
requiredRoles: ['admin'],
},
},
// RTL locales
rtlLocales: ['ar', 'he'] as const,
// Date/number format locales (may differ from translation locale)
formatLocales: {
en: 'en-US',
de: 'de-DE',
fr: 'fr-FR',
ja: 'ja-JP',
ar: 'ar-SA',
he: 'he-IL',
},
} as const;
export type Locale = (typeof i18nConfig.locales)[number];
export type Namespace = keyof typeof i18nConfig.namespaces;
Key Naming Convention
// locales/en/products.json
{
// Page-level keys
"list": {
"title": "Our Products",
"subtitle": "Browse our collection",
"empty": "No products found",
"filters": {
"category": "Category",
"price": "Price Range",
"sort": "Sort By"
}
},
// Component-level keys (when component is domain-specific)
"card": {
"addToCart": "Add to Cart",
"outOfStock": "Out of Stock",
"sale": "Sale"
},
// Shared within namespace
"shared": {
"currency": "{{amount, currency}}",
"reviewCount": "{{count}} review",
"reviewCount_plural": "{{count}} reviews"
}
}
// Usage
t('products:list.title')
t('products:card.addToCart')
t('products:shared.reviewCount', { count: 5 })
Lazy Loading Translations
Loading all translations upfront defeats the purpose of namespaces. Implement proper lazy loading.
Next.js App Router Implementation
// lib/i18n/server.ts
import { createInstance } from 'i18next';
import resourcesToBackend from 'i18next-resources-to-backend';
import { initReactI18next } from 'react-i18next/initReactI18next';
import { i18nConfig, type Locale, type Namespace } from './config';
// Server-side: Load only needed namespaces
export async function getServerTranslations(
locale: Locale,
namespaces: Namespace | Namespace[]
) {
const ns = Array.isArray(namespaces) ? namespaces : [namespaces];
const i18nInstance = createInstance();
await i18nInstance
.use(initReactI18next)
.use(
resourcesToBackend(
(language: string, namespace: string) =>
import(`@/locales/${language}/${namespace}.json`)
)
)
.init({
lng: locale,
ns,
defaultNS: ns[0],
fallbackLng: i18nConfig.defaultLocale,
interpolation: { escapeValue: false },
});
return {
t: i18nInstance.t,
i18n: i18nInstance,
};
}
// Helper for page components
export async function getPageTranslations(
locale: Locale,
pageNamespaces: Namespace[]
) {
// Always include common
const namespaces = ['common', ...pageNamespaces] as Namespace[];
return getServerTranslations(locale, namespaces);
}
// app/[locale]/products/page.tsx
import { getPageTranslations } from '@/lib/i18n/server';
import { ProductList } from '@/components/products/ProductList';
interface Props {
params: { locale: string };
}
export default async function ProductsPage({ params }: Props) {
const { locale } = params;
const { t } = await getPageTranslations(locale as Locale, ['products']);
return (
<main>
<h1>{t('products:list.title')}</h1>
<p>{t('products:list.subtitle')}</p>
<ProductList locale={locale} />
</main>
);
}
// Generate static params for all locales
export function generateStaticParams() {
return i18nConfig.locales.map((locale) => ({ locale }));
}
Client-Side Lazy Loading
// lib/i18n/client.ts
'use client';
import i18next from 'i18next';
import { initReactI18next, useTranslation as useTranslationOrg } from 'react-i18next';
import resourcesToBackend from 'i18next-resources-to-backend';
import LanguageDetector from 'i18next-browser-languagedetector';
import { i18nConfig, type Locale, type Namespace } from './config';
// Initialize once
let initialized = false;
export function initI18nClient(locale: Locale) {
if (initialized) return i18next;
i18next
.use(initReactI18next)
.use(LanguageDetector)
.use(
resourcesToBackend(
(language: string, namespace: string) =>
import(`@/locales/${language}/${namespace}.json`)
)
)
.init({
lng: locale,
fallbackLng: i18nConfig.defaultLocale,
ns: ['common'], // Only preload common
defaultNS: 'common',
interpolation: { escapeValue: false },
detection: {
order: ['cookie', 'navigator'],
caches: ['cookie'],
},
});
initialized = true;
return i18next;
}
// Hook that handles namespace loading
export function useTranslation(ns?: Namespace | Namespace[]) {
const namespaces = ns ? (Array.isArray(ns) ? ns : [ns]) : ['common'];
const ret = useTranslationOrg(namespaces);
// Check if namespaces are loaded
const isLoaded = namespaces.every((namespace) =>
i18next.hasLoadedNamespace(namespace)
);
return {
...ret,
isLoading: !isLoaded,
};
}
// Preload namespaces for route
export async function preloadNamespaces(namespaces: Namespace[]) {
await Promise.all(
namespaces.map((ns) => i18next.loadNamespaces(ns))
);
}
Route-Based Preloading
// components/LocaleLayout.tsx
'use client';
import { useEffect } from 'react';
import { usePathname } from 'next/navigation';
import { preloadNamespaces } from '@/lib/i18n/client';
import { i18nConfig, type Namespace } from '@/lib/i18n/config';
// Map routes to namespaces
function getNamespacesForRoute(pathname: string): Namespace[] {
const namespaces: Namespace[] = ['common'];
for (const [ns, config] of Object.entries(i18nConfig.namespaces)) {
if ('routes' in config && config.routes) {
const matches = config.routes.some((route) => {
// Convert route pattern to regex
const pattern = route
.replace(/\[.*?\]/g, '[^/]+')
.replace(/\*/g, '.*');
return new RegExp(`^${pattern}$`).test(pathname);
});
if (matches) {
namespaces.push(ns as Namespace);
}
}
}
return namespaces;
}
export function NamespacePreloader() {
const pathname = usePathname();
useEffect(() => {
const namespaces = getNamespacesForRoute(pathname);
preloadNamespaces(namespaces);
}, [pathname]);
return null;
}
// Prefetch on link hover
export function LocaleLink({
href,
children,
...props
}: React.ComponentProps<'a'> & { href: string }) {
const handleMouseEnter = () => {
const namespaces = getNamespacesForRoute(href);
preloadNamespaces(namespaces);
};
return (
<Link href={href} onMouseEnter={handleMouseEnter} {...props}>
{children}
</Link>
);
}
RTL Layout Architecture
Supporting RTL languages (Arabic, Hebrew, Persian, Urdu) requires architectural consideration—not just CSS flipping.
The RTL Challenge
┌─────────────────────────────────────────────────────────────────────────────┐
│ LTR vs RTL LAYOUT │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ LTR (English) RTL (Arabic) │
│ ┌─────────────────────────────┐ ┌─────────────────────────────┐ │
│ │ ☰ Logo 🔍 👤 🛒 │ │ 🛒 👤 🔍 Logo ☰ │ │
│ ├─────────────────────────────┤ ├─────────────────────────────┤ │
│ │ ┌────┐ │ │ ┌────┐ │ │
│ │ │IMG │ Product Name │ │ اسم المنتج │IMG │ │ │
│ │ │ │ ★★★★☆ (42 reviews) │ │ (42 مراجعة) ☆★★★★ │ │ │ │
│ │ └────┘ $99.00 │ │ $99.00 └────┘ │ │
│ │ [Add to Cart →] │ │ [← أضف إلى السلة] │ │
│ └─────────────────────────────┘ └─────────────────────────────┘ │
│ │
│ What needs to flip: │
│ ✓ Text alignment │
│ ✓ Flexbox/Grid direction │
│ ✓ Margins and paddings (inline) │
│ ✓ Absolute positioning (inline) │
│ ✓ Borders (inline) │
│ ✓ Icons with direction (arrows, chevrons) │
│ ✓ Scroll direction │
│ │
│ What stays the same: │
│ ✗ Images (usually) │
│ ✗ Numbers (phone numbers, prices) │
│ ✗ Media player controls │
│ ✗ Graphs and charts │
│ ✗ Some icons (universal symbols) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
CSS Logical Properties: The Foundation
/* ❌ Physical properties (don't scale) */
.card {
margin-left: 16px;
padding-right: 24px;
border-left: 2px solid blue;
text-align: left;
float: left;
}
/* ✅ Logical properties (RTL-ready) */
.card {
margin-inline-start: 16px; /* Left in LTR, Right in RTL */
padding-inline-end: 24px; /* Right in LTR, Left in RTL */
border-inline-start: 2px solid blue;
text-align: start;
float: inline-start;
}
/* Logical property mapping */
/*
Physical → Logical (inline axis)
left → inline-start
right → inline-end
Physical → Logical (block axis)
top → block-start
bottom → block-end
width → inline-size
height → block-size
*/
Tailwind CSS RTL Configuration
// tailwind.config.js
module.exports = {
theme: {
extend: {
// Logical property utilities
spacing: {
// These become ms-4, me-4, ps-4, pe-4 etc.
},
},
},
plugins: [
// RTL plugin for automatic flipping
require('tailwindcss-rtl'),
// Custom plugin for logical properties
plugin(function ({ addUtilities }) {
addUtilities({
'.text-start': { textAlign: 'start' },
'.text-end': { textAlign: 'end' },
'.float-start': { float: 'inline-start' },
'.float-end': { float: 'inline-end' },
'.clear-start': { clear: 'inline-start' },
'.clear-end': { clear: 'inline-end' },
});
}),
],
};
// Usage with Tailwind
function ProductCard({ product }: { product: Product }) {
return (
<div className="flex gap-4">
{/* Image on start (left in LTR, right in RTL) */}
<img
src={product.image}
className="w-24 h-24 object-cover"
alt=""
/>
<div className="flex-1">
{/* Text aligns to start automatically */}
<h3 className="text-lg font-semibold">{product.name}</h3>
{/* Price with proper margin */}
<p className="mt-2 text-xl font-bold">
{formatPrice(product.price)}
</p>
{/* Button with directional icon */}
<button className="mt-4 flex items-center gap-2 btn-primary">
<span>{t('products:card.addToCart')}</span>
{/* Arrow flips direction */}
<ArrowIcon className="rtl:rotate-180" />
</button>
</div>
</div>
);
}
Document Direction Setup
// app/[locale]/layout.tsx
import { i18nConfig, type Locale } from '@/lib/i18n/config';
interface Props {
children: React.ReactNode;
params: { locale: string };
}
export default function LocaleLayout({ children, params }: Props) {
const locale = params.locale as Locale;
const dir = i18nConfig.rtlLocales.includes(locale) ? 'rtl' : 'ltr';
return (
<html lang={locale} dir={dir}>
<body className={dir === 'rtl' ? 'font-arabic' : 'font-sans'}>
{children}
</body>
</html>
);
}
Bidirectional Content
// Handle mixed-direction content
function BidiText({ children, locale }: { children: string; locale: Locale }) {
// Detect if content direction differs from page direction
const pageDir = i18nConfig.rtlLocales.includes(locale) ? 'rtl' : 'ltr';
const contentDir = detectTextDirection(children);
if (contentDir !== pageDir) {
// Isolate the content direction
return <bdi dir={contentDir}>{children}</bdi>;
}
return <>{children}</>;
}
// Detect predominant text direction
function detectTextDirection(text: string): 'ltr' | 'rtl' {
// RTL Unicode ranges
const rtlRegex = /[\u0591-\u07FF\u200F\u202B\u202E\uFB1D-\uFDFD\uFE70-\uFEFC]/;
const ltrRegex = /[A-Za-z\u00C0-\u00D6\u00D8-\u00F6]/;
const rtlCount = (text.match(rtlRegex) || []).length;
const ltrCount = (text.match(ltrRegex) || []).length;
return rtlCount > ltrCount ? 'rtl' : 'ltr';
}
// Example: User-generated content in reviews
function ReviewCard({ review }: { review: Review }) {
const { locale } = useLocale();
return (
<div className="review-card">
<BidiText locale={locale}>{review.text}</BidiText>
<span className="review-author">{review.authorName}</span>
</div>
);
}
RTL-Aware Components
// Directional icon component
interface DirectionalIconProps {
icon: 'arrow' | 'chevron' | 'caret';
direction: 'forward' | 'backward' | 'up' | 'down';
className?: string;
}
function DirectionalIcon({ icon, direction, className }: DirectionalIconProps) {
// Icons that flip in RTL
const flipsInRtl = direction === 'forward' || direction === 'backward';
const rotationMap = {
forward: 0, // Points right in LTR, left in RTL
backward: 180, // Points left in LTR, right in RTL
up: -90,
down: 90,
};
const rotation = rotationMap[direction];
const flipClass = flipsInRtl ? 'rtl:-scale-x-100' : '';
return (
<svg
className={cn(
'w-4 h-4 transition-transform',
flipClass,
className
)}
style={{ transform: `rotate(${rotation}deg)` }}
>
{/* Icon path */}
</svg>
);
}
// Usage
<DirectionalIcon icon="chevron" direction="forward" /> // → in LTR, ← in RTL
Locale-Based Routing in Next.js App Router
URL Structure Strategy
┌─────────────────────────────────────────────────────────────────────────────┐
│ LOCALE ROUTING STRATEGIES │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Strategy 1: Path Prefix (Recommended) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ /en/products /de/products /ja/products │ │
│ │ /en/products/123 /de/products/123 /ja/products/123 │ │
│ │ │ │
│ │ ✓ SEO friendly (separate URLs per locale) │ │
│ │ ✓ Shareable links include locale │ │
│ │ ✓ Easy CDN caching per locale │ │
│ │ ✓ Clear user expectation │ │
│ │ ✗ Requires middleware for detection/redirect │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Strategy 2: Subdomain │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ en.example.com/products de.example.com/products │ │
│ │ │ │
│ │ ✓ Clean URLs within locale │ │
│ │ ✓ Can serve from different regions │ │
│ │ ✗ Complex DNS setup │ │
│ │ ✗ SSL certificate per subdomain │ │
│ │ ✗ Cookie sharing requires config │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Strategy 3: Cookie/Header Only (Not Recommended) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ example.com/products (locale from cookie/Accept-Language) │ │
│ │ │ │
│ │ ✗ Same URL, different content (SEO nightmare) │ │
│ │ ✗ Links don't preserve locale │ │
│ │ ✗ Caching is per-user │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
App Router Implementation
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { i18nConfig, type Locale } from '@/lib/i18n/config';
import { match as matchLocale } from '@formatjs/intl-localematcher';
import Negotiator from 'negotiator';
function getLocaleFromRequest(request: NextRequest): Locale {
// Check cookie first
const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value;
if (cookieLocale && i18nConfig.locales.includes(cookieLocale as Locale)) {
return cookieLocale as Locale;
}
// Negotiate from Accept-Language header
const negotiator = new Negotiator({
headers: { 'accept-language': request.headers.get('accept-language') || '' },
});
const languages = negotiator.languages();
try {
return matchLocale(
languages,
i18nConfig.locales as unknown as string[],
i18nConfig.defaultLocale
) as Locale;
} catch {
return i18nConfig.defaultLocale;
}
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Check if pathname has locale prefix
const pathnameHasLocale = i18nConfig.locales.some(
(locale) =>
pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (pathnameHasLocale) {
// Already has locale, continue
const response = NextResponse.next();
// Extract locale and set cookie for persistence
const locale = pathname.split('/')[1] as Locale;
response.cookies.set('NEXT_LOCALE', locale, {
maxAge: 60 * 60 * 24 * 365, // 1 year
path: '/',
});
return response;
}
// No locale in path - redirect to localized version
const locale = getLocaleFromRequest(request);
const newUrl = new URL(`/${locale}${pathname}`, request.url);
// Preserve query params
newUrl.search = request.nextUrl.search;
return NextResponse.redirect(newUrl);
}
export const config = {
matcher: [
// Match all paths except:
'/((?!api|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)',
],
};
App Directory Structure
app/
├── [locale]/
│ ├── layout.tsx # Locale-aware root layout
│ ├── page.tsx # Home page
│ ├── products/
│ │ ├── page.tsx # Product list
│ │ └── [id]/
│ │ └── page.tsx # Product detail
│ ├── checkout/
│ │ └── page.tsx
│ └── account/
│ └── page.tsx
├── api/ # API routes (no locale prefix)
│ └── ...
└── globals.css
Locale-Aware Navigation
// lib/navigation.ts
import { useParams, usePathname } from 'next/navigation';
import { i18nConfig, type Locale } from '@/lib/i18n/config';
export function useLocale(): Locale {
const params = useParams();
return (params.locale as Locale) || i18nConfig.defaultLocale;
}
export function useLocalizedPath() {
const locale = useLocale();
const pathname = usePathname();
// Get path without locale prefix
const pathWithoutLocale = pathname.replace(
new RegExp(`^/${locale}`),
''
) || '/';
return {
pathname: pathWithoutLocale,
localizedPath: (path: string) => `/${locale}${path}`,
switchLocale: (newLocale: Locale) => `/${newLocale}${pathWithoutLocale}`,
};
}
// components/LocaleSwitcher.tsx
'use client';
import { useLocalizedPath, useLocale } from '@/lib/navigation';
import { i18nConfig, type Locale } from '@/lib/i18n/config';
const localeNames: Record<Locale, string> = {
en: 'English',
de: 'Deutsch',
fr: 'Français',
ja: '日本語',
ar: 'العربية',
he: 'עברית',
};
export function LocaleSwitcher() {
const currentLocale = useLocale();
const { switchLocale } = useLocalizedPath();
return (
<select
value={currentLocale}
onChange={(e) => {
window.location.href = switchLocale(e.target.value as Locale);
}}
className="locale-switcher"
aria-label="Select language"
>
{i18nConfig.locales.map((locale) => (
<option key={locale} value={locale}>
{localeNames[locale]}
</option>
))}
</select>
);
}
Hreflang Tags for SEO
// app/[locale]/layout.tsx
import { i18nConfig, type Locale } from '@/lib/i18n/config';
export default function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: { locale: string };
}) {
const locale = params.locale as Locale;
return (
<html lang={locale}>
<head>
<AlternateLinks locale={locale} />
</head>
<body>{children}</body>
</html>
);
}
// Generate hreflang links
function AlternateLinks({ locale }: { locale: Locale }) {
const pathname = usePathname();
const pathWithoutLocale = pathname.replace(`/${locale}`, '');
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
return (
<>
{i18nConfig.locales.map((loc) => (
<link
key={loc}
rel="alternate"
hrefLang={loc}
href={`${baseUrl}/${loc}${pathWithoutLocale}`}
/>
))}
<link
rel="alternate"
hrefLang="x-default"
href={`${baseUrl}/${i18nConfig.defaultLocale}${pathWithoutLocale}`}
/>
</>
);
}
Formatting: Dates, Numbers, and Plurals
Unified Formatting Layer
// lib/i18n/formatting.ts
import { i18nConfig, type Locale } from './config';
// Get ICU locale for formatting
function getFormatLocale(locale: Locale): string {
return i18nConfig.formatLocales[locale] || locale;
}
// Number formatting
export function formatNumber(
value: number,
locale: Locale,
options?: Intl.NumberFormatOptions
): string {
return new Intl.NumberFormat(getFormatLocale(locale), options).format(value);
}
export function formatCurrency(
value: number,
locale: Locale,
currency: string = 'USD'
): string {
return formatNumber(value, locale, {
style: 'currency',
currency,
});
}
export function formatPercent(value: number, locale: Locale): string {
return formatNumber(value, locale, {
style: 'percent',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
}
// Date formatting
export function formatDate(
date: Date | string | number,
locale: Locale,
options?: Intl.DateTimeFormatOptions
): string {
const d = typeof date === 'string' || typeof date === 'number'
? new Date(date)
: date;
return new Intl.DateTimeFormat(getFormatLocale(locale), {
dateStyle: 'medium',
...options,
}).format(d);
}
export function formatRelativeTime(
date: Date | string | number,
locale: Locale
): string {
const d = typeof date === 'string' || typeof date === 'number'
? new Date(date)
: date;
const now = new Date();
const diffInSeconds = Math.floor((d.getTime() - now.getTime()) / 1000);
const rtf = new Intl.RelativeTimeFormat(getFormatLocale(locale), {
numeric: 'auto',
});
// Determine best unit
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
['year', 60 * 60 * 24 * 365],
['month', 60 * 60 * 24 * 30],
['week', 60 * 60 * 24 * 7],
['day', 60 * 60 * 24],
['hour', 60 * 60],
['minute', 60],
['second', 1],
];
for (const [unit, secondsInUnit] of units) {
if (Math.abs(diffInSeconds) >= secondsInUnit) {
const value = Math.round(diffInSeconds / secondsInUnit);
return rtf.format(value, unit);
}
}
return rtf.format(0, 'second');
}
// List formatting
export function formatList(
items: string[],
locale: Locale,
options?: Intl.ListFormatOptions
): string {
return new Intl.ListFormat(getFormatLocale(locale), {
style: 'long',
type: 'conjunction',
...options,
}).format(items);
}
ICU Message Syntax for Complex Plurals
// locales/en/products.json
{
"results": "{count, plural, =0 {No products found} one {# product found} other {# products found}}",
"cartItems": "{count, plural, =0 {Your cart is empty} one {# item in cart} other {# items in cart}}",
"reviews": "{count, plural, =0 {No reviews yet} one {# review} other {# reviews}}",
// Ordinals
"ranking": "{position, selectordinal, one {#st place} two {#nd place} few {#rd place} other {#th place}}",
// Select (gender, etc.)
"greeting": "{gender, select, male {Welcome back, Mr. {name}} female {Welcome back, Ms. {name}} other {Welcome back, {name}}}",
// Nested
"notification": "{count, plural, =0 {No new notifications} one {{gender, select, male {He sent} female {She sent} other {They sent}} you a message} other {You have # new notifications}}"
}
// Usage with i18next
import { t } from 'i18next';
t('products:results', { count: 0 }); // "No products found"
t('products:results', { count: 1 }); // "1 product found"
t('products:results', { count: 42 }); // "42 products found"
t('products:ranking', { position: 1 }); // "1st place"
t('products:ranking', { position: 2 }); // "2nd place"
t('products:ranking', { position: 3 }); // "3rd place"
t('products:ranking', { position: 4 }); // "4th place"
React Components for Formatting
// components/i18n/FormattedValue.tsx
'use client';
import { useLocale } from '@/lib/navigation';
import * as fmt from '@/lib/i18n/formatting';
export function FormattedNumber({
value,
options,
}: {
value: number;
options?: Intl.NumberFormatOptions;
}) {
const locale = useLocale();
return <>{fmt.formatNumber(value, locale, options)}</>;
}
export function FormattedCurrency({
value,
currency = 'USD',
}: {
value: number;
currency?: string;
}) {
const locale = useLocale();
return <>{fmt.formatCurrency(value, locale, currency)}</>;
}
export function FormattedDate({
value,
options,
}: {
value: Date | string | number;
options?: Intl.DateTimeFormatOptions;
}) {
const locale = useLocale();
return <time dateTime={new Date(value).toISOString()}>
{fmt.formatDate(value, locale, options)}
</time>;
}
export function FormattedRelativeTime({
value,
}: {
value: Date | string | number;
}) {
const locale = useLocale();
return <time dateTime={new Date(value).toISOString()}>
{fmt.formatRelativeTime(value, locale)}
</time>;
}
// Usage
<FormattedCurrency value={99.99} currency="EUR" /> // €99,99 (de) or €99.99 (en)
<FormattedDate value={new Date()} /> // Feb 21, 2025 (en) or 21.02.2025 (de)
<FormattedRelativeTime value={Date.now() - 3600000} /> // 1 hour ago
Common Pitfalls and Tech Debt
┌─────────────────────────────────────────────────────────────────────────────┐
│ I18N TECH DEBT PATTERNS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Hardcoded Strings │
│ ──────────────────────────────────── │
│ ❌ <button>Submit</button> │
│ ❌ console.log('User logged in'); │
│ ❌ throw new Error('Invalid email'); │
│ │
│ Fix: Lint rules + pre-commit hooks │
│ eslint-plugin-i18next/no-literal-string │
│ │
│ ──────────────────────────────────────────────────────────────────────── │
│ │
│ 2. String Concatenation │
│ ──────────────────────────────────── │
│ ❌ t('hello') + ' ' + userName + '!' │
│ ❌ `You have ${count} items` │
│ │
│ Fix: Use interpolation │
│ ✅ t('greeting', { name: userName }) │
│ ✅ t('itemCount', { count }) │
│ │
│ Why: Word order differs between languages │
│ EN: "Hello John!" → JA: "ジョンさん、こんにちは!" │
│ │
│ ──────────────────────────────────────────────────────────────────────── │
│ │
│ 3. Assuming Text Length │
│ ──────────────────────────────────── │
│ ❌ CSS that breaks with longer text │
│ ❌ Fixed-width buttons/containers │
│ ❌ Truncation without considering locale │
│ │
│ Fix: Design for expansion │
│ German text is ~30% longer than English │
│ Use min-width, flex, word-wrap │
│ │
│ ──────────────────────────────────────────────────────────────────────── │
│ │
│ 4. Date/Number Format Assumptions │
│ ──────────────────────────────────── │
│ ❌ date.toLocaleDateString() // Uses browser locale │
│ ❌ number.toFixed(2) // Ignores locale decimal separator │
│ │
│ Fix: Always pass explicit locale │
│ ✅ new Intl.DateTimeFormat(locale).format(date) │
│ ✅ new Intl.NumberFormat(locale).format(number) │
│ │
│ ──────────────────────────────────────────────────────────────────────── │
│ │
│ 5. Images/Icons with Text │
│ ──────────────────────────────────── │
│ ❌ Banner images with embedded text │
│ ❌ Icons that include words │
│ │
│ Fix: Separate text from images │
│ Use CSS text overlays or locale-specific assets │
│ │
│ ──────────────────────────────────────────────────────────────────────── │
│ │
│ 6. Incomplete RTL Support │
│ ──────────────────────────────────── │
│ ❌ Using margin-left instead of margin-inline-start │
│ ❌ Icons that don't flip │
│ ❌ Not testing with actual RTL content │
│ │
│ Fix: Use logical properties from day one │
│ Test with Arabic/Hebrew early │
│ │
│ ──────────────────────────────────────────────────────────────────────── │
│ │
│ 7. Missing Pluralization │
│ ──────────────────────────────────── │
│ ❌ `${count} item${count !== 1 ? 's' : ''}` │
│ │
│ Fix: Use ICU plural rules (languages have complex plural forms) │
│ Russian: 1 товар, 2 товара, 5 товаров, 21 товар │
│ Arabic: 6 different plural forms │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
ESLint Configuration
// .eslintrc.js
module.exports = {
plugins: ['i18next'],
rules: {
'i18next/no-literal-string': [
'error',
{
// Ignore certain patterns
ignoreCallee: ['console.log', 'console.warn', 'console.error'],
ignoreAttribute: ['data-testid', 'className', 'href', 'src', 'alt'],
ignoreProperty: ['key', 'ref'],
},
],
},
overrides: [
{
// Stricter in components
files: ['src/components/**/*.tsx', 'app/**/*.tsx'],
rules: {
'i18next/no-literal-string': 'error',
},
},
{
// Relaxed in tests
files: ['**/*.test.ts', '**/*.test.tsx'],
rules: {
'i18next/no-literal-string': 'off',
},
},
],
};
Production Checklist
┌─────────────────────────────────────────────────────────────────────────────┐
│ I18N PRODUCTION CHECKLIST │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Architecture │
│ □ Namespace strategy defined and documented │
│ □ Lazy loading implemented per namespace │
│ □ Common namespace kept small (< 200 keys) │
│ □ CODEOWNERS for translation files │
│ │
│ Routing │
│ □ Locale in URL path (/en/products) │
│ □ Middleware handles detection and redirect │
│ □ Locale persisted in cookie │
│ □ Language switcher preserves current page │
│ │
│ SEO │
│ □ hreflang tags on all pages │
│ □ Canonical URLs per locale │
│ □ Sitemap per locale │
│ □ Locale-specific meta descriptions │
│ │
│ RTL Support │
│ □ CSS logical properties used throughout │
│ □ dir attribute set on html element │
│ □ Directional icons flip appropriately │
│ □ Tested with actual Arabic/Hebrew content │
│ │
│ Formatting │
│ □ Dates use Intl.DateTimeFormat with explicit locale │
│ □ Numbers use Intl.NumberFormat with explicit locale │
│ □ Pluralization uses ICU format │
│ □ No string concatenation for dynamic content │
│ │
│ Quality │
│ □ ESLint rule for hardcoded strings │
│ □ Translation completeness checks in CI │
│ □ Missing key detection and reporting │
│ □ Pseudo-localization for testing │
│ │
│ Performance │
│ □ Translation files code-split │
│ □ Only required namespaces loaded │
│ □ CDN caching for translation files │
│ □ Bundle size monitored per locale │
│ │
│ Workflow │
│ □ Translation management system integrated │
│ □ String extraction automated │
│ □ Review process for translation changes │
│ □ Fallback handling for missing translations │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
The Bottom Line
I18n architecture decisions compound. What seems like a small choice early—flat vs nested keys, loading strategy, RTL approach—becomes immovable infrastructure as you scale.
The key principles:
-
Namespace by domain, not by page. Features change, pages merge and split. Domain boundaries are stable.
-
Lazy load everything except common. The bundle size of 50 locales × 10 namespaces × 500 keys is catastrophic if loaded upfront.
-
Use logical CSS properties from day one. Retrofitting RTL support is exponentially harder than building it in.
-
Locale belongs in the URL. SEO, caching, sharing, debugging—everything is simpler with locale in the path.
-
Never concatenate strings. Word order, grammar, and context vary by language. Use interpolation and ICU message format.
-
Lint aggressively. Hardcoded strings are tech debt that grows silently. Catch them automatically.
The cost of fixing i18n architecture after launch is measured in months, not days. Invest early, and the system scales effortlessly. Cut corners, and you'll rewrite it all when you expand to your third market.
Internationalization isn't a feature. It's infrastructure. Treat it accordingly.
What did you think?