All articles
11 min read

React i18n: From react-i18next Setup to a Real Translation Workflow

Setting up react-i18next properly, structuring translation keys that survive refactors, and connecting the app to a workflow translators can actually use.

Reacti18nJSON

Most React i18n guides stop at t('hello'). That gets you a demo, not a shippable product. The parts that actually cost time later are embedded markup inside sentences, key names that break the moment someone renames a component, plural rules for languages that have more than two forms, and the file churn you get when translators and developers edit the same JSON.

This walks through react-i18next end to end – init, provider, hooks, the Trans component, namespaces, ICU plurals, RTL, locale persistence, testing – and then the workflow around it: extracting keys, handing them to translators, and syncing files back without merge hell.

Install and initialise

Three packages cover almost every app: the core library, the React bindings, and browser language detection.

npm install i18next react-i18next i18next-browser-languagedetector

Keep the init in one file and import it once, at the top of your entry point, before React renders.

// src/i18n/index.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';

import enCommon from './locales/en/common.json';
import enCheckout from './locales/en/checkout.json';
import lvCommon from './locales/lv/common.json';
import lvCheckout from './locales/lv/checkout.json';

void i18n
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    resources: {
      en: { common: enCommon, checkout: enCheckout },
      lv: { common: lvCommon, checkout: lvCheckout },
    },
    fallbackLng: 'en',
    supportedLngs: ['en', 'lv'],
    defaultNS: 'common',
    interpolation: { escapeValue: false }, // React already escapes
    detection: {
      order: ['querystring', 'cookie', 'localStorage', 'navigator'],
      caches: ['localStorage', 'cookie'],
      lookupQuerystring: 'lang',
    },
  });

export default i18n;

Two settings deserve a comment. escapeValue: false is correct here and only here – React escapes JSX children itself, so double-escaping turns an apostrophe into a mess. And supportedLngs matters more than it looks: without it, a browser reporting en-GB will make i18next look for an en-GB bundle that does not exist. With it, en-GB resolves to en.

Provider setup

If you import the init module for its side effect, react-i18next picks up the default instance automatically and you do not strictly need a provider. Use one anyway – it makes the instance explicit and testable.

// src/main.tsx
import { StrictMode, Suspense } from 'react';
import { createRoot } from 'react-dom/client';
import { I18nextProvider } from 'react-i18next';
import i18n from './i18n';
import App from './App';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <I18nextProvider i18n={i18n}>
      <Suspense fallback={null}>
        <App />
      </Suspense>
    </I18nextProvider>
  </StrictMode>,
);

The Suspense boundary is required as soon as you load namespaces asynchronously – which you will, once the app grows past a few screens.

useTranslation in components

import { useTranslation } from 'react-i18next';

export function CartSummary({ itemCount, total }: Props) {
  const { t } = useTranslation('checkout');

  return (
    <section aria-label={t('cart.summary.label')}>
      <h2>{t('cart.summary.title')}</h2>
      <p>{t('cart.summary.itemCount', { count: itemCount })}</p>
      <p>{t('cart.summary.total', { total })}</p>
    </section>
  );
}

Pass the namespace to the hook rather than prefixing every key with checkout:. It reads better and it means moving a component between namespaces is a one-line change.

The Trans component, properly

This is where most codebases go wrong. You have a sentence with a link or bold text inside it:

"Read our terms before you continue."

The tempting fix is to split it into three keys – prefix, link text, suffix – and concatenate. Do not. Word order differs between languages, and a translator who sees three fragments has no idea what sentence they belong to. Latvian, German and Japanese will all put that link somewhere your English fragments cannot reach.

Trans solves it by letting the translation string carry numbered placeholders for the markup:

