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 144 18 Updated 3mo ago
GitHub

Install

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

Install via the SkillsCat registry.

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.