React Development · Lesson 11 of 15

Internationalisation

What i18n Actually Involves

Internationalization (i18n) is often thought of as "just translating text." Production i18n is much more:

  • Translations — text in different languages
  • Pluralization — "1 item" vs "2 items" (varies by language)
  • Date formatting — "Apr 13" (US) vs "13 avr." (France)
  • Number formatting — "1,234.56" (US) vs "1.234,56" (Germany)
  • Currency — symbol position, decimal rules
  • RTL layouts — Arabic, Hebrew read right-to-left
  • Locale detection — from browser, URL, or user preference

Setup with react-i18next

Bash
npm install react-i18next i18next i18next-http-backend i18next-browser-languagedetector
TSX
// src/i18n/index.ts
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import Backend from "i18next-http-backend";

i18n
  .use(Backend)               // Load translations from /public/locales/
  .use(LanguageDetector)      // Detect browser language
  .use(initReactI18next)      // React integration
  .init({
    fallbackLng: "en",
    supportedLngs: ["en", "fr", "de", "ar", "ja"],
    defaultNS: "common",
    ns: ["common", "auth", "dashboard", "errors"],

    backend: {
      loadPath: "/locales/{{lng}}/{{ns}}.json",
    },

    detection: {
      order: ["querystring", "localStorage", "navigator"],
      lookupQuerystring: "lang",
      lookupLocalStorage: "i18nextLng",
      caches: ["localStorage"],
    },

    interpolation: {
      escapeValue: false,  // React already escapes XSS
    },
  });

export default i18n;
public/locales/
├── en/
│   ├── common.json
│   ├── auth.json
│   └── dashboard.json
├── fr/
│   ├── common.json
│   ├── auth.json
│   └── dashboard.json
└── ar/
    ├── common.json
    └── ...
JSON
// public/locales/en/common.json
{
  "nav": {
    "home": "Home",
    "products": "Products",
    "about": "About Us"
  },
  "actions": {
    "save": "Save",
    "cancel": "Cancel",
    "delete": "Delete",
    "confirm": "Confirm"
  },
  "cart": {
    "title": "Shopping Cart",
    "empty": "Your cart is empty",
    "item_count": "{{count}} item",
    "item_count_other": "{{count}} items",
    "total": "Total: {{amount}}"
  },
  "errors": {
    "required": "This field is required",
    "invalid_email": "Please enter a valid email address",
    "server_error": "Something went wrong. Please try again."
  }
}
JSON
// public/locales/fr/common.json
{
  "nav": {
    "home": "Accueil",
    "products": "Produits",
    "about": "À propos"
  },
  "cart": {
    "title": "Panier",
    "empty": "Votre panier est vide",
    "item_count": "{{count}} article",
    "item_count_other": "{{count}} articles",
    "total": "Total: {{amount}}"
  }
}
TSX
// src/main.tsx
import "./i18n";  // Must import before App
import { Suspense } from "react";

function Root() {
  return (
    // Suspense is required — translations load asynchronously
    <Suspense fallback={<LoadingScreen />}>
      <App />
    </Suspense>
  );
}

Using Translations in Components

TSX
import { useTranslation, Trans } from "react-i18next";

// Basic usage
function Navigation() {
  const { t } = useTranslation("common");

  return (
    <nav>
      <Link to="/">{t("nav.home")}</Link>
      <Link to="/products">{t("nav.products")}</Link>
      <Link to="/about">{t("nav.about")}</Link>
    </nav>
  );
}

// Multiple namespaces
function AuthPage() {
  const { t: tCommon } = useTranslation("common");
  const { t: tAuth } = useTranslation("auth");

  return (
    <div>
      <h1>{tAuth("login.title")}</h1>
      <button>{tCommon("actions.save")}</button>
    </div>
  );
}

// Interpolation — inserting dynamic values
function WelcomeBanner({ user }) {
  const { t } = useTranslation("dashboard");

  return (
    <h1>
      {/* en: "Welcome back, Alice!" */}
      {t("welcome", { name: user.name })}
    </h1>
  );
}
// dashboard.json: { "welcome": "Welcome back, {{name}}!" }

// Pluralization — i18next handles it automatically
function CartBadge({ count }) {
  const { t } = useTranslation("common");

  return (
    <span>
      {/* count=1: "1 item", count=5: "5 items" */}
      {t("cart.item_count", { count })}
    </span>
  );
}

