React i18next: setup and workflow

Setting up i18next in a React app: namespaces, plurals, interpolation, Trans for embedded markup, lazy loading, and keeping translation files in sync.

i18next is the default choice for React internationalization, and it is flexible enough that most of the decisions are left to you. This guide covers a setup that scales.

Installing and initialising

bash
npm install i18next react-i18next i18next-browser-languagedetector
js
// src/i18n.js
import i18next from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";

import en from "./locales/en/common.json";
import fr from "./locales/fr/common.json";

i18next
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    resources: {
      en: { common: en },
      fr: { common: fr },
    },
    defaultNS: "common",
    fallbackLng: "en",
    interpolation: { escapeValue: false }, // React already escapes
    returnEmptyString: false,
  });

export default i18next;

Import it once, at application entry, before anything renders.

Two settings worth understanding. escapeValue: false is correct for React — React escapes interpolated values itself, and leaving i18next’s escaping on double-escapes apostrophes into '. And returnEmptyString: false makes an empty translation fall back rather than rendering nothing, which is what you want when a translator has not reached a key yet.

Reading translations

jsx
import { useTranslation } from "react-i18next";

function Dashboard() {
  const { t } = useTranslation();

  return (
    <>
      <h1>{t("dashboard.title")}</h1>
      <p>{t("dashboard.welcome", { name: user.firstName })}</p>
    </>
  );
}

With the JSON:

json
{
  "dashboard": {
    "title": "Dashboard",
    "welcome": "Welcome back, {{name}}"
  }
}

Note the interpolation syntax is {{name}} — double braces, Mustache-style, distinct from the single braces ICU and FormatJS use. Mixing the two is a common source of strings that render literally.

Plurals

i18next selects a plural form by key suffix, driven by the count option:

json
{
  "messages_one": "1 message",
  "messages_other": "{{count}} messages"
}
jsx
t("messages", { count: 5 });   // => "5 messages"

The suffixes are the CLDR category names in v4 and later — _zero, _one, _two, _few, _many, _other. Older versions used numeric suffixes (_0, _1, _2), and this is the main thing to check when following an older tutorial.

The important consequence: the set of suffixes is per language. A Polish file needs messages_one, messages_few, messages_many, messages_other. Generating target files by copying the English key set produces files that are missing forms for most numbers. See plural rules by language.

There is also ordinal support via { count: 3, ordinal: true }, which uses _ordinal_ suffixes and its own CLDR category set.

Sentences containing markup

This is the case that goes wrong most often. You need:

Read our terms of service before continuing.

The instinct is to split the sentence into three strings and concatenate. That produces an untranslatable string, because the fragment order is different in other languages.

Trans solves it:

jsx
import { Trans } from "react-i18next";

<Trans i18nKey="terms">
  Read our <a href="/tos">terms of service</a> before continuing.
</Trans>
json
{
  "terms": "Read our <1>terms of service</1> before continuing."
}

The translator moves <1>…</1> wherever the target grammar puts it. The href stays in the code, so a translator cannot break the link, and there is no HTML for them to mangle.

Use Trans whenever a translatable sentence contains a link, a bold run, or an inline component. Do not use it for whole paragraphs of markup — at that point the content belongs in a CMS rather than a translation file.

Namespaces

Namespaces split translations into separately loadable files:

jsx
const { t } = useTranslation("checkout");
t("payment.declined");   // reads from checkout.json

Combined with a backend plugin they load on demand:

bash
npm install i18next-http-backend
js
i18next.use(HttpBackend).init({
  backend: { loadPath: "/locales/{{lng}}/{{ns}}.json" },
  ns: ["common"],
  defaultNS: "common",
});

Now common.json ships with the initial bundle and checkout.json is fetched when the checkout route mounts. For an application of any size this is the difference between a reasonable initial payload and shipping every string in every language up front.

Split namespaces along the same lines as your route-level code splitting. A namespace per feature area is usually right; a namespace per component is too granular and produces a request waterfall.

Suspense and loading

