# react-i18next Tutorial: Set Up i18n in a React App (2026)

> A complete react-i18next tutorial for Vite and TypeScript: install and init i18next, useTranslation, Trans, interpolation, plurals, namespaces, Suspense, language switching, typed keys, and tests.
- **Author**: Mohit Sapat
- **Published**: 2026-10-08
- **URL**: https://www.babelize.co/blogs/react-i18next-tutorial

---

<p class="lead">
    react-i18next is the most widely used way to translate a React app, and also one of the easiest to set up in a way that bites you later: a blank screen from a missing Suspense boundary, plurals that only work in English, or every language bundled into the main chunk. This tutorial builds a complete setup for a Vite + React + TypeScript app on the current releases (i18next v26 and react-i18next v17), from install to tests, and explains why each piece is there.
</p>

<h2>The Short Answer</h2>

<p>
    Install <code>i18next</code> and <code>react-i18next</code>, initialize i18next once with <code>initReactI18next</code>, a fallback language, and your translation resources, then call <code>const { t } = useTranslation()</code> in components. Add <code>i18next-browser-languagedetector</code> to pick the user's language and <code>i18next-http-backend</code> to load translation files on demand, wrap the app in <code>&lt;Suspense&gt;</code>, and type your keys with <code>CustomTypeOptions</code>.
</p>

<h2>i18next vs react-i18next: What Each Package Does</h2>

<table>
    <thead>
        <tr>
            <th>Package</th>
            <th>Current major</th>
            <th>Role</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td><code>i18next</code></td>
            <td>v26</td>
            <td>Framework-agnostic core: resources, fallback, interpolation, plurals, formatting</td>
        </tr>
        <tr>
            <td><code>react-i18next</code></td>
            <td>v17</td>
            <td>React bindings: <code>useTranslation</code>, <code>Trans</code>, <code>I18nextProvider</code>, Suspense support</td>
        </tr>
        <tr>
            <td><code>i18next-browser-languagedetector</code></td>
            <td>v8</td>
            <td>Detects language from the query string, cookie, localStorage, or browser settings</td>
        </tr>
        <tr>
            <td><code>i18next-http-backend</code></td>
            <td>v4</td>
            <td>Fetches translation JSON files at runtime; v4 relies on the platform's native <code>fetch</code></td>
        </tr>
    </tbody>
</table>

<p>
    Because the core knows nothing about React, the same configuration and JSON files can also power React Native or a Node.js service. If your app is a Next.js App Router project, read our <a href="/blogs/next-intl-vs-react-i18next/">next-intl vs react-i18next comparison</a> first; react-i18next hooks are client-only there, and next-intl is often the simpler fit.
</p>

<h2>Step 1: Install the Packages</h2>

<pre><code>npm install i18next react-i18next i18next-browser-languagedetector i18next-http-backend</code></pre>

<p>
    You only need the HTTP backend if you load translations at runtime. Small apps with two or three languages can import JSON directly instead; both options are shown below.
</p>

<h2>Step 2: Create Translation Files</h2>

<p>
    i18next organizes strings by language and <strong>namespace</strong>. A namespace is one JSON file, usually per feature, so pages can load only what they use:
</p>

<pre><code>public/
  locales/
    en/
      common.json
      checkout.json
    de/
      common.json
      checkout.json
src/
  i18n.ts
  main.tsx
  App.tsx</code></pre>

<pre><code>{
    "nav": {
        "home": "Home",
        "pricing": "Pricing"
    },
    "greeting": "Welcome back, {{name}}!",
    "cart_one": "{{count}} item in your cart",
    "cart_other": "{{count}} items in your cart",
    "terms": "By continuing you accept our &lt;termsLink&gt;terms&lt;/termsLink&gt;."
}</code></pre>

<p>
    That is <code>public/locales/en/common.json</code>. Files in Vite's <code>public/</code> folder are served as-is at <code>/locales/...</code>, which is exactly where the HTTP backend looks by default. For a refresher on nesting and key naming, see our <a href="/blogs/json-translation-files-explained/">JSON translation files guide</a>.
</p>

<h2>Step 3: Initialize i18next</h2>

<pre><code>// 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 supportedLngs = ["en", "de", "pl", "ar"] as const;