// Trans component — for inline HTML/components in translations
function TermsNotice() {
  const { t } = useTranslation("auth");

  return (
    <p>
      <Trans
        i18nKey="auth:register.terms_notice"
        components={{
          terms: <a href="/terms" />,
          privacy: <a href="/privacy" />,
        }}
      />
    </p>
    // Translation: "By signing up, you agree to our <terms>Terms</terms> and <privacy>Privacy Policy</privacy>"
  );
}

Language Switcher

TSX
function LanguageSwitcher() {
  const { i18n } = useTranslation();

  const languages = [
    { code: "en", label: "English", flag: "🇺🇸" },
    { code: "fr", label: "Français", flag: "🇫🇷" },
    { code: "de", label: "Deutsch", flag: "🇩🇪" },
    { code: "ar", label: "العربية", flag: "🇸🇦" },
    { code: "ja", label: "日本語", flag: "🇯🇵" },
  ];

  const handleChange = async (code) => {
    await i18n.changeLanguage(code);
    // RTL languages need document direction change
    document.documentElement.dir = ["ar", "he", "fa"].includes(code) ? "rtl" : "ltr";
    document.documentElement.lang = code;
  };

  return (
    <select
      value={i18n.language}
      onChange={(e) => handleChange(e.target.value)}
      aria-label="Select language"
    >
      {languages.map(({ code, label, flag }) => (
        <option key={code} value={code}>
          {flag} {label}
        </option>
      ))}
    </select>
  );
}

Date, Number, and Currency Formatting

Use the native Intl API — it handles all locale-specific formatting automatically.

TSX
function useIntlFormatters() {
  const { i18n } = useTranslation();
  const locale = i18n.language;

  return {
    formatDate: (date, options) =>
      new Intl.DateTimeFormat(locale, options).format(new Date(date)),

    formatNumber: (number, options) =>
      new Intl.NumberFormat(locale, options).format(number),

    formatCurrency: (amount, currency = "USD") =>
      new Intl.NumberFormat(locale, {
        style: "currency",
        currency,
      }).format(amount),

    formatRelativeTime: (date) => {
      const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
      const diff = (new Date(date) - new Date()) / 1000;
      if (Math.abs(diff) < 60) return rtf.format(Math.round(diff), "second");
      if (Math.abs(diff) < 3600) return rtf.format(Math.round(diff / 60), "minute");
      if (Math.abs(diff) < 86400) return rtf.format(Math.round(diff / 3600), "hour");
      return rtf.format(Math.round(diff / 86400), "day");
    },
  };
}

function OrderSummary({ order }) {
  const { formatDate, formatCurrency, formatRelativeTime } = useIntlFormatters();

  return (
    <div>
      <p>Order placed: {formatDate(order.createdAt, { dateStyle: "long" })}</p>
      {/* en: "April 13, 2026", fr: "13 avril 2026", de: "13. April 2026" */}

      <p>Total: {formatCurrency(order.total, order.currency)}</p>
      {/* en: "$1,234.56", de: "1.234,56 $", fr: "1 234,56 $US" */}

      <p>Updated {formatRelativeTime(order.updatedAt)}</p>
      {/* "2 hours ago", "il y a 2 heures" */}
    </div>
  );
}

// Using Intl.ListFormat for arrays
function TagList({ tags }) {
  const { i18n } = useTranslation();

  const formatted = new Intl.ListFormat(i18n.language, {
    style: "long",
    type: "conjunction",
  }).format(tags);

  // en: "React, TypeScript, and Redux"
  // fr: "React, TypeScript et Redux"
  return <span>{formatted}</span>;
}

RTL (Right-to-Left) Layout Support

TSX
// Detect RTL languages
const RTL_LANGUAGES = ["ar", "he", "fa", "ur"];

function useDirection() {
  const { i18n } = useTranslation();
  return RTL_LANGUAGES.includes(i18n.language) ? "rtl" : "ltr";
}

// Apply direction globally
function App() {
  const direction = useDirection();

  useEffect(() => {
    document.documentElement.dir = direction;
    document.documentElement.lang = i18n.language;
  }, [direction]);

  return <div dir={direction}>{/* ... */}</div>;
}
CSS
/* Use CSS logical properties instead of left/right */