With a backend, translations arrive asynchronously. react-i18next integrates with Suspense by default:

jsx
<Suspense fallback={<Spinner />}>
  <App />
</Suspense>

If you would rather handle it manually, set useSuspense: false in the react options and check the ready flag from useTranslation. Pick one — a component tree where some parts suspend and others check ready is hard to reason about.

Formatting numbers and dates

i18next delegates to Intl:

json
{
  "total": "Total: {{amount, currency(EUR)}}",
  "updated": "Updated {{date, datetime}}"
}

Do not format numbers or dates in JavaScript and interpolate the result as a string. The formatting is locale-dependent, and pre-formatting bakes in the wrong locale’s conventions — see what is a locale for what actually varies.

For messages where the structure depends on the value — gender selection, nested plurals — consider the ICU plugin i18next-icu, which gives you full ICU MessageFormat instead of i18next’s own simpler syntax.

Keeping files in sync

Extract keys automatically rather than maintaining the JSON by hand. i18next-parser scans source for t() calls and Trans components and writes the key skeleton:

bash
npx i18next-parser 'src/**/*.{js,jsx,ts,tsx}' -o src/locales/$LOCALE/$NAMESPACE.json

Then sync with your translation platform from CI:

bash
wti push      # source files up
wti pull      # translations back
wti diff      # what a push would change

Push on merge to your designated branch, pull nightly into a pull request. See Git-based localization workflows for why exactly one branch should push.

Type safety

By default t("anything.at.all") compiles, and a typo becomes a string rendering the key itself. TypeScript can close that gap by deriving the key union from your English resources:

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

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

Now a mistyped key is a compile error, autocomplete lists the available keys, and deleting a key from the JSON surfaces every call site that used it. This costs about ten lines and eliminates an entire class of bug — worth doing on day one rather than after the first incident.

It does mean your English JSON is the schema, which is the right relationship: the source language defines what exists and the other languages are translations of it.

Testing

The goal is not asserting on translated text — that makes tests brittle and duplicates the locale file. It is proving the wiring works.

Initialise a test instance that returns keys verbatim:

js
i18next.use(initReactI18next).init({
  lng: "cimode",   // t() returns the key itself
  resources: {},
});

cimode makes every lookup return its key, so a component test can assert on dashboard.title without depending on the copy. Combine that with a separate check that every key referenced in code exists in the English file, and you have covered both halves.

The other test worth writing is a completeness check across locales: every key in en present in every other language, and every plural key carrying the right suffix set for that language. Run it in CI and a half-translated locale fails the build instead of shipping.

Common mistakes

  • escapeValue: true in React, producing &#39; in place of apostrophes.
  • Concatenating sentence fragments instead of using Trans.
  • Copying the English plural key set into every target language.
  • Interpolating pre-formatted numbers and dates as strings.
  • Keys that are English sentences. t("Welcome back") seems convenient until the copy changes and every key becomes stale.
  • One giant namespace shipped in every language on first load.

Frequently asked questions

How do I set up i18next in React?
Install i18next and react-i18next, create an i18n module that calls i18next.use(initReactI18next).init() with your resources and fallback language, import it once at application entry, and read translations with the useTranslation hook.
How does i18next handle plurals?
By key suffix. In v4 and later the suffixes are the CLDR category names — key_one, key_few, key_many, key_other — selected from the count option you pass. Earlier versions used numeric suffixes, which is the main thing to check when reading older examples.
What is the Trans component for?
Translating a string that contains embedded markup or React components, such as a sentence with a link in the middle. It lets the translator move the tagged section within the sentence without the translation containing raw HTML.
Should I use namespaces in i18next?
Once the application is beyond a few hundred keys, yes. Namespaces split translations into separately loadable files, which keeps the initial bundle small and gives you a natural unit for code splitting. Below that size a single namespace is simpler.

Keep reading

Translate your app without the spreadsheet round-trip

WebTranslateIt reads the file formats and placeholder syntax described on this page, validates them as translators work, and syncs the results straight back into your repository.