i18n
    .use(HttpBackend)
    .use(LanguageDetector)
    .use(initReactI18next)
    .init({
        supportedLngs,
        fallbackLng: "en",
        load: "languageOnly", // "de-AT" loads "de"
        ns: ["common"],
        defaultNS: "common",
        debug: import.meta.env.DEV,
        interpolation: {
            escapeValue: false, // React already escapes rendered output
        },
        backend: {
            loadPath: `${import.meta.env.BASE_URL}locales/{{lng}}/{{ns}}.json`,
        },
        detection: {
            order: ["querystring", "localStorage", "navigator"],
            lookupQuerystring: "lng",
            caches: ["localStorage"],
        },
    });

export default i18n;</code></pre>

<p>A few of these options deserve an explanation:</p>

<ul>
    <li><strong><code>supportedLngs</code></strong> stops the detector from requesting files for languages you do not ship.</li>
    <li><strong><code>fallbackLng</code></strong> is used for any key missing in the active language, so an untranslated string shows English instead of a raw key.</li>
    <li><strong><code>escapeValue: false</code></strong> is safe in React because JSX escapes strings. Leave it on if you also use i18next outside React to build HTML.</li>
    <li><strong><code>loadPath</code></strong> uses <code>BASE_URL</code> so the app still finds its files when deployed under a sub-path.</li>
</ul>

<p>Import the module once, before rendering, and give the app a Suspense boundary:</p>

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

createRoot(document.getElementById("root")!).render(
    &lt;StrictMode&gt;
        &lt;Suspense fallback={&lt;div className="app-loading" /&gt;}&gt;
            &lt;App /&gt;
        &lt;/Suspense&gt;
    &lt;/StrictMode&gt;,
);</code></pre>

<p>
    <strong>Why Suspense:</strong> <code>useTranslation</code> suspends while a namespace is loading (<code>react.useSuspense</code> defaults to <code>true</code>). Without a boundary, React has nothing to show and the app renders blank or throws. If you prefer to handle loading yourself, set <code>react: { useSuspense: false }</code> and check the <code>ready</code> flag that <code>useTranslation</code> returns.
</p>

<figure class="blog-figure"><img src="/blog/inline/react-i18next-tutorial-1.webp" alt="Diagram of the react-i18next startup flow: src/main.tsx imports ./i18n, src/i18n.ts registers HttpBackend, LanguageDetector and initReactI18next, the detector checks querystring, localStorage and navigator, HttpBackend fetches /locales/en/common.json while Suspense shows the fallback, then useTranslation and t(&quot;greeting&quot;) render" width="1600" height="970" loading="lazy" decoding="async" /><figcaption>From import "./i18n" to the first t() call: detection, the HTTP backend, and the Suspense fallback.</figcaption></figure>

<h3>Alternative: bundle the translations</h3>

<p>
    For a small app, skip the backend and import the files. Translations are then available synchronously, and no Suspense fallback ever shows:
</p>

<pre><code>// src/i18n.ts (bundled variant)
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import enCommon from "./locales/en/common.json";
import deCommon from "./locales/de/common.json";

i18n.use(initReactI18next).init({
    resources: {
        en: { common: enCommon },
        de: { common: deCommon },
    },
    lng: "en",
    fallbackLng: "en",
    defaultNS: "common",
    interpolation: { escapeValue: false },
    initAsync: false,
});

export default i18n;</code></pre>

<p>
    The trade-off is size: every user downloads every language. Switch to the backend, or to dynamic <code>import()</code> per language, once that starts to matter. (<code>initAsync</code> replaced the old <code>initImmediate</code> option, which i18next v26 no longer accepts.)
</p>

<h2>Step 4: Translate Components with useTranslation</h2>

<pre><code>// src/components/Header.tsx
import { useTranslation } from "react-i18next";

export function Header({ userName, cartCount }: { userName: string; cartCount: number }) {
    const { t } = useTranslation();

    return (
        &lt;header&gt;
            &lt;nav&gt;
                &lt;a href="/"&gt;{t("nav.home")}&lt;/a&gt;
                &lt;a href="/pricing"&gt;{t("nav.pricing")}&lt;/a&gt;
            &lt;/nav&gt;
            &lt;p&gt;{t("greeting", { name: userName })}&lt;/p&gt;
            &lt;p&gt;{t("cart", { count: cartCount })}&lt;/p&gt;
        &lt;/header&gt;
    );
}</code></pre>

<p>
    Components that use <code>useTranslation</code> re-render automatically when the language changes. To avoid repeating a long prefix, pass <code>keyPrefix</code>: <code>useTranslation("common", { keyPrefix: "nav" })</code> lets you write <code>t("home")</code>.
</p>

