Skip to content

Localize a React app: complete guide with Localingos

If you have a React app and want to ship it in more than English, this guide walks you from zero to a fully localized build in about 20 minutes. We'll use react-i18next as the runtime library (it's the most mature React i18n option in 2026) and Localingos as the automated translation backend that keeps your locale files in sync as English copy evolves.

By the end you'll have: detection of the user's preferred language, persistence across sessions, interpolation with variables preserved exactly, and a CI step that pulls new translations on every push.

Why automate React localization

The hardest part of React i18n isn't react-i18next — that's a one-day setup. The hard part is keeping es.json, de.json, fr.json, and every other locale file in sync as your English copy changes weekly. Teams that do this manually (CSV exports, agency emails, hand-edited JSON) burn engineering time on every release and ship missing-key errors to users.

Localingos solves the second problem: you maintain en-US.json, push it, and we return your other target locales with placeholders preserved exactly. No more "Spanish version of the app is two sprints behind."

Step 1 — Install Localingos and react-i18next

npm install react-i18next i18next i18next-browser-languagedetector
npm install -g localingos

The CLI authenticates with an API key — there is no login command. Create a project and an API key first (ProjectsCreate Project, then Developer ToolsAPI Keys), and supply the key one of two ways:

  • .localingos.json in your project root (gitignored) for local development — localingos init writes this for you.
  • The LOCALINGOS_API_KEY environment variable, which takes precedence and is what you use in CI.

Step 2 — Wire react-i18next

Create src/i18n/index.ts:

import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';

import enUS from './en-US.json';
import esES from './es-ES.json';
import deDE from './de-DE.json';
import frFR from './fr-FR.json';

// Use the same locale codes Localingos uses — full BCP 47, e.g. en-US, not en.
// The CLI writes one file per target locale named after that code, so your
// imports, your resource keys and your <option> values should all match it.
export const SUPPORTED_LOCALES = ['en-US', 'es-ES', 'de-DE', 'fr-FR'] as const;

i18n
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    resources: {
      'en-US': { translation: enUS },
      'es-ES': { translation: esES },
      'de-DE': { translation: deDE },
      'fr-FR': { translation: frFR },
    },
    fallbackLng: 'en-US',
    supportedLngs: SUPPORTED_LOCALES,
    // Without this, a browser reporting en-GB resolves to a language you don't
    // ship and the switcher renders an empty selection on first visit.
    nonExplicitSupportedLngs: true,
    interpolation: { escapeValue: false },
    detection: {
      order: ['localStorage', 'navigator'],
      caches: ['localStorage'],
    },
  });

export default i18n;

Import once in your entry file — src/main.tsx on a Vite scaffold, src/index.tsx on Create React App:

import './i18n';

Create src/i18n/en-US.json — this is your source of truth:

{
  "welcome": "Welcome, {{name}}"
}

Watch out for i18next's locale canonicalization. i18next rewrites some deprecated language subtags before it looks up a bundle. The clearest case is Tagalog: Localingos writes tl-PH.json, but i18next rewrites tl to fil, so the bundle loads and is then unreachable — t() silently returns your fallback language with no error and nothing in the console. If you ship tl-PH, register it under both codes:

resources: {
  'tl-PH': { translation: tlPH },
  'fil-PH': { translation: tlPH },
}

Step 3 — Configure Localingos

Run localingos init in your project root and answer its prompts. It writes two files:

localingos.config.json — commit this. It holds the project settings you share with your team and CI:

{
  "projectId": "your-project-id",
  "sourceLocale": "en-US",
  "format": "json-nested",
  "sourceFile": "src/i18n/en-US.json",
  "outputDir": "src/i18n",
  "outputPattern": "{locale}.json"
}

.localingos.json — add this to .gitignore. It holds your API key for local development:

{ "apiKey": "your-api-key" }

init is interactive. If you need to configure a container, a provisioning script, or an AI agent non-interactively, just write those two files yourself — the CLI reads nothing else, and hand-written files are equivalent to generated ones.