import { Trans, useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';

export function TermsNotice() {
  const { t } = useTranslation('checkout');

  return (
    <p>
      <Trans
        t={t}
        i18nKey="terms.notice"
        components={{
          terms: <Link to="/terms" />,
          bold: <strong />,
        }}
      />
    </p>
  );
}

And the translation file:

{
  "terms": {
    "notice": "Read our <terms>terms of service</terms> <bold>before</bold> you continue."
  }
}

The named-component form shown above is far better than the numeric one (<0>, <1>) that older examples use, because a translator can see what each tag means and index drift stops being a class of bug. The child elements you pass carry the props; the text between the tags comes from the translation. Interpolation works inside it too:

{
  "greeting": "Welcome back, <bold>{{name}}</bold> — you have {{count}} unread messages."
}

The rule to enforce in review: one sentence is one key. If you find yourself concatenating t() calls, reach for Trans instead.

Key naming that survives refactors

Two schools exist and both are defensible.

English-as-key (t('Read our terms of service')) gives instant readability in code and a working fallback with zero files. It suits marketing sites and apps whose copy is stable. The cost is that every copywriting tweak – a comma, a capital letter – is a new key that silently falls back to English in every other language, and your JSON is full of keys that are hard to diff.

Structured keys (feature.component.element) cost a lookup when reading code but hold up under refactoring. The convention that works:

checkout.cartSummary.title
checkout.cartSummary.itemCount
checkout.paymentForm.cardNumber.label
checkout.paymentForm.cardNumber.error.invalid
settings.profile.avatar.uploadButton

Feature first, then component, then element, then variant. Sorted alphabetically, related strings cluster; a whole feature is one subtree you can move or delete. Rewording English never orphans a translation, because the key is not the copy.

The honest counter-argument: structured keys make untranslated states invisible. checkout.cartSummary.title renders as a key path, not as a sentence, if the lookup fails. Mitigate it with fallbackLng plus a dev-time handler:

missingKeyHandler: (lngs, ns, key) => {
  if (import.meta.env.DEV) console.warn(`[i18n] missing ${ns}:${key}`);
},
saveMissing: import.meta.env.DEV,

Whichever you pick, pick one and lint it. Mixed conventions are worse than either.

Namespaces and lazy loading

Namespaces are your code-splitting unit. One per feature, plus a common namespace for buttons, validation messages and date labels shared everywhere. Bundling all locales into the main chunk means a French user downloads Japanese.

Swap static imports for the HTTP backend:

npm install i18next-http-backend
import HttpBackend from 'i18next-http-backend';

void i18n
  .use(HttpBackend)
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    ns: ['common'],
    defaultNS: 'common',
    backend: {
      loadPath: '/locales/{{lng}}/{{ns}}.json',
    },
  });

Now useTranslation('checkout') fetches /locales/en/checkout.json on first render and suspends until it arrives. Only common ships up front. If a route is heavy, preload before navigation with i18n.loadNamespaces('checkout').

Interpolation, formatting and plurals

Interpolation is {{ name }} in the string and an object in the call. Formatting numbers, currencies and dates should go through the built-in Intl formatter rather than being pre-formatted in the component – the whole point is that the format is locale-specific.

{
  "cart": {
    "total": "Total: {{total, currency(EUR)}}",
    "placedOn": "Placed on {{date, datetime}}"
  }
}
i18n.init({
  interpolation: {
    escapeValue: false,
  },
});
// values passed as real types, not strings
t('cart.total', { total: 42.5 });
t('cart.placedOn', { date: new Date(order.createdAt) });

Plurals

English has two forms. Latvian has three. Arabic has six. Never write count === 1 ? t('item') : t('items') – that logic does not survive translation. i18next uses Intl.PluralRules categories as key suffixes:

{
  "cart": {
    "itemCount_one": "{{count}} item",
    "itemCount_other": "{{count}} items"
  }
}

The Latvian file for the same key legitimately carries three:

{
  "cart": {
    "itemCount_zero": "{{count}} preču",
    "itemCount_one": "{{count}} prece",
    "itemCount_other": "{{count}} preces"
  }
}

You call it identically – t('cart.itemCount', { count }) – and i18next selects the form. The count option is special-cased; do not rename it. If you need full ICU MessageFormat (nested select, ordinals, gendered forms), add i18next-icu and write standard ICU in the value.

RTL

Arabic and Hebrew need more than translated strings. Set the direction on the document when the language changes, and let CSS logical properties do the rest.

i18n.on('languageChanged', (lng) => {
  const dir = i18n.dir(lng); // 'ltr' | 'rtl'
  document.documentElement.dir = dir;
  document.documentElement.lang = lng;
});

Then replace directional CSS with logical equivalents: margin-inline-start instead of margin-left, padding-inline-end instead of padding-right, inset-inline-start instead of left. Tailwind's ps-* / pe-* / ms-* / me-* utilities map to these directly. Icons that imply direction – back arrows, progress chevrons – need an explicit flip; icons that do not, like a search glass, must not be flipped.

Detecting and persisting locale

The detector config above already covers it: querystring first so shared links carry a language, then cookie and localStorage for returning visitors, then navigator as the fallback. The cookie matters if you ever server-render – it is the only one the server can read.

An explicit switcher writes through the same layer:

function LanguageSwitcher() {
  const { i18n } = useTranslation();
  return (
    <select
      value={i18n.resolvedLanguage}
      onChange={(e) => void i18n.changeLanguage(e.target.value)}
    >
      <option value="en">English</option>
      <option value="lv">Latviešu</option>
    </select>
  );
}