<h2>Step 5: Interpolation and Formatting</h2>

<p>
    i18next placeholders use double curly braces: <code>{{name}}</code>. Pass values in the options object, never by concatenating strings, because word order changes between languages.
</p>

<p>
    The built-in formatter wraps the <code>Intl</code> APIs, so numbers, currencies, dates, and relative times format per language:
</p>

<pre><code>{
    "orderTotal": "Total: {{amount, currency}}",
    "shippedOn": "Shipped on {{date, datetime}}",
    "downloads": "{{value, number}} downloads"
}</code></pre>

<pre><code>t("orderTotal", {
    amount: 1499.5,
    formatParams: { amount: { currency: order.currency } },
});
// en: "Total: $1,499.50"  de: "Total: 1.499,50 $"

t("shippedOn", {
    date: new Date(order.shippedAt),
    formatParams: { date: { dateStyle: "long" } },
});</code></pre>

<p>
    Take the currency from your data, not from the language: a German user looking at a USD invoice should still see dollars, formatted the German way. Our <a href="/blogs/javascript-intl-api-dates-numbers-currencies/">Intl API guide</a> covers the formatting options in depth.
</p>

<h2>Step 6: Plurals with _one and _other</h2>

<p>
    i18next resolves plurals with <code>Intl.PluralRules</code>. You write one key per plural category, with a suffix, and pass <code>count</code>:
</p>

<pre><code>{
    "cart_one": "{{count}} item in your cart",
    "cart_other": "{{count}} items in your cart",
    "cart_zero": "Your cart is empty"
}</code></pre>

<p>
    <code>t("cart", { count: 3 })</code> picks <code>cart_other</code>. The optional <code>_zero</code> key is a special case: when it exists and <code>count</code> is 0, i18next uses it even in languages like English that have no grammatical zero form.
</p>

<p>Other languages need other categories. Polish uses one, few, many, and other:</p>

<pre><code>{
    "cart_one": "{{count}} produkt w koszyku",
    "cart_few": "{{count}} produkty w koszyku",
    "cart_many": "{{count}} produktów w koszyku",
    "cart_other": "{{count}} produktu w koszyku"
}</code></pre>

<p>
    Arabic uses all six categories (zero, one, two, few, many, other). Your code does not change; only the translation files do. That is the main reason to never write <code>count === 1 ? ... : ...</code> in a component. Ordinals ("1st", "2nd") work the same way with <code>t("place", { count, ordinal: true })</code> and keys like <code>place_ordinal_one</code>. For background on why categories beat if/else, see our <a href="/blogs/icu-messageformat-plurals-guide/">guide to plurals and ICU MessageFormat</a>.
</p>

<p>
    i18next's format is not ICU. If your translators or other apps already use ICU MessageFormat, add the <code>i18next-icu</code> plugin rather than mixing syntaxes in the same files.
</p>

<h2>Step 7: Rich Text with the Trans Component</h2>

<p>
    When a sentence contains a link or bold text, splitting it into three keys breaks translation. Use <code>Trans</code> with named components instead:
</p>

<pre><code>// src/components/TermsNotice.tsx
import { Trans } from "react-i18next";
import { Link } from "react-router";

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

<p>
    The translation <code>"By continuing you accept our &lt;termsLink&gt;terms&lt;/termsLink&gt;."</code> keeps the whole sentence together, translators can move the tag, and the rendered link is a real router component. Pass interpolation values through the <code>values</code> prop and a plural count through <code>count</code>. Prefer named components over the older indexed form (<code>&lt;1&gt;...&lt;/1&gt;</code>); indexes break as soon as someone edits the JSX.
</p>

<h2>Step 8: Namespaces and Lazy Loading</h2>

<p>
    Load feature namespaces only where they are used. Asking for a namespace that is not loaded yet makes the HTTP backend fetch it, and the component suspends until it arrives:
</p>

<pre><code>// src/pages/Checkout.tsx
import { useTranslation } from "react-i18next";

export default function Checkout() {
    // "checkout" is primary; "common" is available as a fallback namespace
    const { t } = useTranslation(["checkout", "common"]);

    return (
        &lt;main&gt;
            &lt;h1&gt;{t("title")}&lt;/h1&gt;
            &lt;button&gt;{t("common:nav.home")}&lt;/button&gt;
        &lt;/main&gt;
    );
}</code></pre>

<p>
    Combine this with route-level code splitting (<code>React.lazy</code>) and a page's JavaScript and its strings arrive together. To avoid a loading flash on navigation, preload in a route loader or on hover with <code>i18n.loadNamespaces("checkout")</code>.
