React i18next Component Patterns
When a React component renders welcome.title verbatim instead of “Welcome back”, you are looking at the canonical react-i18next failure: a useTranslation call ran before its namespace bundle finished loading, and the t() function returned the raw key. This guide standardizes the component patterns — useTranslation, the Trans component for embedded markup, namespace splitting, Suspense versus useSuspense: false, and lazy backends — that keep that flash of raw keys off the screen and keep your extraction pipeline honest.
The patterns here assume react-i18next v13+ on i18next v23+, React 18 with concurrent rendering, and a bundler that supports dynamic import() for lazy locale chunks. Every snippet is runnable against a standard Vite or Next.js client tree.
Prerequisites
Concept & spec — where react-i18next sits
react-i18next is a thin React binding over i18next: i18next owns the resource store, the plural resolver, and interpolation; the React layer owns subscription and re-render. The library’s plural and selection behavior is driven by the Unicode CLDR plural rules through i18next’s Intl.PluralRules-backed resolver, and its interpolation honors the same category boundaries you would hit when handling pluralization in Arabic and Slavic languages. Message bodies are i18next’s own format by default but can be switched to ICU MessageFormat via the i18next-icu post-processor when you need full select/plural grammar.
This component layer is one node in the broader Framework i18n & Component Routing approach: route-level locale resolution decides which language is active, and these patterns decide how each component subscribes to it. The two must agree on a single active locale, or a changeLanguage call in a route guard will re-render every subscribed component while a stale bundle is still loading.
A useTranslation(ns) call does three things: it reads the active language, it requests the named namespace from the configured backend if absent, and it subscribes the component to languageChanged and loaded events so the component re-renders when either fires. That subscription is the whole reason you cannot simply read i18next’s store from a module-level constant — without it, a changeLanguage call would mutate the store but leave the React tree showing stale strings. Understanding this lifecycle is what separates a component that flashes raw keys from one that renders cleanly: the hook returns a ready boolean precisely so you can decide what to paint during the window between mount and the loaded event.
The active language itself is not chosen here. It is resolved upstream — by a URL prefix, a cookie, or an Accept-Language parse — and handed to i18next via init({ lng }) or a changeLanguage call. These component patterns are deliberately agnostic to that decision; their only contract is that exactly one language is active per render pass and that the namespace bundle for the strings on screen has either loaded or is being awaited.
Step-by-step implementation
1. Initialize i18next once
Create one init module and import it at the entry point — never inside feature components. Enabling react: { useSuspense: false } here makes load failures recoverable instead of throwing at a boundary; flip it to true only when every consumer sits under a Suspense boundary.
// i18n.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import resourcesToBackend from 'i18next-resources-to-backend';
i18n
.use(resourcesToBackend((lng: string, ns: string) =>
import(`./locales/${lng}/${ns}.json`)))
.use(initReactI18next)
.init({
fallbackLng: 'en',
ns: ['common'],
defaultNS: 'common',
interpolation: { escapeValue: false }, // React already escapes
react: { useSuspense: false },
});
export default i18n;
The resourcesToBackend adapter is the key choice here: it turns a dynamic import() into i18next’s backend interface, so each locales/<lng>/<ns>.json becomes a separately fetched chunk rather than a string baked into the main bundle. Prefer it over i18next-http-backend when your build pipeline already code-splits, because it lets the bundler hash and cache each namespace independently. Set escapeValue: false because React escapes interpolated children itself; leaving the i18next default of true double-escapes ampersands and angle brackets in translated content.
2. Mount the provider
Wrap the tree once with I18nextProvider. Provider nesting causes redundant subscriptions and breaks React.memo boundaries, so mount exactly one instance above your router.
import { I18nextProvider } from 'react-i18next';
import i18n from './i18n';
export const App = ({ children }: { children: React.ReactNode }) => (
<I18nextProvider i18n={i18n}>{children}</I18nextProvider>
);
In single-instance apps you can skip I18nextProvider entirely — initReactI18next registers a default instance that every hook picks up. Keep the explicit provider when you run more than one instance (for example, an isolated instance per micro-frontend) so each subtree subscribes to the right store. Mounting two providers around the same instance is the common mistake: it doubles every subscription and defeats React.memo, because both providers re-render their children on every languageChanged event.
3. Read strings with useTranslation
Pass the namespace explicitly so the hook requests the right bundle and so extraction tooling can resolve keys statically. Destructure ready to gate output when Suspense is disabled.
import { useTranslation } from 'react-i18next';
export const Greeting = ({ name }: { name: string }) => {
const { t, ready } = useTranslation('dashboard');
if (!ready) return <span aria-busy="true" />;
return <h1>{t('greeting', { name })}</h1>;
};
The ready guard matters only when useSuspense is false; with Suspense enabled the hook never returns until the bundle resolves, so ready is always true inside the boundary. For pluralized strings, pass count and let i18next select the CLDR category — t('items', { count }) resolves items_one, items_other, and the additional categories that Slavic and Arabic locales require. Never hand-build the suffix yourself; the resolver reads the active locale’s plural rules and picks the form.
4. Render embedded markup with Trans
For strings that contain inline elements — a link, <strong>, a <br/> — use Trans so translators see one coherent sentence instead of three fragmented keys. Child elements are referenced positionally; the translation file uses numbered tags.
import { Trans } from 'react-i18next';
export const Notice = ({ count }: { count: number }) => (
<Trans i18nKey="quota.notice" count={count} ns="dashboard">
You have used <strong>{{ count }}</strong> of your seats. <a href="/billing">Upgrade</a>.
</Trans>
);
{ "quota": { "notice": "You have used <1>{{count}}</1> of your seats. <3>Upgrade</3>." } }
The numbers in <1> and <3> are the depth-first indices of the JSX children, counting every node including bare text. In the example, the literal text "You have used " is index 0, the <strong> is 1, the text " of your seats. " is 2, and the <a> is 3 — which is why Upgrade is wrapped in <3>. Get this counting wrong and Trans emits the literal tag, which is the single most common Trans bug. When the markup is stable you can name the components instead, via the components prop, to make the JSON readable: <bold> and <link> rather than <1> and <3>.
5. Split and lazy-load namespaces
Load common eagerly and pull feature namespaces on demand by naming them in each component’s useTranslation call. The resourcesToBackend import in step 1 already code-splits each namespace into its own chunk, so unused locales never reach the client.
const { t } = useTranslation(['settings', 'common']); // settings chunk loads on mount
When you pass an array, the first namespace is the default for unprefixed keys; common trails as the fallback so shared strings resolve without a common: prefix. The practical rule is one namespace per feature surface and a small shared common for buttons, validation messages, and layout chrome. This keeps each lazy chunk under control: a user who never opens billing never downloads the billing strings, which is exactly the bundle-size win you want measured in the verification step below.
6. Choose Suspense or the ready flag deliberately
The two loading strategies are mutually exclusive per consumer, and picking the wrong one is behind most “blank screen” and “raw key flash” reports. With useSuspense: true, a useTranslation call that hits an unloaded namespace throws a promise, suspending the nearest Suspense boundary; React shows that boundary’s fallback until every pending bundle resolves, then commits the whole subtree at once. This gives the cleanest result — no intermediate raw-key state — but only if a boundary actually exists above the consumer. Without one, React surfaces an “A component suspended while responding to synchronous input” error.
// useSuspense: true — needs a boundary
<Suspense fallback={<Spinner />}>
<DashboardPanel /> {/* calls useTranslation('dashboard') */}
</Suspense>
With useSuspense: false, the hook returns immediately with ready: false and a t that echoes keys until the loaded event re-renders the component. You trade the single clean commit for per-component control and recoverable failures — a backend 404 leaves ready false rather than crashing a boundary. Choose Suspense for route-level loading where one spinner covers the whole view; choose the ready flag for widgets that must degrade gracefully or that render outside any boundary, such as a portal-mounted toast.
Configuration reference
| Option | Type | Description / default |
|---|---|---|
ns |
string | string[] |
Namespaces to preload at init. Default 'translation'. |
defaultNS |
string |
Namespace used when a key has no ns: prefix. Default 'translation'. |
fallbackLng |
string | string[] | object |
Locale(s) tried when a key is missing in the active language. Default 'dev'. |
react.useSuspense |
boolean |
When true, components suspend until bundles load; when false, they render and re-render. Default true. |
react.bindI18n |
string |
i18next events that trigger re-render. Default 'languageChanged'. |
partialBundledLanguages |
boolean |
Allow mixing bundled and backend-loaded namespaces. Default false. |
interpolation.escapeValue |
boolean |
Escape interpolated values. Set false under React (React escapes). Default true. |
Framework variants
React (Vite / CRA)
The patterns above apply directly. Wrap lazy routes in a Suspense boundary only if you set useSuspense: true; otherwise the ready gate in each hook is sufficient and avoids a top-level fallback spinner. In a Vite project, the dynamic import() inside resourcesToBackend is statically analyzable, so Vite emits one chunk per locales/<lng>/<ns>.json automatically — no manual manualChunks configuration is needed. Verify in the build output that you see distinct dashboard-en and settings-en chunks rather than one fat locale bundle.
Next.js App Router
Server Components cannot use the useTranslation hook because there is no client context. Resolve messages on the server with an await-able getTranslations-style helper, and mark only the leaves that genuinely need interactivity with 'use client'. Pass already-resolved strings as props into those client leaves, or initialize a client-side instance with the server-fetched resources to avoid a second fetch. Align the active locale with the route segment per the Next.js i18n Routing Setup; a mismatch between the [lang] segment and the i18next instance is the usual cause of a hydration warning after a locale switch, because the server rendered one language and the client hydrated against another.
Angular
Angular does not use react-i18next, but the namespace-and-lazy-load mental model carries over to its localization layer; if you maintain both stacks, mirror the namespace boundaries so a shared TMS sees one consistent key tree. See the Angular Localization Module Setup for the framework-native equivalent.
Vue / Nuxt
The mental model maps cleanly: react-i18next’s useTranslation is vue-i18n’s useI18n, and the namespace concept becomes message scoping. Teams maintaining both stacks should mirror namespace boundaries across them — see the Vue I18n Composition API Guide for the reactive equivalent.
Node.js backend (SSR data)
When pre-rendering, call i18n.cloneInstance() per request and await instance.changeLanguage(lng) before renderToString, so each request gets an isolated language without a shared-state race.
Verification
Confirm that every key referenced in source exists in your resource files before the bundle ships. Run i18next-parser to extract keys, then diff against committed JSON.
# Extract every t() and Trans i18nKey into per-namespace JSON
npx i18next-parser 'src/**/*.{ts,tsx}' \
--output 'locales/$LOCALE/$NAMESPACE.json'
# Fail the build if extraction would add or remove keys
git diff --exit-code locales/
A focused unit test proves the render path resolves keys rather than echoing them:
import { render, screen } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import i18n from './i18n';
import { Greeting } from './Greeting';
test('renders a resolved string, not the raw key', async () => {
await i18n.changeLanguage('en');
render(<I18nextProvider i18n={i18n}><Greeting name="Ada" /></I18nextProvider>);
expect(await screen.findByText(/welcome/i)).toBeInTheDocument();
expect(screen.queryByText('greeting')).toBeNull();
});
In CI, wire saveMissing: true with a missingKeyHandler that throws in test mode so any unresolved key fails the pipeline rather than silently degrading.
Common pitfalls
- Raw keys flash on first paint. A namespace loaded asynchronously but rendered before
ready. Gate onready, or setuseSuspense: trueunder a boundary. Closely related: the missing translation warning in react-intl covers the equivalent diagnostic in the FormatJS stack. Transrenders literal<1>tags. The child element indices in the component do not match the numbered tags in the JSON. Count elements depth-first starting at 0 and align the JSON tag numbers.- Hydration mismatch after
changeLanguage. The server rendered one locale and the client switched before hydration finished. Resolve the locale on the server and never callchangeLanguageduring the first client render. - Keys orphaned in the TMS. Dynamic key construction (
t(\item.${id}`)`) is invisible to static extraction. Keep keys literal, or whitelist dynamic patterns in the parser config. The trade-offs between extraction toolchains are covered in formatjs vs Lingui extraction pipeline. - Double escaping. Leaving
interpolation.escapeValue: trueunder React escapes already-escaped output. Set it tofalse.
FAQ
When should I use the Trans component instead of the t() function?
Use t() for plain strings and simple {{value}} interpolation. Use Trans only when the translated string contains inline JSX — a link, bold text, or a line break — because Trans lets a translator work with one continuous sentence and preserves the element nesting. Splitting a sentence into three t() calls around an <a> produces ungrammatical translations in languages with different word order.
Should I enable Suspense or set useSuspense to false?
Set useSuspense: true when every component that calls useTranslation sits under a Suspense boundary and you want a single fallback while bundles load. Set useSuspense: false when you prefer per-component control via the ready flag, when you render outside a boundary, or when you need load failures to be recoverable rather than thrown. Mixing the two without a boundary is the usual cause of an “A component suspended” error.
How do namespaces affect lazy loading?
Each namespace is a separate resource bundle. When a component calls useTranslation('settings'), react-i18next requests only that namespace from the backend, and a dynamic-import backend code-splits it into its own chunk. Keep common small and eager; scope feature strings into their own namespaces so a user who never opens settings never downloads settings strings.
Why does t() return the key string instead of the translation?
Almost always the namespace had not finished loading when the component rendered, or the key is genuinely missing from the active language. Gate rendering on ready, confirm the namespace is listed in ns or requested by the hook, and enable saveMissing in development so missing keys are logged rather than silently echoed.
Related
- Next.js i18n Routing Setup — align route-segment locale resolution with the i18next instance to avoid hydration mismatches.
- Vue I18n Composition API Guide — the reactive equivalent of these hook and scoping patterns.
- Missing translation warning in react-intl — diagnosing unresolved keys in the FormatJS stack.
- formatjs vs Lingui extraction pipeline — choosing a key-extraction toolchain for React.
- ICU Message Format Deep Dive — switching i18next message bodies to full ICU grammar.
Part of Framework i18n & Component Routing.