changeLanguage writes to the configured caches automatically, so no extra persistence code is needed. Use resolvedLanguage, not language – the latter can be en-GB while your bundle is en.

Testing components that use translations

Two viable strategies.

Mock the hook when you only care about component logic. Assertions then run against key names, which makes tests immune to copy changes:

vi.mock('react-i18next', () => ({
  useTranslation: () => ({ t: (k: string) => k, i18n: { changeLanguage: vi.fn() } }),
  Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey,
}));

Render with a real instance when you want to catch missing keys and broken interpolation. Build a test-only i18n instance with the real English resources and wrap the component:

// test/i18n.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import common from '../src/i18n/locales/en/common.json';
import checkout from '../src/i18n/locales/en/checkout.json';

void i18n.use(initReactI18next).init({
  lng: 'en',
  resources: { en: { common, checkout } },
  interpolation: { escapeValue: false },
});

export default i18n;

A cheap third test is worth adding to CI: load every locale file, walk the key trees, and fail if a non-English file is missing keys the English one has. It catches the drift that no component test will.

The translation workflow

The code is the easy half. The recurring pain is the loop: developers add keys, translators fill them in, files come back, and someone resolves a conflict in a 3000-line JSON file by hand.

Extract keys instead of hand-editing

Add i18next-parser and let it scan source for t() and Trans usages, writing keys into the locale files with empty values.

# i18next-parser.config.yaml
locales: [en, lv, ar]
defaultNamespace: common
output: src/i18n/locales/$LOCALE/$NAMESPACE.json
keySeparator: '.'
sort: true
keepRemoved: false
npx i18next-parser 'src/**/*.{ts,tsx}'

sort: true is doing quiet, important work – deterministic key order means a diff shows what actually changed rather than a reshuffle.

Hand off without email attachments

Sending JSON to translators over email produces version drift within a week. Whatever platform you use, the requirements are the same: translators work in a UI without touching the repo, developers pull finished translations back, and the two never overwrite each other. If your translators prefer desktop CAT tools, export XLIFF rather than JSON – it is bilingual, pairing each string with its source text so the translator has context.

Keep the namespace split across the handoff – if common.json and checkout.json come back as separate files, a translator finishing checkout copy never touches the file your next release edits.

Avoid merge hell

Merge conflicts in locale files come from two sides editing one file at once. Two things fix most of it:

  1. Split by namespace. Small files, edited by different features, conflict far less than one giant translation.json.
  2. Branch translations the same way you branch code. A feature branch gets its own translation branch, and merging back to main is an explicit, permission-guarded step rather than a text diff.

In CI, pull approved translations before build so the repo is never the coordination point:

- name: Pull approved translations
  run: npx @translatize/cli pull --branch main
  env:
    TRANSLATIZE_API_TOKEN: ${{ secrets.TRANSLATIZE_API_TOKEN }}

- name: Fail the build on missing strings
  run: npx @translatize/cli status --branch main --fail-on-missing
  env:
    TRANSLATIZE_API_TOKEN: ${{ secrets.TRANSLATIZE_API_TOKEN }}

The project and the on-disk file pattern come from a translatize.config.json written once by translatize init, so the commands themselves stay short. The token is an integration token prefixed mcni_, bound to a single project and branch, which is why a CI job holding a feature-branch token cannot write to main by accident. Current flags are listed in the docs.

For the file-format mechanics behind this – nested versus flat JSON, and what survives a round trip – see translating JSON files with AI. If you are on Next.js rather than a Vite SPA, the App Router setup differs enough to warrant its own guide.

Where Translatize fits

A react-i18next app produces one JSON file per namespace per locale, and that is where the loop breaks: two branches both add keys to checkout.json, and Git resolves the collision as text.

Translatize branches translations the way you branch code. A feature branch gets its own translation branch, keys added there stay isolated, and only an admin or owner merges to main – so a translator can own a branch with no path to production strings. Four merge strategies cover the real cases: overwrite as a non-destructive union where the source wins, replace, keep-newer by per-label timestamp, and manual key-by-key resolution. Structured keys like checkout.cartSummary.title import from nested or flat JSON unchanged, and the same content exports to XLIFF 1.2, Android XML or iOS .strings – nine formats, symmetric both ways. Each label carries a review status and fields for description, screenshot and character limit.

Wrapping up

The setup is an afternoon: init, provider, hook. The decisions that matter are the ones you make in the first week – one sentence per key, Trans for anything with markup, structured keys if the app will be refactored, namespaces as your splitting unit, and ICU plural forms instead of ternaries.

Get those right and adding a language later is a content task, not an engineering project. Get them wrong and every new locale re-opens the same code.

Keep reading