Three things worth knowing:

  • format decides your key shape. json-nested turns home.title into { "home": { "title": … } }; json-flat keeps "home.title" as a single top-level key. Pick the one your runtime expects — react-i18next handles nested keys out of the box with its default . separator.
  • Target locales live on the project, not in this file. There is no locale list in localingos.config.json. The CLI writes a file for every target locale configured on the project itself, so you can add a language without touching your repo. See Step 4.
  • Placeholder preservation is automatic. Localingos extracts the placeholders in your source string — {{name}}, ${count}, %s, %d, ICU fragments — and validates that each one appears in the translation, retrying with a corrective prompt when it doesn't. There is nothing to configure.

Step 4 — Choose your target locales

Target locales are a property of the project, set in the dashboard:

  1. Go to Projects.
  2. Press Edit on the project row (this opens the project panel).
  3. Add or remove locales in the Locales field, then press Update Project.

Locales use full BCP 47 codes — es-ES, pt-BR, zh-TW. Adding one here is all it takes; the next localingos sync writes the new file. Removing one stops future translations for it.

Step 5 — Sync

localingos sync

This pushes new and changed keys from en-US.json and pulls back whatever translations are ready.

Translation is asynchronous. On the first sync of a new key, the push succeeds and there is usually nothing to pull yet:

Push: 10 created, 0 updated, 0 deleted, 0 unchanged
✅ 0 translations received
⏳ 10 keys pending translation: home.title, home.subtitle, …
No new translations. Run "localingos sync" again later.

That is expected, not an error. Run localingos sync (or localingos pull) again a few seconds later to collect the results. Subsequent syncs only translate changed keys, so unchanged strings are never re-billed.

Check the files before you commit them. pull writes whatever is ready at that moment and exits 0, so a file pulled mid-translation can be missing keys without any warning. Verify every locale has the key count you expect:

