Type something to search...
Internationalization in React with react-i18next

Internationalization in React with react-i18next

Translating a React app sounds like a find-and-replace job until you hit the details. "1 items" needs a singular form, and Polish has three plural forms, not two. A date written as 03/04 means March in one country and April in another. A sentence with a bold link inside can't be split into three separately translated fragments without breaking grammar. Arabic and Hebrew read right to left. Hardcoded strings scattered through components make all of this painful to retrofit.

react-i18next is the most widely used solution for these problems in React. It wraps i18next, a mature translation engine, with hooks and components that fit React's model: a useTranslation hook for strings, a Trans component for markup inside translations, and automatic re-rendering when the language changes.

This guide walks through a complete setup in a Vite + React 19 + TypeScript app: configuration, translation files, interpolation, plurals, number and date formatting, rich text, lazy-loading namespaces, a language switcher, right-to-left support, and type-safe keys.

Installation

Install i18next, the React bindings, and two common plugins: one to detect the user's language and one to load translation files over HTTP.

npm install i18next react-i18next i18next-browser-languagedetector i18next-http-backend

Here's the file layout used in this post:

public/locales/
  en/
    common.json
    checkout.json
  de/
    common.json
    checkout.json
  ar/
    common.json
    checkout.json
src/
  i18n.ts
  main.tsx
  components/LanguageSwitcher.tsx

Translation files live in public so the HTTP backend can fetch them at runtime. Users only download the languages and namespaces they actually need.

Configuring i18next

Create a single module that configures i18next and registers the React plugin:

// src/i18n.ts
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import HttpBackend from "i18next-http-backend";

export const supportedLanguages = ["en", "de", "ar"] as const;

i18n
  .use(HttpBackend)
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    fallbackLng: "en",
    supportedLngs: supportedLanguages,
    nonExplicitSupportedLngs: true, // treat "de-AT" as "de"
    ns: ["common"],
    defaultNS: "common",
    backend: {
      loadPath: "/locales/{{lng}}/{{ns}}.json",
    },
    detection: {
      order: ["querystring", "localStorage", "navigator"],
      lookupQuerystring: "lang",
      caches: ["localStorage"],
    },
    interpolation: {
      escapeValue: false, // React already escapes output
    },
  });

export default i18n;

The important options:

  • fallbackLng is used when a key is missing in the current language, so users see English rather than a raw key.
  • supportedLngs restricts detection to languages you actually ship.
  • detection.order checks a ?lang=de query parameter first, then a previous choice saved in localStorage, then the browser's language.
  • escapeValue: false is safe in React because JSX escapes strings when rendering. Leaving it on double-escapes characters like &.

Import the module once, before rendering, and wrap the app in Suspense. With an HTTP backend, translations load asynchronously, and react-i18next suspends components until they're ready:

// src/main.tsx
import { StrictMode, Suspense } from "react";
import { createRoot } from "react-dom/client";
import "./i18n";
import App from "./App";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <Suspense fallback={<div className="app-loading">Loading…</div>}>
      <App />
    </Suspense>
  </StrictMode>,
);

If you'd rather bundle translations into the JavaScript (fine for small apps with two or three languages), skip the backend and pass a resources object to init instead. Then nothing suspends.

Translation Files

Translation files are plain JSON. Nest keys by feature to keep them navigable:

