Back to blog
Frontend Engineeringintermediate

React Internationalization (i18n): react-i18next & Multi-Language Apps

Build production-ready multilingual React apps β€” setting up react-i18next, handling translations, pluralization, date/number formatting, RTL layouts, and lazy-loading locale files.

LearnixoApril 13, 20268 min read
Reacti18nInternationalizationreact-i18nextl10nRTL
Share:𝕏

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.

Enjoyed this article?

Explore the Frontend Engineering learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.