i18n-localization

Internationalization and localization for web applications. Handles next-intl, react-intl, ICU MessageFormat, RTL layouts, and dynamic locale loading.

i18n-localization

Internationalization (i18n) and localization (l10n) for web applications. Implements proper message formatting, locale routing, RTL support, and dynamic translation loading.

Translation File Structure

BAD: Flat structure, mixed namespaces

// messages/en.json
{
  "welcomeMessage": "Welcome",
  "homePageTitle": "Home",
  "userProfileName": "Name",
  "userProfileEmail": "Email",
  "checkoutButtonSubmit": "Submit Order"
}

GOOD: Namespaced structure

// messages/en/common.json
{
  "welcome": "Welcome",
  "loading": "Loading...",
  "error": "An error occurred"
}

// messages/en/home.json
{
  "title": "Home",
  "hero": {
    "heading": "Welcome to our platform",
    "subheading": "Build amazing things"
  }
}

// messages/en/user.json
{
  "profile": {
    "name": "Name",
    "email": "Email",
    "updatedAt": "Last updated {date}"
  }
}

ICU MessageFormat - Plurals and Select

BAD: String concatenation

// DON'T: Breaks in many languages
const message = count === 1
  ? `You have ${count} message`
  : `You have ${count} messages`;

// DON'T: No gender agreement support
const greeting = gender === 'male' ? `Welcome, Mr. ${name}` : `Welcome, Ms. ${name}`;

GOOD: ICU MessageFormat

{
  "messages": {
    "count": "{count, plural, =0 {No messages} one {# message} other {# messages}}"
  },
  "greeting": {
    "formal": "{gender, select, male {Welcome, Mr. {name}} female {Welcome, Ms. {name}} other {Welcome, {name}}}"
  },
  "cart": {
    "items": "{itemCount, plural, =0 {Your cart is empty} one {# item in cart} other {# items in cart}}",
    "total": "{total, number, ::currency/USD}"
  }
}
// Usage with react-intl
import { useIntl } from 'react-intl';

function MessageCount({ count }: { count: number }) {
  const intl = useIntl();
  return <p>{intl.formatMessage({ id: 'messages.count' }, { count })}</p>;
}

Next.js 16 with next-intl

BAD: Client-side only, no routing

// app/page.tsx - DON'T
'use client';
import { useState } from 'react';

export default function Page() {
  const [locale, setLocale] = useState('en');
  const messages = require(`@/messages/${locale}.json`);

  return <div>{messages.welcome}</div>;
}

GOOD: Server-side with middleware routing

// i18n.ts
import { notFound } from 'next/navigation';
import { getRequestConfig } from 'next-intl/server';

export const locales = ['en', 'es', 'fr', 'ar'] as const;
export type Locale = (typeof locales)[number];

export default getRequestConfig(async ({ locale }) => {
  if (!locales.includes(locale as Locale)) notFound();

  return {
    messages: (await import(`./messages/${locale}.json`)).default
  };
});

// middleware.ts
import createMiddleware from 'next-intl/middleware';
import { locales } from './i18n';

export default createMiddleware({
  locales,
  defaultLocale: 'en',
  localePrefix: 'always' // /en/about, /es/about
});

export const config = {
  matcher: ['/', '/(en|es|fr|ar)/:path*']
};

// app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { locales } from '@/i18n';

export function generateStaticParams() {
  return locales.map((locale) => ({ locale }));
}