// public/locales/en/common.json
{
  "nav": {
    "home": "Home",
    "pricing": "Pricing",
    "account": "Account"
  },
  "greeting": "Hello, {{name}}!",
  "cart": {
    "items_one": "{{count}} item in your cart",
    "items_other": "{{count}} items in your cart",
    "empty": "Your cart is empty"
  },
  "lastSeen": "Last seen {{date, datetime}}",
  "total": "Total: {{amount, currency(EUR)}}",
  "terms": "By signing up you agree to our <termsLink>terms of service</termsLink>."
}
// public/locales/de/common.json
{
  "nav": {
    "home": "Startseite",
    "pricing": "Preise",
    "account": "Konto"
  },
  "greeting": "Hallo, {{name}}!",
  "cart": {
    "items_one": "{{count}} Artikel im Warenkorb",
    "items_other": "{{count}} Artikel im Warenkorb",
    "empty": "Dein Warenkorb ist leer"
  },
  "lastSeen": "Zuletzt gesehen {{date, datetime}}",
  "total": "Summe: {{amount, currency(EUR)}}",
  "terms": "Mit der Registrierung akzeptierst du unsere <termsLink>Nutzungsbedingungen</termsLink>."
}

Keys stay identical across languages. Only the values change.

Translating Strings With useTranslation

The useTranslation hook returns a t function bound to the current language:

import { useTranslation } from "react-i18next";

export function Nav() {
  const { t } = useTranslation();

  return (
    <nav>
      <a href="/">{t("nav.home")}</a>
      <a href="/pricing">{t("nav.pricing")}</a>
      <a href="/account">{t("nav.account")}</a>
    </nav>
  );
}

Dots in the key navigate the nested JSON. When the language changes, every component using the hook re-renders with new strings.

Interpolation

Values wrapped in double curly braces in the JSON, like {{name}}, are replaced with values you pass to t:

function Welcome({ user }: { user: { name: string } }) {
  const { t } = useTranslation();
  return <h1>{t("greeting", { name: user.name })}</h1>;
}

Never build sentences by concatenating translated fragments, such as t("hello") + ", " + name. Word order differs between languages, and translators need the whole sentence with placeholders to get it right.

Plurals

i18next picks the plural form automatically when you pass a count option. It uses the browser's Intl.PluralRules, so it knows each language's rules. You provide suffixed keys: _one, _other, and for languages that need them, _zero, _two, _few, and _many.

function CartSummary({ count }: { count: number }) {
  const { t } = useTranslation();

  if (count === 0) return <p>{t("cart.empty")}</p>;
  return <p>{t("cart.items", { count })}</p>;
}

With count: 1 this renders "1 item in your cart". With count: 5 it renders "5 items in your cart". In Arabic, your translator would add items_zero, items_two, items_few, and items_many variants, and i18next selects the right one without any code changes.

Number, Currency, and Date Formatting

i18next has built-in formatters backed by the Intl APIs. In the JSON above, {{date, datetime}} and {{amount, currency(EUR)}} tell i18next how to format the value for the active language:

function OrderInfo({ total, lastLogin }: { total: number; lastLogin: Date }) {
  const { t } = useTranslation();

  return (
    <dl>
      <dt>{t("total", { amount: total })}</dt>
      <dd>{t("lastSeen", { date: lastLogin })}</dd>
    </dl>
  );
}

For total = 1234.5, English shows "€1,234.50" and German shows "1.234,50 €". You can pass formatter options directly in the key as well, like {{date, datetime(dateStyle: long)}}.

Outside of translated strings, use Intl directly with the current language:

const { i18n } = useTranslation();
const formatted = new Intl.NumberFormat(i18n.resolvedLanguage, {
  style: "percent",
}).format(0.42);

i18n.resolvedLanguage is the best supported match for the user, such as "de" when the browser reports "de-AT".

Rich Text With the Trans Component

When a translation contains markup, like a link in the middle of a sentence, use Trans. It lets translators move the link anywhere within the sentence while you supply the actual element:

import { Trans } from "react-i18next";
import { Link } from "react-router";

export function TermsNotice() {
  return (
    <p>
      <Trans
        i18nKey="terms"
        components={{ termsLink: <Link to="/terms" /> }}
      />
    </p>
  );
}

The JSON uses a named tag, <termsLink>, and the components prop maps that name to a real React element. The translated text between the tags becomes the link's children. Named components are easier for translators to read than the older numbered tags like <1>.

