react-i18next tutorial: a complete guide for 2026
2026-05-25 · Localingos team
If you've ever shipped a React app to more than one country, you know the moment: a product manager asks "can we add Spanish?" and suddenly half your components need translation. This guide walks through react-i18next — the most-used i18n library for React — from a blank npx create-react-app to a production-deployed multilingual app, with the patterns that actually survive contact with real product teams.
By the end you'll have working code for translation files, the useTranslation hook, interpolation, pluralization, namespaces, lazy loading per language, and a sane pattern for switching languages and persisting the choice. Everything here is current as of react-i18next v15 (May 2026).
Why react-i18next over the alternatives
There are three serious contenders for React i18n in 2026: react-i18next, react-intl (FormatJS), and lingui. They're all capable. react-i18next is the one I reach for first because:
- It's framework-agnostic at the core (
i18next) and the React binding is small. - The ecosystem is enormous — language detectors, backend loaders, ICU plugins, key extractors all exist.
- It handles the boring stuff (loading state, language detection, persistence) without you having to write it.
- The hook API (
useTranslation) plays nicely with concurrent React and Suspense.
react-intl is more rigid around the ICU MessageFormat syntax, which is great for translator handoff but heavier to write. lingui is excellent if you want compile-time message extraction baked in. Pick what fits — but for most teams shipping in a hurry, react-i18next is the path of least resistance.
Setup
Start from an existing React project (create-react-app, Vite, anything). Install the runtime and the React binding:
npm install i18next react-i18next i18next-browser-languagedetector
The i18next-browser-languagedetector plugin reads the user's preferred language from navigator.language, the URL, a cookie, or localStorage — without it you'd be coding that detection yourself.
Create src/i18n/index.ts:
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import en from './en.json';
import es from './es.json';
import de from './de.json';
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: en },
es: { translation: es },
de: { translation: de },
},
fallbackLng: 'en',
interpolation: { escapeValue: false }, // React already escapes
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
},
});
export default i18n;
A few non-obvious choices here:
escapeValue: falseis correct for React — React's JSX renderer escapes for you, and double-escaping mangles apostrophes.detection.orderputslocalStoragefirst so a returning user sees the language they last picked, not whatever the browser claims.fallbackLng: 'en'means missing keys silently fall through to English. In development you'll wantsaveMissing: trueso you can spot gaps; in production keep it off.
Now wire it up at the entry point. Edit src/index.tsx:
import React from 'react';
import ReactDOM from 'react-dom/client';
import './i18n'; // import for side effects — sets up i18n before App renders
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
That's it for setup. Create three JSON files alongside i18n/index.ts:
// en.json
{
"welcome": "Welcome",
"signIn": "Sign in",
"greeting": "Hello, {{name}}"
}
// es.json
{
"welcome": "Bienvenido",
"signIn": "Iniciar sesión",
"greeting": "Hola, {{name}}"
}
// de.json
{
"welcome": "Willkommen",
"signIn": "Anmelden",
"greeting": "Hallo, {{name}}"
}
Using translations in components
The useTranslation hook gives you a t function. That's almost everything.
import { useTranslation } from 'react-i18next';
export const Header: React.FC<{ userName: string }> = ({ userName }) => {
const { t } = useTranslation();
return (
<header>
<h1>{t('welcome')}</h1>
<p>{t('greeting', { name: userName })}</p>
<button>{t('signIn')}</button>
</header>
);
};
{{name}} in your translation string gets replaced by name: userName in the options object. This is interpolation, and it's the single most useful feature — your strings stay declarative and translators don't need to think about template literals.
Switching language
A language switcher is a one-liner. The hook gives you i18n too:
const { i18n } = useTranslation();
i18n.changeLanguage('es');
Because the language detector is configured with caches: ['localStorage'], this call also persists the choice. Next page load, the user is back in Spanish without you doing anything.
A simple dropdown:
const LANGUAGES = { en: 'English', es: 'Español', de: 'Deutsch' } as const;
export const LanguageSwitcher: React.FC = () => {
const { i18n } = useTranslation();
return (
<select
value={i18n.language}
onChange={(e) => i18n.changeLanguage(e.target.value)}
>
{Object.entries(LANGUAGES).map(([code, label]) => (
<option key={code} value={code}>{label}</option>
))}
</select>
);
};
Pluralization
English plurals are easy: 1 item, 2 items. Other languages are not — Russian has three plural forms, Arabic has six. Hardcoding ${count} ${count === 1 ? 'item' : 'items'} is a trap.
react-i18next uses the Unicode CLDR plural rules — you provide the variants by suffix:
// en.json
{
"cart_one": "{{count}} item in your cart",
"cart_other": "{{count}} items in your cart"
}
// ru.json
{
"cart_one": "{{count}} товар в корзине",
"cart_few": "{{count}} товара в корзине",
"cart_many": "{{count}} товаров в корзине",
"cart_other": "{{count}} товара в корзине"
}
In the component:
const { t } = useTranslation();
return <span>{t('cart', { count: items.length })}</span>;
react-i18next picks the right variant based on the active language and the value of count. You write one component, it works in every language with the right plural grammar.
Namespaces — organizing larger apps
Once you have more than ~50 keys, a flat JSON file becomes painful. Namespaces let you split translations by feature.
// i18n/index.ts
i18n.init({
ns: ['common', 'auth', 'billing', 'dashboard'],
defaultNS: 'common',
resources: {
en: {
common: { /* shared strings */ },
auth: { /* sign-in, sign-up, password reset */ },
billing: { /* invoices, plans, usage */ },
dashboard: { /* main app shell */ },
},
// ...other languages
},
});
Each namespace is its own JSON file in practice. To use a non-default namespace:
const { t } = useTranslation('billing');
return <h2>{t('invoiceTitle')}</h2>;
Or fetch multiple namespaces at once:
const { t } = useTranslation(['common', 'auth']);
return (
<>
<h1>{t('common:welcome')}</h1>
<button>{t('auth:signIn')}</button>
</>
);
Lazy loading translations
Bundling every language into your initial JavaScript bundle is wasteful — a user who only speaks French shouldn't download Japanese strings. Use i18next-http-backend to load on demand:
npm install i18next-http-backend
import Backend from 'i18next-http-backend';
i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
fallbackLng: 'en',
ns: ['common', 'auth', 'billing'],
});
Drop your JSON files at public/locales/en/common.json, public/locales/es/common.json, etc. The library fetches them lazily and caches in memory. Wrap your app in <Suspense> to handle the loading state:
<Suspense fallback={<div>Loading…</div>}>
<App />
</Suspense>
You go from "every language bundled" to "only the active language loaded" with zero code changes in your components.
Handling RTL languages
Arabic, Hebrew, and Persian read right-to-left. CSS handles most of the heavy lifting via the dir attribute on <html>:
useEffect(() => {
const dir = ['ar', 'he', 'fa'].includes(i18n.language) ? 'rtl' : 'ltr';
document.documentElement.dir = dir;
document.documentElement.lang = i18n.language;
}, [i18n.language]);
Pair this with logical CSS properties (margin-inline-start instead of margin-left) and most of your layout flips correctly without per-component code.
Production patterns that save you later
A few things I wish I'd known sooner:
Type your keys. TypeScript users can generate a typed t function so missing keys are a compile error, not a runtime miss. Add a react-i18next.d.ts:
import 'react-i18next';
import common from './i18n/en/common.json';
import auth from './i18n/en/auth.json';
declare module 'react-i18next' {
interface CustomTypeOptions {
defaultNS: 'common';
resources: {
common: typeof common;
auth: typeof auth;
};
}
}
Now t('nonExistentKey') is a TypeScript error. Worth its weight in gold.
Don't translate at module load. Calling t() at the top level of a file (outside a component) captures the active language at module-eval time, then never updates. If you need translated constants, wrap them in a hook or a function called inside a component.
Keep keys semantic. t('confirmDeleteAccount') is better than t('areYouSureYouWantToDelete') — when product changes the copy from "Are you sure?" to "This can't be undone", the key stays the same and no component code changes.
Treat translation files as source of truth. Commit en.json to git. Generate the other locales (don't write them by hand and don't let them drift). A CI check that fails the build when a key exists in en.json but is missing in es.json catches drift before it ships.
What about the actual translations?
This is where most teams stall. You've wired up the library, you have 200 keys in en.json, and now you need... 20 other languages translated, kept in sync as English copy evolves, with placeholders intact and no broken JSON.
Doing this by hand (CSV exports, email threads, Google Translate copy-paste) burns engineering and PM cycles for weeks per release. Two patterns work:
- Translation Management System (Lokalise, Phrase, Crowdin) — upload your JSON, translators edit in a UI, you pull back. Solid but slow and expensive per seat.
- AI-powered automated translation — at build time, an LLM produces the non-English locales from the English source, preserving placeholders and respecting your context. This used to be unreliable; with current Claude/GPT-class models it's production-grade for most app strings.
Localingos is built specifically for path #2 — you push English strings via CLI or API, we translate to 60+ locales while preserving {{placeholders}}, ICU fragments, and brand terms you define, and you pull the result back. It plugs into the exact useTranslation setup above with no code changes. If you want to skip the "now I need to translate 200 keys" step, give it a try — there's a free tier that covers small apps end to end.
Wrap up
You now have a complete react-i18next setup: detection, persistence, interpolation, plurals, namespaces, lazy loading, and typed keys. The library is mature, the patterns above will hold for the foreseeable future, and the only remaining problem is the human-language one: who actually produces the translations and keeps them current. Solve that, ship, and stop blocking on i18n.