export default async function LocaleLayout({
  children,
  params: { locale }
}: {
  children: React.ReactNode;
  params: { locale: string };
}) {
  if (!locales.includes(locale as any)) notFound();

  const messages = await getMessages();

  return (
    <html lang={locale} dir={locale === 'ar' ? 'rtl' : 'ltr'}>
      <body>
        <NextIntlClientProvider messages={messages}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

// app/[locale]/page.tsx
import { useTranslations } from 'next-intl';

export default function HomePage() {
  const t = useTranslations('home');

  return (
    <div>
      <h1>{t('title')}</h1>
      <p>{t('hero.heading')}</p>
    </div>
  );
}

Date and Number Formatting

BAD: Hardcoded formats

// DON'T: Breaks internationalization
const date = new Date().toLocaleDateString('en-US');
const price = `$${amount.toFixed(2)}`;
const percent = `${(value * 100).toFixed(1)}%`;

GOOD: Intl API

import { useIntl } from 'react-intl';

function ProductCard({ price, date, discount }: Props) {
  const intl = useIntl();

  // Date formatting
  const formattedDate = intl.formatDate(date, {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  });

  // Currency formatting
  const formattedPrice = intl.formatNumber(price, {
    style: 'currency',
    currency: 'USD'
  });

  // Percentage formatting
  const formattedDiscount = intl.formatNumber(discount, {
    style: 'percent',
    minimumFractionDigits: 0,
    maximumFractionDigits: 1
  });

  return (
    <div>
      <p>{formattedPrice}</p>
      <p>{formattedDiscount} off</p>
      <time>{formattedDate}</time>
    </div>
  );
}

// Or use native Intl API directly
const dateFormatter = new Intl.DateTimeFormat('es-ES', {
  dateStyle: 'full',
  timeStyle: 'short'
});

const numberFormatter = new Intl.NumberFormat('de-DE', {
  style: 'decimal',
  minimumFractionDigits: 2
});

console.log(dateFormatter.format(new Date())); // "domingo, 2 de marzo de 2026, 14:30"
console.log(numberFormatter.format(1234.5)); // "1.234,50"

RTL Layout Support

BAD: Hardcoded left/right

/* DON'T: Breaks in RTL languages */
.sidebar {
  float: left;
  margin-right: 20px;
  text-align: left;
}

.arrow {
  padding-left: 10px;
}

GOOD: Logical properties

/* Use logical properties */
.sidebar {
  float: inline-start;
  margin-inline-end: 20px;
  text-align: start;
}

.arrow {
  padding-inline-start: 10px;
}

/* Or use Tailwind RTL utilities */
.element {
  @apply ms-4 me-2; /* margin-start, margin-end */
  @apply ps-4 pe-2; /* padding-start, padding-end */
}

/* RTL-specific overrides */
[dir="rtl"] .custom-element {
  transform: scaleX(-1); /* Flip icons */
}
// tailwind.config.ts
import type { Config } from 'tailwindcss';

const config: Config = {
  content: ['./app/**/*.{ts,tsx}'],
  theme: {
    extend: {}
  },
  plugins: [
    require('tailwindcss-logical')
  ]
};

export default config;

Dynamic Locale Loading

BAD: Load all locales upfront

// DON'T: Bundles all translations
import en from './messages/en.json';
import es from './messages/es.json';
import fr from './messages/fr.json';
import ar from './messages/ar.json';

const messages = { en, es, fr, ar };

GOOD: Dynamic imports

// lib/i18n.ts
export async function loadMessages(locale: string) {
  try {
    const messages = await import(`@/messages/${locale}.json`);
    return messages.default;
  } catch (error) {
    console.error(`Failed to load messages for locale: ${locale}`);
    return import('@/messages/en.json').then(m => m.default);
  }
}

// For large translation files, split by namespace
export async function loadNamespace(locale: string, namespace: string) {
  const messages = await import(`@/messages/${locale}/${namespace}.json`);
  return messages.default;
}

// app/[locale]/dashboard/page.tsx
import { loadNamespace } from '@/lib/i18n';

export default async function DashboardPage({ params }: Props) {
  const messages = await loadNamespace(params.locale, 'dashboard');

  return <NextIntlClientProvider messages={messages}>
    {/* Dashboard content */}
  </NextIntlClientProvider>;
}

Language Detection and Switching

BAD: Manual cookie management

// DON'T: Unreliable, no fallback
'use client';
import { useRouter } from 'next/navigation';

function LangSwitcher() {
  const router = useRouter();

  const changeLocale = (locale: string) => {
    document.cookie = `locale=${locale}`;
    router.refresh();
  };

  return <button onClick={() => changeLocale('es')}>Español</button>;
}

GOOD: next-intl locale switching

// components/LocaleSwitcher.tsx
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, usePathname } from 'next/navigation';
import { locales } from '@/i18n';

export function LocaleSwitcher() {
  const t = useTranslations('common');
  const locale = useLocale();
  const router = useRouter();
  const pathname = usePathname();

  const changeLocale = (newLocale: string) => {
    // Replace locale in pathname
    const segments = pathname.split('/');
    segments[1] = newLocale;
    router.push(segments.join('/'));
  };

  return (
    <select
      value={locale}
      onChange={(e) => changeLocale(e.target.value)}
      aria-label={t('selectLanguage')}
    >
      {locales.map((loc) => (
        <option key={loc} value={loc}>
          {t(`locales.${loc}`)}
        </option>
      ))}
    </select>
  );
}

// With content negotiation fallback
// middleware.ts
import createMiddleware from 'next-intl/middleware';
import { locales } from './i18n';

export default createMiddleware({
  locales,
  defaultLocale: 'en',
  localeDetection: true, // Uses Accept-Language header
  localePrefix: 'as-needed' // Only add prefix for non-default locales
});

Type-Safe Translations

BAD: String keys, runtime errors

// DON'T: Typos cause runtime errors
const title = t('home.titel'); // Typo: "titel" instead of "title"
const message = t('user.proifle.name'); // Typo: "proifle"

GOOD: Generated types

// scripts/generate-types.ts
import fs from 'fs';
import path from 'path';

function generateTypes() {
  const enMessages = JSON.parse(
    fs.readFileSync('./messages/en.json', 'utf-8')
  );

  const types = generateTypeFromObject(enMessages);

  fs.writeFileSync(
    './types/messages.d.ts',
    `export type Messages = ${types};`
  );
}

// types/messages.d.ts (generated)
export type Messages = {
  common: {
    welcome: string;
    loading: string;
  };
  home: {
    title: string;
    hero: {
      heading: string;
      subheading: string;
    };
  };
  user: {
    profile: {
      name: string;
      email: string;
    };
  };
};

// Usage with type safety
import { useTranslations } from 'next-intl';

function HomePage() {
  const t = useTranslations('home');

  // TypeScript autocomplete and validation
  return <h1>{t('title')}</h1>; // ✓
  // return <h1>{t('titel')}</h1>; // ✗ TypeScript error
}

Common Patterns

Pluralization with Complex Rules

{
  "items": {
    "selected": "{count, plural, =0 {No items selected} one {# item selected} other {# items selected}}",
    "remaining": "{count, plural, =0 {All done!} one {# item left} other {# items left}}"
  }
}

Rich Text Formatting

// messages/en.json
{
  "terms": "By signing up, you agree to our <link>Terms of Service</link>"
}

// Component
import { useTranslations } from 'next-intl';
import Link from 'next/link';

function SignupForm() {
  const t = useTranslations('auth');

  return (
    <p>
      {t.rich('terms', {
        link: (chunks) => <Link href="/terms">{chunks}</Link>
      })}
    </p>
  );
}

Server Component Translations

// app/[locale]/about/page.tsx
import { getTranslations } from 'next-intl/server';

export async function generateMetadata({ params }: Props) {
  const t = await getTranslations({ locale: params.locale, namespace: 'about' });

  return {
    title: t('metaTitle'),
    description: t('metaDescription')
  };
}

export default async function AboutPage({ params }: Props) {
  const t = await getTranslations({ locale: params.locale, namespace: 'about' });

  return <h1>{t('title')}</h1>;
}

Apply these patterns to build robust, scalable internationalization in web applications.