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
npm install i18next react-i18next i18next-browser-languagedetector
// 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
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:
{
"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:
{
"messages_one": "1 message",
"messages_other": "{{count}} messages"
}
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:
import { Trans } from "react-i18next";
<Trans i18nKey="terms">
Read our <a href="/tos">terms of service</a> before continuing.
</Trans>
{
"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:
const { t } = useTranslation("checkout");
t("payment.declined"); // reads from checkout.json
Combined with a backend plugin they load on demand:
npm install i18next-http-backend
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:
<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:
{
"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:
npx i18next-parser 'src/**/*.{js,jsx,ts,tsx}' -o src/locales/$LOCALE/$NAMESPACE.json
Then sync with your translation platform from CI:
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:
// 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:
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: truein React, producing'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
-
Next.js internationalization
Internationalizing a Next.js App Router app: routing by locale segment, server and client translation, metadata, hreflang and keeping message files in sync.
-
ICU MessageFormat: a practical guide
The syntax for plurals, gender selection, number and date formatting in one message string — with the escaping rules and the mistakes translators reliably make.
-
Placeholder formats cheat sheet: %s, %@, %{name}, {{name}}, {0}
Every string placeholder syntax you will meet, which language or framework uses it, and what breaks when a translator retypes one by hand.
-
Translate i18next files (documentation)
How WebTranslateIt parses i18next JSON, including plural suffixes and nesting.
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.