Organizing With Namespaces

As an app grows, one giant translation file becomes slow to download and hard to manage. Namespaces split translations into separate files, usually per feature or route. A component asks for the namespaces it needs:

import { useTranslation } from "react-i18next";

export default function CheckoutPage() {
  const { t } = useTranslation("checkout");

  return (
    <section>
      <h1>{t("title")}</h1>
      <p>{t("shippingNote")}</p>
      <button type="submit">{t("common:actions.pay")}</button>
    </section>
  );
}
// public/locales/en/checkout.json
{
  "title": "Checkout",
  "shippingNote": "Free shipping on orders over €50."
}

The first time CheckoutPage renders, react-i18next sees that the checkout namespace isn't loaded, fetches /locales/en/checkout.json, and suspends until it arrives. Combined with route-level code splitting, users download both the JavaScript and the translations for a feature only when they visit it. Code splitting with React.lazy and Suspense shows how to set up the lazy routes.

The common:actions.pay syntax reads a key from another namespace. You can also pass an array, useTranslation(["checkout", "common"]), where the first entry is the default.

Building a Language Switcher

Changing language is a single call to i18n.changeLanguage. It loads any missing translations, updates every component, and saves the choice through the detector's cache:

// src/components/LanguageSwitcher.tsx
import { useTranslation } from "react-i18next";
import { supportedLanguages } from "../i18n";

const labels: Record<(typeof supportedLanguages)[number], string> = {
  en: "English",
  de: "Deutsch",
  ar: "العربية",
};

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

  return (
    <label>
      <span className="sr-only">Language</span>
      <select
        value={i18n.resolvedLanguage}
        onChange={(e) => i18n.changeLanguage(e.target.value)}
      >
        {supportedLanguages.map((lng) => (
          <option key={lng} value={lng} lang={lng}>
            {labels[lng]}
          </option>
        ))}
      </select>
    </label>
  );
}

Show each language name in its own language. A German speaker looking for their language scans for "Deutsch", not "German". The lang attribute on each option helps screen readers pronounce the names correctly.

Setting lang and dir on the Document

Screen readers use the lang attribute on html to choose a voice, and browsers use dir to lay out right-to-left text. Keep both in sync with i18next:

// add to src/i18n.ts
i18n.on("languageChanged", (lng) => {
  document.documentElement.lang = lng;
  document.documentElement.dir = i18n.dir(lng);
});

i18n.dir returns "rtl" for languages like Arabic, Hebrew, and Persian, and "ltr" otherwise. Once dir="rtl" is set, flexbox rows, text alignment, and default list indentation flip automatically. To make your own spacing flip too, use CSS logical properties:

.card {
  /* flips correctly in RTL */
  padding-inline-start: 1rem;
  margin-inline-end: auto;
  border-inline-start: 4px solid var(--accent);
}

Tailwind's ps-4, me-auto, and border-s-4 utilities map to the same logical properties.

Type-Safe Translation Keys

A typo in a key like t("nav.hmoe") silently renders the key itself. With TypeScript, you can make i18next check keys at compile time by declaring your resources:

// src/@types/i18next.d.ts
import "i18next";
import type common from "../../public/locales/en/common.json";
import type checkout from "../../public/locales/en/checkout.json";

declare module "i18next" {
  interface CustomTypeOptions {
    defaultNS: "common";
    resources: {
      common: typeof common;
      checkout: typeof checkout;
    };
  }
}

Make sure resolveJsonModule is enabled in tsconfig.json. Now t autocompletes keys, flags unknown ones, and knows which namespace each useTranslation call refers to. English acts as the source of truth, and a CI script or a tool like i18next-parser can check other languages for missing keys.

Testing Translated Components

In tests, you usually don't want HTTP loading or Suspense. Create a test instance with bundled resources:

// src/test/i18n.ts
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import common from "../../public/locales/en/common.json";