for f in src/i18n/*.json; do
  echo "$f: $(node -e "
    const flat=(o,p='')=>Object.entries(o).flatMap(([k,v])=>
      typeof v==='object'&&v?flat(v,p+k+'.'):[p+k]);
    console.log(flat(require('./$f')).length)
  ") keys"
done

Commit the resulting es-ES.json, de-DE.json, etc. — these files belong in git so PR reviewers see exactly what shipped.

Step 6 — Use in components

import { useTranslation } from 'react-i18next';

export const Welcome: React.FC<{ name: string }> = ({ name }) => {
  const { t } = useTranslation();
  return <h1>{t('welcome', { name })}</h1>;
};

That's it. No imports per language, no string formatting code in components, and {{name}} comes back intact in every locale.

Plurals need a deliberate decision

Localingos translates the strings you give it. It does not expand a two-form English plural into the four forms Polish needs, or the six Welsh needs — a translated string comes back with the same number of forms you sent. Getting plurals right is therefore a decision you make in your source file and your runtime, not something the sync does for you.

With react-i18next, author every form your target languages require using i18next's suffix convention, and let the library select among them:

{
  "cart_one": "{{count}} item in your cart",
  "cart_other": "{{count}} items in your cart"
}

react-i18next picks the variant using CLDR rules for the active language, so it will ask for cart_few or cart_many in languages that have those categories. If your source only defines _one and _other, those keys won't exist and the library falls back. For a language set that includes Slavic or Celtic locales, either add the extra suffixed keys to your source file or keep count-bearing copy out of translation and format numbers separately.

Step 7 — Language switcher

import { useTranslation } from 'react-i18next';
import { SUPPORTED_LOCALES } from './i18n';

const LABELS: Record<string, string> = {
  'en-US': 'English',
  'es-ES': 'Español',
  'de-DE': 'Deutsch',
  'fr-FR': 'Français',
};

export const LanguageSwitcher: React.FC = () => {
  const { i18n } = useTranslation();
  // resolvedLanguage is the locale actually in use, which is what the <select>
  // must reflect — i18n.language can hold a code you don't ship (e.g. en-GB).
  const active = i18n.resolvedLanguage ?? 'en-US';
  return (
    <select value={active} onChange={e => i18n.changeLanguage(e.target.value)}>
      {SUPPORTED_LOCALES.map(code => (
        <option key={code} value={code}>{LABELS[code] ?? code}</option>
      ))}
    </select>
  );
};

Because the detector is configured with caches: ['localStorage'], this choice persists across sessions — returning users land in their last picked language.

Step 8 — Automate sync in CI

Add to .github/workflows/i18n.yml:

name: i18n-sync
on:
  push:
    branches: [main]
    paths: ['src/i18n/en-US.json']
jobs:
  sync:
    runs-on: ubuntu-latest
    permissions: { contents: write, pull-requests: write }
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm install -g localingos
      # Push the source strings, then poll until every locale is complete.
      # A single sync would commit a partial set, because translation is async.
      - run: |
          localingos push
          for i in $(seq 1 20); do
            localingos pull
            if node scripts/i18n-complete.cjs; then
              echo "all locales complete"; exit 0
            fi
            echo "waiting for translations (attempt $i)..."; sleep 15
          done
          echo "timed out with incomplete translations" >&2; exit 1
        env:
          LOCALINGOS_API_KEY: ${{ secrets.LOCALINGOS_API_KEY }}
      - uses: peter-evans/create-pull-request@v6
        with:
          commit-message: "chore(i18n): sync translations"
          branch: i18n/auto-sync
          title: "chore(i18n): sync translations"

The loop matters. localingos pull writes whatever is ready and exits 0, so a workflow that syncs once and commits will open a green PR containing partially translated files. scripts/i18n-complete.cjs is a few lines that exits non-zero while any locale is missing keys. Note the .cjs extension: Vite and most modern scaffolds set "type": "module" in package.json, which makes a .js helper using require fail outright.

// scripts/i18n-complete.cjs
const fs = require('fs');
const path = require('path');
const dir = 'src/i18n';
const flat = (o, p = '') =>
  Object.entries(o).flatMap(([k, v]) =>
    v && typeof v === 'object' ? flat(v, `${p}${k}.`) : [`${p}${k}`]);

const read = f => flat(JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')));
const source = new Set(read('en-US.json'));
let ok = true;

for (const f of fs.readdirSync(dir)) {
  if (!f.endsWith('.json') || f.startsWith('en-US')) continue;
  const missing = [...source].filter(k => !read(f).includes(k));
  if (missing.length) {
    console.error(`${f}: missing ${missing.length} keys`);
    ok = false;
  }
}
process.exit(ok ? 0 : 1);

Every time someone merges English copy changes, a PR opens with freshly translated locale files — verified complete before the PR exists. Reviewer approves, merges, ships. Same review gate as any other code change.

Production checklist

  • Lazy-load locales if you ship 10+ languages. Use i18next-http-backend to fetch JSON on demand instead of bundling everything. Cuts initial bundle weight significantly.
  • Type-check your keys. TypeScript users can wire react-i18next's CustomTypeOptions so t('missingKey') is a compile-time error. Worth its weight in gold once your key count exceeds ~200.
  • Give the translator context. Add a src/i18n/en-US.descriptions.json next to your source file, with the same key structure and a short note per string. It's read at sync time, never shipped to the browser, and it's the most effective quality lever available — use it to mark brand names as do-not-translate. Set the project's Context field in the dashboard for instructions that apply to every string. Note that adding or editing descriptions marks all strings as changed, so the next sync re-translates and re-bills your whole corpus.
  • Keep the sidecar out of your build output if your locale files live in a served directory. en-US.descriptions.json contains internal notes about your UI; exclude it from whatever copies your assets.
  • Gate on completeness in CI, using the check from Step 8. Placeholder integrity is validated server-side, but key completeness is yours to enforce.
  • Review machine output before a launch. Quality is high but not infallible, and there is no CLI command to override a single translation — edit the string in your source file, or add a description that disambiguates it, and re-sync.

Where this leaves you

Your React app now:

  • Detects the user's preferred language from browser / localStorage
  • Keeps every {{placeholder}} intact across all locales
  • Stays in sync with English copy automatically on every CI run
  • Ships in as many of the 56 supported locales as you enable

The setup is permanent — you'll never write per-language code in components again, and adding a language is one change on the project in the dashboard plus one <option> in the switcher.

If you want to start now, the free tier gives you 5,000 words with no credit card. Worth knowing how that is counted: usage is your source word count multiplied by the number of locales you enable, so 5,000 words is a small app in three or four languages rather than a whole corpus in all of them. Enough to see the entire loop work end to end. Which plan do I need? does the arithmetic for your own string count.