</p>

<figure class="blog-figure"><img src="/blog/inline/react-i18next-tutorial-2.webp" alt="Diagram of namespace lazy loading: at app start only common.json loads; opening Checkout lazy-loads the page chunk and useTranslation([&quot;checkout&quot;, &quot;common&quot;]) fetches /locales/en/checkout.json and suspends; then t(&quot;title&quot;) and t(&quot;common:nav.home&quot;) render. i18n.loadNamespaces(&quot;checkout&quot;) preloads it" width="1600" height="800" loading="lazy" decoding="async" /><figcaption>Feature namespaces load the first time a component asks for them; preload to skip the fallback.</figcaption></figure>

<p>
    Translation files in <code>public/</code> are not fingerprinted, so browsers and CDNs may cache stale copies after a deploy. Add a version query string through the backend's <code>queryStringParams</code> option (for example, a build ID) or set short cache headers on <code>/locales/</code>.
</p>

<h2>Step 9: Language Switching</h2>

<pre><code>// src/components/LanguageSwitcher.tsx
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { supportedLngs } from "../i18n";

const labels: Record&lt;(typeof supportedLngs)[number], string&gt; = {
    en: "English",
    de: "Deutsch",
    pl: "Polski",
    ar: "العربية",
};

export function LanguageSwitcher() {
    const { i18n } = useTranslation();
    const current = i18n.resolvedLanguage ?? "en";

    useEffect(() =&gt; {
        document.documentElement.lang = current;
        document.documentElement.dir = i18n.dir(current);
    }, [current, i18n]);

    return (
        &lt;select value={current} onChange={(e) =&gt; i18n.changeLanguage(e.target.value)}&gt;
            {supportedLngs.map((lng) =&gt; (
                &lt;option key={lng} value={lng}&gt;
                    {labels[lng]}
                &lt;/option&gt;
            ))}
        &lt;/select&gt;
    );
}</code></pre>

<p>
    Use <code>i18n.resolvedLanguage</code>, not <code>i18n.language</code>, for UI state: <code>language</code> can be a detected value like <code>de-AT</code>, while <code>resolvedLanguage</code> is the supported language actually in use. <code>changeLanguage</code> loads any missing namespaces, and the detector caches the choice in localStorage. Setting <code>dir</code> matters for Arabic and Hebrew; pair it with CSS logical properties.
</p>

<h2>Step 10: Type-Safe Keys with TypeScript</h2>

<p>Augment i18next's <code>CustomTypeOptions</code> with your source-language resources:</p>

<pre><code>// src/@types/i18next.d.ts
import "i18next";
import common from "../../public/locales/en/common.json";
import checkout from "../../public/locales/en/checkout.json";

declare module "i18next" {
    interface CustomTypeOptions {
        defaultNS: "common";
        resources: {
            common: typeof common;
            checkout: typeof checkout;
        };
    }
}</code></pre>

<p>
    Now <code>t("nav.hom")</code> is a compile error, keys autocomplete, and recent versions also type interpolation values (for example, <code>{{value, number}}</code> expects a number). Make sure <code>resolveJsonModule</code> is enabled in <code>tsconfig.json</code> and that the declaration file is inside an included folder. On large catalogs, the optional selector API (<code>enableSelector</code> in <code>CustomTypeOptions</code>, then <code>t(($) =&gt; $.nav.home)</code>) keeps editor type-checking fast.
</p>

<h2>Step 11: Test Components with a Real Instance</h2>

<p>
    Mocking <code>t</code> to return the key hides broken interpolation and plurals. Create a dedicated instance with bundled resources and no Suspense instead:
</p>

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

export function createTestI18n() {
    const instance = i18next.createInstance();
    instance.use(initReactI18next).init({
        lng: "en",
        fallbackLng: "en",
        defaultNS: "common",
        resources: { en: { common } },
        interpolation: { escapeValue: false },
        react: { useSuspense: false },
        initAsync: false,
        saveMissing: true,
        missingKeyHandler: (_lngs, ns, key) =&gt; {
            throw new Error(`Missing translation: ${ns}:${key}`);
        },
    });
    return instance;
}</code></pre>

<pre><code>// src/components/Header.test.tsx
import { render, screen } from "@testing-library/react";
import { I18nextProvider } from "react-i18next";
import { expect, test } from "vitest";
import { Header } from "./Header";
import { createTestI18n } from "../test/i18n";