i18n.use(initReactI18next).init({
  lng: "en",
  fallbackLng: "en",
  defaultNS: "common",
  resources: { en: { common } },
  interpolation: { escapeValue: false },
  react: { useSuspense: false },
});

export default i18n;

Import it in your Vitest setup file, and assertions can use the real English strings, such as screen.getByText("Your cart is empty"). That way tests also catch missing keys. For the rest of the testing setup, see testing React components with Vitest and Testing Library.

Common Mistakes With react-i18next

  • Concatenating translated fragments. Word order varies by language. Translate whole sentences with placeholders.
  • Handling plurals with ternaries. count === 1 ? "item" : "items" only works for some languages. Pass count and use plural suffixes.
  • Formatting numbers and dates manually. Use i18next's formatters or Intl with i18n.resolvedLanguage.
  • Forgetting Suspense with an HTTP backend. Without it, the first render throws a promise with nowhere to catch it.
  • Leaving escapeValue on. React already escapes text, so you'll see &amp; instead of &.
  • Hardcoding left and right in CSS. Use logical properties so layouts flip in RTL languages.
  • Using t outside components at module level. Strings computed at import time won't update when the language changes. Call t during render, or store keys and translate them later.

Frequently Asked Questions (FAQ) About react-i18next

i18next is a framework-agnostic translation engine that handles loading resources, interpolation, plurals, and formatting. react-i18next is a thin layer on top that provides the useTranslation hook, the Trans component, and automatic re-rendering when the language changes. You install and configure both.

Bundle them if you support only a few languages and the files are small, because it avoids loading states entirely. Load them over HTTP with i18next-http-backend when you have many languages or large files, so each user downloads only their language and only the namespaces their current page uses.

Pass a count option to t and add the extra suffixed keys the language needs, such as _few and _many for Polish or _zero and _two for Arabic. i18next uses Intl.PluralRules to choose the correct key, so your component code stays the same.

Yes. Frameworks like Next.js and React Router in framework mode can preload the needed language and namespaces on the server and pass them to the client so the first render matches. The details depend on the framework, and some, like Next.js, also have dedicated libraries built around i18next.

Call t directly in the attribute, for example aria-label set to the result of t with your key. It returns a plain string, so it works anywhere a string is expected, including title, alt, and placeholder attributes.

Many teams use a translation management platform such as Locize, Crowdin, or Lokalise, which imports and exports i18next JSON and gives translators context and screenshots. For smaller projects, translators can edit the JSON directly, ideally with a script that reports missing or unused keys.

Conclusion

react-i18next gives you a complete internationalization toolkit with a small API: configure i18next once, write translations as JSON with placeholders and plural suffixes, read them with useTranslation, render markup inside translations with Trans, and switch languages with changeLanguage. Namespaces and the HTTP backend keep downloads small, i18n.dir and CSS logical properties handle right-to-left layouts, and a CustomTypeOptions declaration turns missing keys into compile errors.

If you're retrofitting an existing app, start with one feature. Move its strings into a namespace, replace hardcoded text with t calls, and add a second language, even a machine-translated one, to shake out layout and pluralization problems early. Once that feature works end to end, the rest of the migration becomes repetitive rather than difficult.

Tags :
Share :

Related Posts

A Practical Guide to useEffect and Its Dependency Array

A Practical Guide to useEffect and Its Dependency Array

useEffect is the hook people get wrong most often, and the dependency array is usually where it goes wrong. Leave a value out and your effect works

Continue Reading
Accessibility Best Practices for React Developers

Accessibility Best Practices for React Developers

React makes it easy to build interfaces out of anything. A div with an onClick looks and behaves like a button for a mouse user, so it ships. The

Continue Reading
Animations in React with Motion (Framer Motion)

Animations in React with Motion (Framer Motion)

CSS transitions get you far, until you need to animate something leaving the page. React removes the element from the DOM immediately, so there's not

Continue Reading