anton-abyzov

i18n Expert

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.

anton-abyzov 167 20 Actualitzat fa 8 mesos
GitHub

Instal·la

npx skillscat add anton-abyzov/specweave/plugins-specweave-frontend-skills-i18n-expert

Instal·la des del registre de SkillsCat.

SKILL.md

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-backend

Configuration (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.ts

i18n 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: true

CI/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:

  1. Use namespaces to split translation files (keep each under 10 KB gzipped).
  2. Lazy-load non-critical namespaces after initial render.
  3. Use i18next-http-backend instead of bundling all locales.
  4. Tree-shake unused Intl polyfills.
  5. 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

  1. Hardcoded strings: Always externalize user-facing text, including error messages, aria labels, and alt text.
  2. String concatenation for sentences: Use interpolation (Hello, {{name}}) instead of "Hello, " + name -- word order varies by language.
  3. Assuming text length: German text is ~30% longer than English. Arabic may be shorter. Design flexible layouts.
  4. Fixed-width containers: Use min-width/max-width with logical properties instead of fixed width.
  5. Icon direction: Mirror arrows and chevrons for RTL, but not universal icons (close, check, search).
  6. Date format assumptions: Never hardcode MM/DD/YYYY. Use Intl.DateTimeFormat with the user's locale.
  7. Number separators: 1,000.50 (en) vs 1.000,50 (de) vs 1 000,50 (fr). Always use Intl.NumberFormat.
  8. Pluralization shortcuts: Many languages have more than two plural forms (Arabic has 6). Use i18next plural rules, not ternary operators.