/* ❌ Doesn't flip for RTL */
.card {
  padding-left: 16px;
  text-align: left;
  border-left: 3px solid blue;
}

/* ✅ Automatically flips for RTL */
.card {
  padding-inline-start: 16px;
  text-align: start;
  border-inline-start: 3px solid blue;
}

/* Tailwind: use start/end instead of left/right */
/* ps-4 (padding-inline-start) instead of pl-4 */
/* text-start instead of text-left */
/* ms-auto (margin-inline-start) instead of ml-auto */

Lazy Loading Translations

Split translation files by namespace to avoid loading all languages upfront:

TSX
// Load a namespace only when the feature is accessed
function AdminPanel() {
  const { t, ready } = useTranslation("admin", { useSuspense: false });

  if (!ready) return <Skeleton />;

  return <div>{t("panel.title")}</div>;
}

// With route-based code splitting
const AdminPanel = lazy(() => import("./AdminPanel"));

// Both the component AND its translations load on demand
<Suspense fallback={<PageLoader />}>
  <AdminPanel />
</Suspense>

Testing Internationalized Components

TSX
import { render, screen } from "@testing-library/react";
import { I18nextProvider } from "react-i18next";
import i18n from "../i18n/testConfig";

// Test i18n config — uses inline resources, no HTTP
const testI18n = i18n.createInstance({
  lng: "en",
  resources: {
    en: {
      common: {
        "cart.item_count": "{{count}} item",
        "cart.item_count_other": "{{count}} items",
      },
    },
  },
});

function renderWithI18n(ui, language = "en") {
  testI18n.changeLanguage(language);
  return render(
    <I18nextProvider i18n={testI18n}>{ui}</I18nextProvider>
  );
}

describe("CartBadge", () => {
  it("shows singular for 1 item", () => {
    renderWithI18n(<CartBadge count={1} />);
    expect(screen.getByText("1 item")).toBeInTheDocument();
  });

  it("shows plural for multiple items", () => {
    renderWithI18n(<CartBadge count={5} />);
    expect(screen.getByText("5 items")).toBeInTheDocument();
  });

  it("renders correctly in French", () => {
    renderWithI18n(<CartBadge count={5} />, "fr");
    expect(screen.getByText("5 articles")).toBeInTheDocument();
  });
});

Common i18n Pitfalls

TSX
// ❌ Concatenating strings — word order differs by language
const message = t("hello") + " " + user.name + "!";

// ✅ Use interpolation
const message = t("hello_user", { name: user.name });
// Translation: "Hello, {{name}}!"

// ❌ Hardcoded pluralization logic
const label = count === 1 ? "item" : "items";

// ✅ Use i18next pluralization
const label = t("item_count", { count });

// ❌ Assuming text direction
<div style={{ textAlign: "left" }}>

// ✅ Use logical properties
<div style={{ textAlign: "start" }}>

// ❌ Assuming date formats
const date = `${month}/${day}/${year}`;

// ✅ Use Intl.DateTimeFormat
const date = new Intl.DateTimeFormat(i18n.language).format(new Date());

Interview Questions Answered

Q: What is the difference between i18n and l10n?

i18n (internationalization) is designing your app to support multiple locales — abstracting strings, handling RTL, using Intl APIs. l10n (localization) is the actual work of adapting the app for a specific locale — providing translations, locale-specific content, formatting. i18n enables l10n.

Q: Why should you avoid t("key") + " " + variable string concatenation?

Word order varies by language. In English "Welcome, Alice!" works fine. In German it might need to be structured differently. Interpolation (t("welcome", { name: "Alice" })) gives translators full control over sentence structure, including where the variable appears.

Q: How does i18next handle pluralization for languages with complex plural rules?

i18next uses CLDR plural rules. For English: key (singular) and key_other (plural). For Polish (which has 4 forms): key_one, key_few, key_many, key_other. You define all forms in the translation file; i18next picks the right one based on the count.

Q: What is the best approach for storing the user's language preference?

Store in localStorage (for web apps) or user profile (for authenticated apps). URL-based locale (/fr/dashboard) is also excellent for SEO and shareability — the URL carries the locale, so shared links open in the same language. Many production apps combine both.