Frontend i18n and l10n expert. Use for multilingual apps, i18next/react-i18next, Next.js i18n routing, RTL layouts, Intl APIs, date/number/currency formatting, translation pipelines.
Install
npx skillscat add anton-abyzov/specweave/plugins-specweave-frontend-skills-i18n-expert Install via the SkillsCat registry.
i18n Expert
You are an expert in frontend internationalization (i18n) and localization (l10n). You help teams build multilingual applications that handle translations, locale-aware formatting, RTL layouts, and translation management workflows.
Triggers: i18n, internationalization, translation, localization, l10n, RTL, multilingual, locale, react-i18next, hreflang, Intl, pluralization, Crowdin, Lokalise
Core Expertise
1. i18next / react-i18next Setup
Installation:
npm install i18next react-i18next i18next-browser-languagedetector i18next-http-backendConfiguration (src/i18n/index.ts):
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import HttpBackend from 'i18next-http-backend';
i18n
.use(HttpBackend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: 'en',
supportedLngs: ['en', 'de', 'fr', 'ar', 'ja', 'zh'],
defaultNS: 'common',
ns: ['common', 'auth', 'dashboard', 'errors'],
interpolation: {
escapeValue: false, // React already escapes
},
detection: {
order: ['querystring', 'cookie', 'localStorage', 'navigator', 'htmlTag'],
lookupQuerystring: 'lng',
lookupCookie: 'i18next',
lookupLocalStorage: 'i18nextLng',
caches: ['localStorage', 'cookie'],
},
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
react: {
useSuspense: true,
},
});
export default i18n;Namespace Organization:
public/locales/
├── en/
│ ├── common.json # Shared: buttons, labels, navigation
│ ├── auth.json # Login, signup, password reset
│ ├── dashboard.json # Dashboard-specific strings
│ ├── errors.json # Error messages
│ └── validation.json # Form validation messages
├── de/
│ ├── common.json
│ └── ...
└── ar/
├── common.json
└── ...Namespace JSON structure (en/common.json):
{
"nav": {
"home": "Home",
"about": "About",
"settings": "Settings"
},
"actions": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"confirm": "Are you sure?"
},
"greeting": "Hello, {{name}}!"
}Component Usage:
import { useTranslation } from 'react-i18next';
function Dashboard() {
const { t } = useTranslation('dashboard');
const { t: tCommon } = useTranslation('common');
return (
<div>
<h1>{t('title')}</h1>
<p>{tCommon('greeting', { name: 'Alice' })}</p>
<button>{tCommon('actions.save')}</button>
</div>
);
}Pluralization (en/common.json):
{
"items_count": "{{count}} item",
"items_count_other": "{{count}} items",
"items_count_zero": "No items"
}// Automatically selects the correct plural form
t('items_count', { count: 0 }); // "No items"
t('items_count', { count: 1 }); // "1 item"
t('items_count', { count: 5 }); // "5 items"Context-based translations (e.g., gendered text):
{
"friend": "A friend",
"friend_male": "A boyfriend",
"friend_female": "A girlfriend"
}t('friend', { context: 'male' }); // "A boyfriend"Language Switcher Component:
import { useTranslation } from 'react-i18next';
const languages = [
{ code: 'en', label: 'English', dir: 'ltr' },
{ code: 'de', label: 'Deutsch', dir: 'ltr' },
{ code: 'ar', label: 'العربية', dir: 'rtl' },
{ code: 'ja', label: '日本語', dir: 'ltr' },
];
function LanguageSwitcher() {
const { i18n } = useTranslation();
const changeLanguage = (code: string) => {
i18n.changeLanguage(code);
const lang = languages.find((l) => l.code === code);
document.documentElement.dir = lang?.dir ?? 'ltr';
document.documentElement.lang = code;
};
return (
<select
value={i18n.language}
onChange={(e) => changeLanguage(e.target.value)}
aria-label="Select language"
>
{languages.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.label}
</option>
))}
</select>
);
}2. Next.js i18n (App Router)
Directory-based i18n routing:
app/
├── [locale]/
│ ├── layout.tsx
│ ├── page.tsx
│ ├── about/
│ │ └── page.tsx
│ └── dashboard/
│ └── page.tsx
├── middleware.ts
└── i18n/
├── config.ts
├── request.ts
└── dictionaries.tsi18n Config (i18n/config.ts):
export const i18nConfig = {
defaultLocale: 'en',
locales: ['en', 'de', 'fr', 'ar', 'ja'],
} as const;
export type Locale = (typeof i18nConfig.locales)[number];Middleware for Language Detection (middleware.ts):
import { NextRequest, NextResponse } from 'next/server';
import { i18nConfig } from './i18n/config';
function getLocale(request: NextRequest): string {
// 1. Check cookie
const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value;
if (cookieLocale && i18nConfig.locales.includes(cookieLocale as any)) {
return cookieLocale;
}
// 2. Check Accept-Language header
const acceptLanguage = request.headers.get('accept-language');
if (acceptLanguage) {
const preferred = acceptLanguage
.split(',')
.map((lang) => lang.split(';')[0].trim().substring(0, 2))
.find((lang) => i18nConfig.locales.includes(lang as any));
if (preferred) return preferred;
}
return i18nConfig.defaultLocale;
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Skip static assets, API routes, and _next
if (
pathname.startsWith('/_next') ||
pathname.startsWith('/api') ||
pathname.includes('.')
) {
return;
}
// Check if pathname has a locale prefix
const pathnameHasLocale = i18nConfig.locales.some(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (pathnameHasLocale) return;
// Redirect to locale-prefixed URL
const locale = getLocale(request);
const newUrl = new URL(`/${locale}${pathname}`, request.url);
return NextResponse.redirect(newUrl);
}
export const config = {
matcher: ['/((?!_next|api|favicon.ico|robots.txt|sitemap.xml).*)'],
};Dictionary Loading (i18n/dictionaries.ts):
import type { Locale } from './config';
const dictionaries = {
en: () => import('../dictionaries/en.json').then((m) => m.default),
de: () => import('../dictionaries/de.json').then((m) => m.default),
fr: () => import('../dictionaries/fr.json').then((m) => m.default),
ar: () => import('../dictionaries/ar.json').then((m) => m.default),
ja: () => import('../dictionaries/ja.json').then((m) => m.default),
};
export function getDictionary(locale: Locale) {
return dictionaries[locale]();
}Locale Layout (app/[locale]/layout.tsx):
import { i18nConfig, type Locale } from '@/i18n/config';
import { notFound } from 'next/navigation';
export function generateStaticParams() {
return i18nConfig.locales.map((locale) => ({ locale }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: Locale }>;
}) {
const { locale } = await params;
return {
alternates: {
canonical: `https://example.com/${locale}`,
languages: Object.fromEntries(
i18nConfig.locales.map((l) => [l, `https://example.com/${l}`])
),
},
};
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: Locale }>;
}) {
const { locale } = await params;
if (!i18nConfig.locales.includes(locale)) {
notFound();
}
const dir = locale === 'ar' ? 'rtl' : 'ltr';
return (
<html lang={locale} dir={dir}>
<body>{children}</body>
</html>
);
}SEO: hreflang Tags (in root layout <head>):
import { i18nConfig } from '@/i18n/config';
function HreflangTags({ currentPath }: { currentPath: string }) {
const baseUrl = 'https://example.com';
return (
<>
{i18nConfig.locales.map((locale) => (
<link
key={locale}
rel="alternate"
hrefLang={locale}
href={`${baseUrl}/${locale}${currentPath}`}
/>
))}
<link
rel="alternate"
hrefLang="x-default"
href={`${baseUrl}/${i18nConfig.defaultLocale}${currentPath}`}
/>
</>
);
}Locale Page with Dictionary (app/[locale]/page.tsx):
import { getDictionary } from '@/i18n/dictionaries';
import type { Locale } from '@/i18n/config';
export default async function HomePage({
params,
}: {
params: Promise<{ locale: Locale }>;
}) {
const { locale } = await params;
const dict = await getDictionary(locale);
return (
<main>
<h1>{dict.home.title}</h1>
<p>{dict.home.description}</p>
</main>
);
}3. RTL Support
CSS Logical Properties (replace physical properties with logical equivalents):
| Physical (avoid) | Logical (prefer) |
|---|---|
margin-left |
margin-inline-start |
margin-right |
margin-inline-end |
padding-left |
padding-inline-start |
padding-right |
padding-inline-end |
text-align: left |
text-align: start |
text-align: right |
text-align: end |
float: left |
float: inline-start |
border-left |
border-inline-start |
left: 0 |
inset-inline-start: 0 |
right: 0 |
inset-inline-end: 0 |
width |
inline-size |
height |
block-size |
RTL-safe CSS example:
.sidebar {
/* Physical (breaks RTL): */
/* padding-left: 1rem; margin-right: 2rem; */
/* Logical (works in LTR and RTL): */
padding-inline-start: 1rem;
margin-inline-end: 2rem;
border-inline-start: 3px solid var(--accent);
inset-inline-start: 0;
}
.card {
text-align: start;
display: flex;
flex-direction: row; /* Flex auto-reverses in RTL */
gap: 1rem;
}Tailwind CSS RTL Plugin:
npm install tailwindcss-rtl// tailwind.config.ts
import type { Config } from 'tailwindcss';
import rtlPlugin from 'tailwindcss-rtl';
export default {
plugins: [rtlPlugin],
} satisfies Config;Usage with rtl: and ltr: variants:
<div className="ps-4 pe-2 text-start">
{/* ps = padding-inline-start, pe = padding-inline-end */}
<span className="ms-2 me-4">
{/* ms = margin-inline-start, me = margin-inline-end */}
Bidirectional text
</span>
</div>
{/* Conditional styles for specific directions */}
<div className="ltr:pl-4 rtl:pr-4 ltr:text-left rtl:text-right">
Direction-specific override
</div>Tailwind v3.3+ built-in logical properties (no plugin needed):
<div className="ps-4 pe-2 ms-2 me-4 text-start">
{/* These use CSS logical properties natively */}
</div>RTL Context Provider:
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
type Direction = 'ltr' | 'rtl';
const RTL_LOCALES = new Set(['ar', 'he', 'fa', 'ur']);
const DirectionContext = createContext<Direction>('ltr');
export function DirectionProvider({
locale,
children,
}: {
locale: string;
children: ReactNode;
}) {
const dir: Direction = RTL_LOCALES.has(locale) ? 'rtl' : 'ltr';
useEffect(() => {
document.documentElement.dir = dir;
document.documentElement.lang = locale;
}, [dir, locale]);
return (
<DirectionContext.Provider value={dir}>
{children}
</DirectionContext.Provider>
);
}
export function useDirection() {
return useContext(DirectionContext);
}Bidirectional text handling:
{/* Isolate embedded text that may have different directionality */}
<p>
User <bdi>{userName}</bdi> posted a comment.
</p>
{/* Force direction for specific content */}
<span dir="ltr">+1 (555) 123-4567</span>
{/* Unicode control characters for mixed content */}
<span>{'\u200F'}{arabicText}{'\u200F'}</span>Icon mirroring for RTL:
/* Mirror directional icons (arrows, chevrons) in RTL */
[dir='rtl'] .icon-directional {
transform: scaleX(-1);
}
/* Do NOT mirror non-directional icons (checkmarks, close, etc.) */4. Date / Number / Currency Formatting
Intl.DateTimeFormat:
function formatDate(
date: Date,
locale: string,
options?: Intl.DateTimeFormatOptions
): string {
const defaults: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
day: 'numeric',
};
return new Intl.DateTimeFormat(locale, { ...defaults, ...options }).format(date);
}
// Examples:
formatDate(new Date(), 'en-US'); // "February 11, 2026"
formatDate(new Date(), 'de-DE'); // "11. Februar 2026"
formatDate(new Date(), 'ja-JP'); // "2026年2月11日"
formatDate(new Date(), 'ar-SA'); // "١١ فبراير ٢٠٢٦"
// Short format
formatDate(new Date(), 'en-US', {
year: 'numeric', month: 'short', day: 'numeric',
}); // "Feb 11, 2026"
// Date and time
formatDate(new Date(), 'en-US', {
dateStyle: 'full',
timeStyle: 'short',
}); // "Wednesday, February 11, 2026 at 3:45 PM"Intl.NumberFormat for Currencies:
function formatCurrency(
amount: number,
currency: string,
locale: string
): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
minimumFractionDigits: 2,
}).format(amount);
}
// Examples:
formatCurrency(1234.5, 'USD', 'en-US'); // "$1,234.50"
formatCurrency(1234.5, 'EUR', 'de-DE'); // "1.234,50 €"
formatCurrency(1234.5, 'JPY', 'ja-JP'); // "¥1,235"
formatCurrency(1234.5, 'SAR', 'ar-SA'); // "١٬٢٣٤٫٥٠ ر.س."
// Compact notation
new Intl.NumberFormat('en', {
notation: 'compact',
compactDisplay: 'short',
}).format(1500000); // "1.5M"
// Percentage
new Intl.NumberFormat('en', {
style: 'percent',
minimumFractionDigits: 1,
}).format(0.856); // "85.6%"
// Unit formatting
new Intl.NumberFormat('en', {
style: 'unit',
unit: 'kilometer-per-hour',
}).format(120); // "120 km/h"Intl.RelativeTimeFormat:
function formatRelativeTime(date: Date, locale: string): string {
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
const now = Date.now();
const diffMs = date.getTime() - now;
const diffSec = Math.round(diffMs / 1000);
const diffMin = Math.round(diffSec / 60);
const diffHr = Math.round(diffMin / 60);
const diffDay = Math.round(diffHr / 24);
if (Math.abs(diffSec) < 60) return rtf.format(diffSec, 'second');
if (Math.abs(diffMin) < 60) return rtf.format(diffMin, 'minute');
if (Math.abs(diffHr) < 24) return rtf.format(diffHr, 'hour');
if (Math.abs(diffDay) < 30) return rtf.format(diffDay, 'day');
const diffMonth = Math.round(diffDay / 30);
if (Math.abs(diffMonth) < 12) return rtf.format(diffMonth, 'month');
return rtf.format(Math.round(diffDay / 365), 'year');
}
// Examples:
// formatRelativeTime(yesterday, 'en') -> "yesterday"
// formatRelativeTime(twoHoursAgo, 'de') -> "vor 2 Stunden"
// formatRelativeTime(nextWeek, 'ja') -> "7日後"Timezone Handling:
// Display in user's timezone
function formatWithTimezone(
date: Date,
locale: string,
timeZone: string
): string {
return new Intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeStyle: 'long',
timeZone,
}).format(date);
}
formatWithTimezone(new Date(), 'en-US', 'America/New_York');
// "Feb 11, 2026, 3:45:00 PM EST"
formatWithTimezone(new Date(), 'en-US', 'Asia/Tokyo');
// "Feb 12, 2026, 5:45:00 AM JST"
// Get user's timezone
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;Reusable Formatting Hook:
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
export function useFormatters() {
const { i18n } = useTranslation();
const locale = i18n.language;
return useMemo(() => ({
date: (date: Date, options?: Intl.DateTimeFormatOptions) =>
new Intl.DateTimeFormat(locale, options).format(date),
number: (value: number, options?: Intl.NumberFormatOptions) =>
new Intl.NumberFormat(locale, options).format(value),
currency: (amount: number, currency: string) =>
new Intl.NumberFormat(locale, {
style: 'currency',
currency,
}).format(amount),
relativeTime: (date: Date) =>
formatRelativeTime(date, locale),
list: (items: string[], type: Intl.ListFormatType = 'conjunction') =>
new Intl.ListFormat(locale, { type }).format(items),
}), [locale]);
}
// Usage:
function PriceDisplay({ amount, currency }: { amount: number; currency: string }) {
const fmt = useFormatters();
return <span>{fmt.currency(amount, currency)}</span>;
}5. Translation Management Workflow
Crowdin Integration:
crowdin.yml:
project_id_env: CROWDIN_PROJECT_ID
api_token_env: CROWDIN_API_TOKEN
files:
- source: /public/locales/en/**/*.json
translation: /public/locales/%two_letters_code%/**/%original_file_name%
type: json
preserve_hierarchy: trueCI/CD pipeline (GitHub Actions):
name: Translation Sync
on:
push:
branches: [main]
paths:
- 'public/locales/en/**'
schedule:
- cron: '0 6 * * 1' # Weekly Monday 6 AM
jobs:
upload-sources:
runs-on: ubuntu-latest
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- uses: crowdin/github-action@v2
with:
upload_sources: true
upload_translations: false
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_API_TOKEN }}
download-translations:
runs-on: ubuntu-latest
if: github.event_name == 'schedule'
steps:
- uses: actions/checkout@v4
- uses: crowdin/github-action@v2
with:
upload_sources: false
download_translations: true
create_pull_request: true
pull_request_title: 'chore: update translations from Crowdin'
pull_request_base_branch_name: main
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_API_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}Lokalise Integration (alternative):
# .github/workflows/lokalise-sync.yml
name: Lokalise Sync
on:
push:
branches: [main]
paths: ['public/locales/en/**']
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Lokalise CLI
run: |
curl -sfL https://raw.githubusercontent.com/lokalise/lokalise-cli-2-go/master/install.sh | sh
- name: Upload source strings
run: |
./bin/lokalise2 file upload \
--token ${{ secrets.LOKALISE_API_TOKEN }} \
--project-id ${{ secrets.LOKALISE_PROJECT_ID }} \
--file "public/locales/en/common.json" \
--lang-iso en
- name: Download translations
run: |
./bin/lokalise2 file download \
--token ${{ secrets.LOKALISE_API_TOKEN }} \
--project-id ${{ secrets.LOKALISE_PROJECT_ID }} \
--format json \
--original-filenames=true \
--directory-prefix "public/locales/%LANG_ISO%"Missing Translation Detection:
// scripts/check-translations.ts
import fs from 'node:fs';
import path from 'node:path';
const LOCALES_DIR = 'public/locales';
const SOURCE_LOCALE = 'en';
function getKeys(obj: Record<string, unknown>, prefix = ''): string[] {
return Object.entries(obj).flatMap(([key, value]) => {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'object' && value !== null) {
return getKeys(value as Record<string, unknown>, fullKey);
}
return [fullKey];
});
}
function checkTranslations() {
const sourceDir = path.join(LOCALES_DIR, SOURCE_LOCALE);
const locales = fs
.readdirSync(LOCALES_DIR)
.filter((d) => d !== SOURCE_LOCALE && fs.statSync(path.join(LOCALES_DIR, d)).isDirectory());
let hasErrors = false;
for (const file of fs.readdirSync(sourceDir)) {
const sourceContent = JSON.parse(
fs.readFileSync(path.join(sourceDir, file), 'utf-8')
);
const sourceKeys = getKeys(sourceContent);
for (const locale of locales) {
const targetPath = path.join(LOCALES_DIR, locale, file);
if (!fs.existsSync(targetPath)) {
console.error(`MISSING FILE: ${locale}/${file}`);
hasErrors = true;
continue;
}
const targetContent = JSON.parse(fs.readFileSync(targetPath, 'utf-8'));
const targetKeys = getKeys(targetContent);
const missing = sourceKeys.filter((k) => !targetKeys.includes(k));
const extra = targetKeys.filter((k) => !sourceKeys.includes(k));
if (missing.length > 0) {
console.error(`${locale}/${file} - MISSING ${missing.length} keys:`);
missing.forEach((k) => console.error(` - ${k}`));
hasErrors = true;
}
if (extra.length > 0) {
console.warn(`${locale}/${file} - EXTRA ${extra.length} keys:`);
extra.forEach((k) => console.warn(` + ${k}`));
}
}
}
process.exit(hasErrors ? 1 : 0);
}
checkTranslations();Add to CI:
{
"scripts": {
"i18n:check": "tsx scripts/check-translations.ts"
}
}Fallback Chain Strategy (i18next):
i18n.init({
fallbackLng: {
'de-AT': ['de', 'en'], // Austrian German -> German -> English
'pt-BR': ['pt', 'en'], // Brazilian Portuguese -> Portuguese -> English
'zh-TW': ['zh-Hant', 'en'], // Traditional Chinese -> English
default: ['en'],
},
// Show key name for missing translations in dev
saveMissing: process.env.NODE_ENV === 'development',
missingKeyHandler: (lngs, ns, key) => {
console.warn(`Missing translation: [${lngs}] ${ns}:${key}`);
},
});6. Performance
Code Splitting Translations by Route:
// i18next lazy-loading with namespaces per route
i18n.init({
partialBundledLanguages: true,
ns: [], // Start empty, load on demand
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
});
// In route component, load namespace on mount
function DashboardPage() {
const { t, ready } = useTranslation('dashboard', { useSuspense: true });
if (!ready) return <Skeleton />;
return <div>{t('welcome')}</div>;
}Dynamic Import of Locale Data (for date-fns or similar):
const localeImports: Record<string, () => Promise<Locale>> = {
en: () => import('date-fns/locale/enUS').then((m) => m.enUS),
de: () => import('date-fns/locale/de').then((m) => m.de),
fr: () => import('date-fns/locale/fr').then((m) => m.fr),
ja: () => import('date-fns/locale/ja').then((m) => m.ja),
ar: () => import('date-fns/locale/arSA').then((m) => m.arSA),
};
async function getDateLocale(lang: string): Promise<Locale> {
const loader = localeImports[lang] ?? localeImports.en;
return loader();
}Bundle Size Optimization:
- Use namespaces to split translation files (keep each under 10 KB gzipped).
- Lazy-load non-critical namespaces after initial render.
- Use
i18next-http-backendinstead of bundling all locales. - Tree-shake unused Intl polyfills.
- Pre-compress translation JSON with gzip/brotli on CDN.
// Webpack/Next.js: exclude unused moment/date-fns locales
// next.config.ts
import type { NextConfig } from 'next';
const config: NextConfig = {
webpack(config) {
// Only include needed locales for moment.js (if used)
config.plugins.push(
new (require('webpack')).ContextReplacementPlugin(
/moment[/\\]locale$/,
/en|de|fr|ar|ja/
)
);
return config;
},
};
export default config;Translation Preloading:
// Preload critical namespaces at app startup
await i18n.loadNamespaces(['common', 'auth']);
// Preload next page translations on hover/focus
function NavLink({ href, ns, children }: {
href: string;
ns: string;
children: React.ReactNode;
}) {
const { i18n } = useTranslation();
const preloadTranslations = () => {
i18n.loadNamespaces(ns);
};
return (
<Link
href={href}
onMouseEnter={preloadTranslations}
onFocus={preloadTranslations}
>
{children}
</Link>
);
}Decision Guide
| Scenario | Recommendation |
|---|---|
| SPA with React | i18next + react-i18next + HTTP backend |
| Next.js App Router | Built-in [locale] routing + server dictionaries |
| Need RTL | CSS logical properties + Tailwind ps/pe utilities |
| Date/number formatting | Native Intl APIs (zero bundle cost) |
| Translation management | Crowdin (open-source friendly) or Lokalise (developer-focused) |
| Large app (50+ routes) | Namespace-per-route + lazy loading |
| SEO-critical pages | generateStaticParams + hreflang + language alternates |
Common Pitfalls
- Hardcoded strings: Always externalize user-facing text, including error messages, aria labels, and alt text.
- String concatenation for sentences: Use interpolation (
Hello, {{name}}) instead of"Hello, " + name-- word order varies by language. - Assuming text length: German text is ~30% longer than English. Arabic may be shorter. Design flexible layouts.
- Fixed-width containers: Use
min-width/max-widthwith logical properties instead of fixedwidth. - Icon direction: Mirror arrows and chevrons for RTL, but not universal icons (close, check, search).
- Date format assumptions: Never hardcode
MM/DD/YYYY. UseIntl.DateTimeFormatwith the user's locale. - Number separators:
1,000.50(en) vs1.000,50(de) vs1 000,50(fr). Always useIntl.NumberFormat. - Pluralization shortcuts: Many languages have more than two plural forms (Arabic has 6). Use i18next plural rules, not ternary operators.