test("pluralizes the cart count", () =&gt; {
    render(
        &lt;I18nextProvider i18n={createTestI18n()}&gt;
            &lt;Header userName="Ada" cartCount={2} /&gt;
        &lt;/I18nextProvider&gt;,
    );
    expect(screen.getByText("2 items in your cart")).toBeInTheDocument();
});</code></pre>

<p>
    Add a CI step that compares each language's keys against English (<a href="/blogs/automate-app-localization-ai-react-github/">here is a ready-made script and GitHub Actions workflow</a>), and run the app in a pseudo-locale to catch hard-coded strings and truncated layouts; our <a href="/blogs/pseudo-localization-testing-guide/">pseudo-localization guide</a> walks through it.
</p>

<h2>Common Pitfalls</h2>

<ul>
    <li><strong>No Suspense boundary.</strong> With the default <code>useSuspense: true</code> and an async backend, the app renders nothing while translations load.</li>
    <li><strong>Calling <code>t</code> at module scope.</strong> A constant like <code>const LABEL = i18n.t("save")</code> is evaluated once, often before resources load, and never updates. Call <code>t</code> during render.</li>
    <li><strong>Omitting <code>count</code>.</strong> Plural suffixes are only chosen when you pass <code>count</code>; passing the number under another name skips plural resolution.</li>
    <li><strong>Plural logic in code.</strong> <code>count === 1 ? "item" : "items"</code> is wrong for Polish, Russian, and Arabic. Let suffixed keys do the work.</li>
    <li><strong>Leftover v3-style plural keys.</strong> Very old projects used <code>key_plural</code> and numeric suffixes; current i18next only understands CLDR suffixes like <code>_one</code> and <code>_other</code>.</li>
    <li><strong>Mixing ICU and i18next syntax.</strong> Without <code>i18next-icu</code>, ICU braces render literally.</li>
    <li><strong>Using <code>i18n.language</code> for comparisons.</strong> It may be <code>en-US</code> while your files are <code>en</code>; use <code>resolvedLanguage</code>.</li>
    <li><strong>Duplicate react-i18next copies in a monorepo.</strong> Two copies mean two contexts, and components complain that no i18next instance was passed. Dedupe the dependency.</li>
</ul>

<h2>Frequently Asked Questions</h2>

<h3>What is the difference between i18next and react-i18next?</h3>
<p>
    i18next is the framework-agnostic core that stores translations and handles fallback, interpolation, plurals, and formatting. react-i18next is the React binding on top of it, adding the useTranslation hook, the Trans component, I18nextProvider, and Suspense support. You install and initialize both.
</p>

<h3>How do plurals work in react-i18next?</h3>
<p>
    Write one key per plural category with a suffix such as item_one and item_other, then call t("item", { count }). i18next picks the suffix with Intl.PluralRules, so languages like Polish can add _few and _many keys without any code changes.
</p>

<h3>Do I need Suspense with react-i18next?</h3>
<p>
    Only if translations load asynchronously. useTranslation suspends while namespaces load because react.useSuspense defaults to true, so wrap your app in a Suspense boundary or set useSuspense to false and check the ready flag. Bundled resources with initAsync: false never suspend.
</p>

<h3>How do I add TypeScript types for translation keys in i18next?</h3>
<p>
    Create a declaration file that augments the i18next module's CustomTypeOptions interface with defaultNS and a resources object built from your source-language JSON files. TypeScript then autocompletes keys and reports unknown keys in t() and the Trans component.
</p>

<h3>Should I use react-i18next with Next.js?</h3>
<p>
    It works, but in the App Router its hooks only run in Client Components, so server rendering needs a separate i18next instance per request. For Next.js-only projects, next-intl is often simpler; react-i18next fits better when you share translations with React Native, Vite apps, or a Node.js backend.
</p>

<h2>Wrapping Up</h2>

<p>
    A solid react-i18next setup comes down to a handful of decisions: one i18n module imported before render, a Suspense boundary, namespaces per feature loaded on demand, suffixed plural keys instead of conditionals, <code>Trans</code> for rich text, and <code>CustomTypeOptions</code> so typos fail the build. Once the code is in place, the ongoing work is keeping every language file in step with English; if you want help with that, <a href="https://docs.babelize.co/features/file-formats" target="_blank" rel="noopener noreferrer">Babelize generates per-language JSON files</a> that react-i18next can load.
</p>
---
- [All articles](https://www.babelize.co/blogs)