DEV Community

Cover image for I Built the Same i18n App Twice: Here is What Broke the First Time
Akshat Sharma
Akshat Sharma

Posted on

I Built the Same i18n App Twice: Here is What Broke the First Time

Why Localization Matters to Me as a Football Fan and Developer

Every four years, the FIFA World Cup brings the entire planet together. Billions of fans tune in from almost every country on earth. Like many of you, I am a huge football fan. I also happen to read and speak several languages, and one thing I have noticed over the years is how much more personal and exciting following the tournament feels when you can read match stats, live commentaries, and news in your own mother tongue.

But while browsing international sports articles and tournament dashboards across different languages, I kept running into the same frustrating problem: grammatical errors everywhere. Plural goal counts that sounded robotic or broken. Buttons that overflowed their boxes, or pages that crashed completely when someone tried to use auto translate in their browser.

Being a developer, I could not help but wonder why localizing web software cleanly is still such a minefield. I started exploring modern engineering solutions to solve this once and for all, and that is when I discovered Tolgee.

To test it properly against the traditional way we build multilingual apps, I decided to run a real engineering experiment.

I built a FIFA World Cup 2026™ tournament dashboard with a live scoreboard, top scorers, group standings, match fixtures, match feed, and tournament facts localized into 7 languages across multiple writing systems and plural rule families (en, fr, cs, pl, ru, hi, ar), using two different i18n stacks. The same UI. The same data. Two translation engines. And I ran the whole thing through 27 automated unit tests to prove which one handles complex Slavic and Arabic grammar right.

Here is what the dashboard looks like:

FIFA World Cup 2026 Dashboard: English Mode

The two stacks:

  1. i18next (standard setup): the industry standard, file-based, suffix-key plurals (_one, _other…), HTTP backend
  2. Tolgee (with @tolgee/format-icu): an open source SDK and Translation Management System with native ICU MessageFormat support

A note on framing: This is not a claim that i18next and Tolgee are mutually exclusive — Tolgee ships an official @tolgee/i18next integration that lets you keep your existing i18next runtime while adding Tolgee's TMS on top. The comparison here is specifically between i18next's default suffix-key plural approach and ICU MessageFormat (which Tolgee defaults to). That distinction matters most for languages with more than two plural forms.

Let's get into it.


The Setup: Same UI, Two Translation Engines

The architecture of this demo is built around a clean adapter pattern. There is one shared dashboard component, FifaDashboard.tsx, that accepts a single t(key, params) function as a prop. That function is the only thing that changes between the two modes.

// The shared component signature — framework-agnostic
interface DashboardProps {
  t: (key: string, params?: Record<string, unknown>) => string;
  mode: 'tolgee' | 'i18next';
  lang: string;
  onLangChange: (lang: string) => void;
}
Enter fullscreen mode Exit fullscreen mode

Two thin wrapper components — TolgeeDashboardWrapper.tsx and I18nextDashboardWrapper.tsx — each hook into their respective SDK and pass the correct t() implementation down. The shared component never knows which engine is running.

This matters for the comparison. Every string you see rendered in the dashboard — the match score minute (67'), the live match count pill (2 matches live), the top scorers list (7 goals), the Czech facts ticker — is routed through the same JSX and the same translation keys. The only variable is which engine resolves them.

App.tsx (tab switcher)
    ├── TolgeeProvider → TolgeeDashboardWrapper → FifaDashboard
    └── I18nextProvider → Suspense → I18nextDashboardWrapper → FifaDashboard
                                    ↑ same component, same props interface
Enter fullscreen mode Exit fullscreen mode

Part 1: i18next — What Works, and Where It Breaks

The Good Stuff First

Setting up i18next is genuinely fast. The entire configuration is 15 lines:

// src/i18n.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import HttpBackend from 'i18next-http-backend';

i18n
  .use(HttpBackend)
  .use(initReactI18next)
  .init({
    lng: 'en',
    fallbackLng: 'en',
    supportedLngs: ['en', 'fr', 'cs', 'pl', 'ru', 'hi', 'ar'],
    backend: {
      loadPath: '/locales/{{lng}}/translation.json',
    },
    interpolation: { escapeValue: false },
    // NOTE: Standard setup only handles _one and _other plural suffixes.
    // Czech needs _few too — but that requires the i18next-icu plugin.
  });
Enter fullscreen mode Exit fullscreen mode

The useTranslation hook integrates cleanly with React 19. The HTTP backend lazy-loads locale files from public/locales/, so your initial JS bundle stays lean. English and French render perfectly: button labels, match feed events, fixture kickoff times, group standings headers — everything just works.

For the match events feed in data.ts, the interpolation is straightforward:

// data.ts — match events data
export const FEED_EVENTS = [
  { minute: 67, type: 'goal', player: 'Vinicius Jr.', club: 'Real Madrid' },
  { minute: 63, type: 'yellow', player: 'Mohammed Al-Burayk' },
  { minute: 54, type: 'sub', out: 'Bellingham', in: 'Valverde' },
  { minute: 45, type: 'ht' },
  ...
];

// en/translation.json
{
  "feed_goal":   "⚽ Goal! {player} ({club})",
  "feed_yellow": "🟨 Yellow card — {player}",
  "feed_sub":    "🔄 Substitution — {out} off, {in} on",
  "feed_ht":     "🔔 Half Time!"
}
Enter fullscreen mode Exit fullscreen mode

That works. The problem only surfaces when you switch the language to Czech.


The Problem: Slavic 4-Form Plurals and Arabic 6-Form Plurals

English plurals are binary: 1 match / 5 matches. Slavic languages like Czech, Polish, and Russian follow the Unicode CLDR plural specification with four grammatically distinct forms (one, few, many, other). Arabic is even more extreme, with six distinct plural categories — the maximum possible in CLDR:

CLDR Category Count Czech (gól) Polish (gol) Russian (гол) Arabic (هدف)
zero 0 0 gólů 0 goli 0 голов صفر أهداف
one 1 1 gól 1 gol 1 гол هدف واحد
two (dual) 2 هدفان
few 2–4 (Slavic) / 3–10 (Ar) 2 góly 2 gole 2 гола 3 أهداف
many Decimals / 11–99 (Ar) 2.5 gólu 5 goli 5 голов 15 هدفاً
other 5+ 5 gólů 21 goli 21 гол 100 هدف

In our demo, the interactive plural showdown panel renders goal counts across all these categories specifically to expose how translation engines behave when confronted with these rules.

Here is what i18next does with the standard setup. The Czech translation file needs:

// public/locales/cs/translation.json  i18next format
{
  "scorers_goals_one":   "{{count}} gól",
  "scorers_goals_other": "{{count}} gólů"
}
Enter fullscreen mode Exit fullscreen mode

When i18next encounters count: 2, it calls Intl.PluralRules('cs').select(2), which correctly returns "few". But then it looks for a key named scorers_goals_few — and finds nothing, because you only defined _one and _other. It silently falls back to _other.

The result:

✅ 1 gól     (one — correct)
❌ 2 gólů    (other — should be: 2 góly)
❌ 3 gólů    (other — should be: 3 góly)
✅ 5 gólů    (other — correct)
✅ 21 gólů   (other — correct)
Enter fullscreen mode Exit fullscreen mode

No error. No warning. It renders. It is wrong.

To be precise: this silent fallback is not unique to i18next — ICU MessageFormat also falls back gracefully when a plural category is missing from the translation string. The difference is structural: with i18next's suffix-key model, each plural form is a separate, independently-omittable JSON key. It is easy to define _one and _other and simply never notice that _few is missing. With ICU, the entire plural rule lives inside one string{count, plural, one {…} few {…} other {…}} — so a missing category is immediately visible to anyone who reads the file. The structure itself makes the gap obvious at authoring time rather than invisible until a native speaker files a bug.

The live count pill in the header also breaks: the dashboard shows 2 zápasů živě instead of the grammatically correct 2 zápasy živě. You can see this in the i18next Czech screenshot:

i18next Czech Mode — Broken Plurals

The orange warning banner in the dashboard explicitly calls this out: "Režim i18next — POZOR: '3 góly' je správně, ale i18next zobrazuje '3 gólů'. Chybí plugin i18next-icu!"


Why This Bug Ships Silently

The mechanism that makes this particularly dangerous in production:

  1. The missing key is invisible at the file level — _one and _other look complete; _few is simply absent with no structural hint it should exist
  2. Your CI/CD pipeline does not fail — the app builds and deploys cleanly
  3. Your English and French tests still pass — the bug is locale-specific
  4. A developer who does not speak Czech has no way to spot it during code review
  5. Most translation reviewers work from static screenshots, not live apps with dynamic counts

This is not a hypothetical. It is the kind of bug that makes it into production in apps localized for Polish, Russian, Arabic, or any other language with more than two plural forms — and stays there because nobody who notices it has the ability to fix it.


Part 2: Configuring Tolgee — Where It Gets Better

ICU MessageFormat: The Plural Problem Solved by Design

Tolgee's @tolgee/format-icu plugin uses ICU MessageFormat — the Unicode standard for formatting messages across locales. Instead of separate keys with suffixes, the entire plural logic lives inside a single string:

// src/locales/cs.json  Tolgee ICU format
{
  "scorers_goals":    "{count, plural, one {# gól} few {# góly} other {# gólů}}",
  "header_live_count": "{count, plural, one {# zápas živě} few {# zápasy živě} other {# zápasů živě}}",
  "stat_goals_subtitle": "{count, plural, one {# gól vstřelen dosud} few {# góly vstřeleny dosud} other {# gólů vstřeleno dosud}}"
}
Enter fullscreen mode Exit fullscreen mode

The # token is replaced by the count. The plural category — one, few, other — is resolved by the ICU engine against the active locale's CLDR rules. The few category for Czech is handled automatically, with no plugin configuration, no suffix keys, and no fallback behaviour.

Here is what the dashboard renders in Czech with Tolgee:

✅ 1 gól     (one — correct)
✅ 2 góly    (few — correct)
✅ 3 góly    (few — correct)
✅ 5 gólů    (other — correct)
✅ 21 gólů   (other — correct)
Enter fullscreen mode Exit fullscreen mode

The header live count becomes 2 zápasy živě. The stats subtitle for 163 goals reads 163 gólů vstřeleno dosud. Every plural string in the dashboard resolves to the correct grammatical form:

Tolgee Czech Mode

(Notice "2 zápasy živě" in the live count pill — the few form, correctly applied.)

The Tolgee SDK Configuration

The full Tolgee setup, including the offline fallback:

// src/tolgee.ts
import { Tolgee, DevTools } from '@tolgee/react';
import { FormatIcu } from '@tolgee/format-icu';

export const tolgee = Tolgee()
  .use(DevTools())   // Alt+click in-context editing (dev/staging only)
  .use(FormatIcu())  // Full ICU MessageFormat: plurals, genders, selects, ordinals
  .init({
    apiUrl: import.meta.env.VITE_TOLGEE_API_URL,
    apiKey: import.meta.env.VITE_TOLGEE_API_KEY,
    language: 'en',
    availableLanguages: ['en', 'fr', 'cs-CZ', 'pl', 'ru', 'hi', 'ar'],
    // staticData: embedded fallback — app works fully offline if cloud is unavailable
    staticData: {
      en:      () => import('./locales/en.json'),
      fr:      () => import('./locales/fr.json'),
      'cs-CZ': () => import('./locales/cs-CZ.json'),
      pl:      () => import('./locales/pl.json'),
      ru:      () => import('./locales/ru.json'),
      hi:      () => import('./locales/hi.json'),
      ar:      () => import('./locales/ar.json'),
    },
  });
Enter fullscreen mode Exit fullscreen mode

Two things worth calling out:

DevTools() — this is the in-context editing plugin. It activates only when apiKey is present (development and staging environments). In a production build without a key, the plugin is inert and ships zero overhead.

staticData — these are dynamic imports that Vite bundles as separate async chunks. Check the build output:

vite v8.1.3 building client environment for production...
✓ 61 modules transformed.

dist/index.html                   0.46 kB │ gzip:   0.29 kB
dist/assets/index-4C8f5oeS.css   21.35 kB │ gzip:   4.74 kB
dist/assets/en-1mDUlYNd.js        7.03 kB │ gzip:   2.87 kB
dist/assets/fr-DwcYreVG.js        7.26 kB │ gzip:   2.97 kB
dist/assets/cs-CZ-DGy2fZiq.js     7.49 kB │ gzip:   3.17 kB
dist/assets/pl-B5rMwVrt.js        7.55 kB │ gzip:   3.18 kB
dist/assets/ru-CTfCuXxl.js        9.08 kB │ gzip:   3.55 kB
dist/assets/ar-DJu1entp.js        9.36 kB │ gzip:   3.44 kB
dist/assets/hi-44eIwXL0.js        9.48 kB │ gzip:   3.28 kB
dist/assets/index-DoMQ5NLb.js   338.36 kB │ gzip: 104.82 kB

✓ built in 306ms
Enter fullscreen mode Exit fullscreen mode

Each locale is lazy-loaded on demand. If Tolgee Cloud is offline or the API key is missing, the app falls back to these embedded files silently — no broken UI, no missing strings.

Wrapping the App

The provider pattern is straightforward:

// src/App.tsx
import { TolgeeProvider } from '@tolgee/react';
import { tolgee } from './tolgee';

function App() {
  const [activeTab, setActiveTab] = useState<'tolgee' | 'i18next'>('tolgee');

  return (
    <div className="app">
      <nav className="tab-nav">{/* Tolgee / i18next switcher */}</nav>

      {activeTab === 'tolgee' && (
        <TolgeeProvider tolgee={tolgee} fallback={<LoadingScreen />}>
          <TolgeeDashboardWrapper />
        </TolgeeProvider>
      )}

      {activeTab === 'i18next' && (
        <I18nextProvider i18n={i18n}>
          <Suspense fallback={<LoadingScreen />}>
            <I18nextDashboardWrapper />
          </Suspense>
        </I18nextProvider>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The TolgeeProvider handles language state reactively via the tolgee instance. Language changes are called with tolgee.changeLanguage(code) from the wrapper, and every component subscribed through useTranslate() re-renders automatically.


Part 3: Proving it With Code, Not Just Screenshots

Screenshots are easy to fake. Tests are not. The repo includes a Vitest test suite that directly asserts the plural output from both engines for every Czech count:

// src/__tests__/plurals.test.ts
import { IntlMessageFormat } from 'intl-messageformat';
// IntlMessageFormat is the same underlying engine that @tolgee/format-icu wraps.

const ICU_GOALS_CS = '{count, plural, one {# gól} few {# góly} other {# gólů}}';

// Mirrors i18next standard behaviour without the ICU plugin:
function simulateI18nextCzech(count: number): string {
  const rule = new Intl.PluralRules('cs').select(count);
  // i18next reads _one and _other keys. 'few' falls through to _other silently.
  if (rule === 'one') return `${count} gól`;
  return `${count} gólů`; // Wrong for count 2, 3, 4
}

describe('Czech Plural Forms — Tolgee ICU', () => {
  it.each([
    [1,   '1 gól'],    // ✅ one
    [2,   '2 góly'],   // ✅ few  ← this is the one that matters
    [3,   '3 góly'],   // ✅ few
    [4,   '4 góly'],   // ✅ few
    [5,   '5 gólů'],   // ✅ other
    [21,  '21 gólů'],  // ✅ other
    [100, '100 gólů'], // ✅ other
  ])('count=%i → "%s"', (count, expected) => {
    const result = new IntlMessageFormat(ICU_GOALS_CS, 'cs').format({ count }) as string;
    expect(result).toBe(expected); // all 7 pass
  });
});

describe('Czech Plural Forms — Standard i18next (no ICU plugin)', () => {
  it('count=2 → WRONG "2 gólů" (should be "2 góly")', () => {
    expect(simulateI18nextCzech(2)).toBe('2 gólů');       // ❌ wrong form
    expect(simulateI18nextCzech(2)).not.toBe('2 góly');   // correct form unreachable
  });
});
Enter fullscreen mode Exit fullscreen mode

Run npm run test. You get:

✓ Czech Plural Forms — Tolgee ICU (FormatIcu plugin) (7 tests)
✓ Czech Plural Forms — Standard i18next (no ICU plugin) (5 tests)
✓ Tournament Facts — Tolgee ICU plurals (Czech) (4 tests)
✓ Cross-language ICU consistency (4 tests)
✓ Polish Plural Forms — Tolgee ICU vs Standard i18next (2 tests)
✓ Russian Plural Forms — Tolgee ICU vs Standard i18next (2 tests)
✓ Hindi Plural Rules — Note on 0 and 1 (1 test)
✓ Arabic 6 Plural Forms — Tolgee ICU vs Standard i18next (2 tests)

Test Files  1 passed (1)
     Tests  27 passed (27)
  Duration  ~300ms
Enter fullscreen mode Exit fullscreen mode

The test for the i18next Czech behaviour passes — but what it asserts is that i18next does produce the wrong form. The expect(...).not.toBe('2 góly') assertion confirms the correct form is never reached. The tests are documentation that happens to be executable.


Part 4: The Workflow Problem (Bigger Than the Plural Problem)

The plural bug is a technical problem with a technical fix. The workflow problem is structural — and it is the one that compounds over time.

The Translation Edit Loop in i18next

In our i18next setup, the Czech translation files live in public/locales/cs/translation.json. Every string — from the match stats labels (Držení míče, Fauly) to the sidebar navigation (Přehled, Střelci) to the footer (FIFA Mistrovství světa 2026™) — is a static file checked into Git.

When a Czech translator reviews the deployed staging dashboard and spots that Čtvrtfinále · 16. hrací den should read Čtvrtfinále · 16. zápasový den, here is what happens:

1. Translator notices the error on the live staging URL
2. Takes a screenshot, opens a Jira ticket
3. Developer picks up the ticket
4. Finds 'live_match_title' key in public/locales/cs/translation.json
5. Creates a branch: git checkout -b fix/cs-match-title
6. Edits the file, commits, opens PR
7. Code review (even if it is just a translator fix)
8. CI/CD pipeline runs — TypeScript check, Vite build, deploy
9. Staging is updated
10. Translator verifies
11. If it is wrong again: start over from step 2
Enter fullscreen mode Exit fullscreen mode

Total time for a one-word copy change in a JSON file: 1–4 hours of developer time that has nothing to do with development.

This is the tax. And in a dashboard like this one — with over 100 translation keys across seven locales (en, fr, cs-CZ, pl, ru, hi, ar), covering navigation labels, match statistics, tournament facts, scorers, standings headers, feed events, and footer copy — that tax compounds every time a translator or product manager wants to touch anything.

The Tolgee Workflow: A Real Edit, End to End

To demonstrate this concretely, I performed a real fix through the in-context editor during the writing of this post.

Switching to Czech in the sidebar and holding Alt, I clicked on the "7 gólů" goal count next to Kylian Mbappé in the Top Scorers section. An overlay appeared immediately, showing the scorers_goals ICU string editable across all seven languages at once:

Tolgee In-Context Editing

I noticed the Czech Many category (used for decimal counts like 2.5 gólu) was empty — a genuine gap. I typed gólu into that field:

Czech Many field filled in with gólu

After clicking Save, the change was pushed directly to Tolgee Cloud with no build, no PR, and no deploy. The Tolgee activity log confirmed the edit instantly:

Tolgee Cloud activity log

The full audit trail shows:

  • Key: scorers_goals (Czech / cs-CZ)
  • Change: many {gólu} added (highlighted green in the diff)
  • Author: Akshat Sharma
  • Time: 7/20/2026 9:34 AM
  • How: Alt+Click in the live running app — no code touched

The corrected ICU string is now:

{count, plural, one {# gól} few {# góly} many {# gólu} other {# gólů}}
Enter fullscreen mode Exit fullscreen mode

Total time from noticing the gap to seeing it live in Tolgee Cloud: under 60 seconds.

With i18next, the same fix would require editing the JSON locale file, committing, opening a PR, getting a review, merging, and waiting for the CI pipeline to deploy. With Tolgee's in-context editor connected to a live project, a translator (not a developer) can do this themselves.

Live In-Context Editing & Cloud Synchronization Gallery

Below are additional captures from the running app showing the full scope of what the in-context editor exposes:

1. Tolgee Cloud Project Overview

Live cloud dashboard (app.tolgee.io) showing our project FIFA WC 2026 Demo with 103 keys at 100% Translated status across all 7 target languages (en, ar, cs-CZ, fr, hi, pl, ru).

Tolgee Cloud

2. Tolgee Cloud Translation Management System

The collaborative TMS string view filtering keys side-by-side across English, Russian (Cyrillic), and Polish (Latin with diacritics).

Tolgee TMS

3. Full 7-Language Tournament Dashboard

Overview of the live dashboard running in browser (localhost:5173) with instant hot-swapping across all 7 languages and writing systems.

Full 7-Language Tournament Dashboard

4. Editing ICU Plural Categories in Context

Holding Alt and clicking a player's goal count opens the scorers_goals key. Notice how all 6 Arabic ICU plural forms (Zero, One, Two, Few, Many, Other) and 4 Czech forms are directly editable inside the overlay.

Editing ICU Plural

5. Editing UI Badge Copy Across Scripts

In-context editing on the tolgee_badge string across English (Latin), Arabic (RTL), Czech (Diacritics), French, and Hindi (Devanagari).

Editing UI Badge

6. Interactive Plural Showdown & Language Filtering

Filtering active target languages in the DevTools popup while interacting with the live Arabic 6-form Plural Showdown grid.

Interactive Plural Showdown & Language Filtering


Part 5: The Browser-Translate Crash — A React Production Bug Worth Understanding

There is a third class of problem that does not show up during development: what happens when your users auto-translate your app with a browser extension.

This is a React and browser compatibility issue — it affects any React application, regardless of which i18n library you use. When Google Translate (or any browser-level translator) runs on a React application, it directly manipulates DOM text nodes. React's Virtual DOM maintains references to those nodes. When the component re-renders — for example, when the live match minute counter ticks forward — React tries to call removeChild on the original text node, which the browser's translator has already replaced:

NotFoundError: Failed to execute 'removeChild' on 'Node':
The node to be removed is not a child of this node.
Enter fullscreen mode Exit fullscreen mode

The component tree crashes. The page is dead until a hard refresh. i18next, Tolgee, or any other i18n library will experience the same crash if the user's browser translator is active — neither library controls DOM mutation.

In our dashboard, the match minute counter in FifaDashboard.tsx ticks every 30 seconds:

const [tick, setTick] = useState(0);

useEffect(() => {
  const interval = setInterval(() => setTick(p => p + 1), 30000);
  return () => clearInterval(interval);
}, []);

const currentMinute = LIVE_MATCH.minute + Math.floor(tick / 2);
// Renders: t('live_minute', { minute: currentMinute }) → "67'"
Enter fullscreen mode Exit fullscreen mode

Every tick triggers a re-render. If a Czech user who found the app via Google Search had their browser set to auto-translate to Czech — because the app was only available in English — that re-render would crash the tab.

So where does Tolgee fit in? Not as a direct fix to the crash, but as the reason users stop needing browser translators in the first place. With i18next's file-based workflow (edit JSON → commit → PR → CI/CD → deploy), adding a new language carries a meaningful developer cost. Teams frequently ship in English-only or in two to three languages and leave the rest to browser auto-translate. With Tolgee, a translator can add a full language through the in-context editor without touching code — dramatically lowering the cost of shipping native localizations.

The Curipod case study makes this concrete: an EdTech platform with 20+ language targets saw 300+ crashes per day from exactly this mechanism — teachers in classrooms relying on Chrome's auto-translate because the app did not have their language natively. When they migrated to Tolgee, the low friction of in-context editing made it feasible to localize into all 20+ languages properly. Native localizations meant users stopped needing the browser translator. Crashes dropped to zero.

The fix for the crash itself is a React-level workaround (wrapping dynamic text in a <span translate="no"> or using key props to force remounts). But the more sustainable solution is not needing the browser translator at all — and that is what a low-friction localization workflow enables.


Part 6: Open Source — What It Actually Means for Your Stack

Both tools in this comparison are MIT-licensed open source software. i18next has been open source since 2011. Tolgee's client SDK is MIT-licensed too.

The meaningful open-source distinction here is not about the client library — it is about the Translation Management System platform. i18next is a client library; there is no i18next server to self-host. Tolgee, by contrast, ships a full TMS — the web UI, server, and database all live in tolgee/tolgee-platform, while the client SDKs (React, Vue, Angular, Svelte, and the i18next integration) live in tolgee/tolgee-js — all open source, all MIT-licensed. That changes what self-hosting means in practice.

This has concrete engineering implications that go beyond "it is free":

Your translation data does not leave your infrastructure

With a SaaS-only TMS, your translation strings are stored on a third-party server. For most applications this is fine. For applications handling regulated data — healthcare, finance, government — or for teams with strict data residency requirements, it is a blocker.

Self-hosting Tolgee is one command:

docker run -v tolgee_data:/data -p 8080:8080 tolgee/tolgee
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:8080. Create a project. Get an API key. Update .env.local. That is the entire setup. The full TMS — in-context editing, AI translation, team management, screenshot attachment, translation status tracking — runs in your own environment.

You can audit exactly what the SDK sends over the network

The DevTools() plugin runs inside your browser in dev/staging mode. With a closed-source SDK you cannot audit what data it transmits. With Tolgee, the source is public and the network calls are inspectable. For teams with strict security review requirements, this matters.

No vendor lock-in

Your translation data in Tolgee is stored in a standard database. The export formats (JSON, XLIFF, CSV, PO) are standard. If you ever need to migrate off Tolgee, you own your data in a portable format. There is no proprietary format that locks your 3,000 translation keys into one vendor's ecosystem.

The community ships integrations for your stack

Beyond React, the open-source community maintains official Tolgee SDKs for:

  • Next.js (App Router + Pages Router)
  • Vue 3
  • Angular
  • Svelte / SvelteKit
  • Vanilla JavaScript / Web Components

If you need something that does not exist yet, you can build it. The SDK architecture is extensible — as shown by the DevTools() and FormatIcu() plugin pattern in this demo.

If you want to go deeper, the documentation covers everything from project setup to CI/CD integration.


The Full Comparison Table

1. 7-Language Plural & Writing System Comparison Matrix

Our project tests all 7 locales side-by-side across 4 distinct script families (Latin, Cyrillic, Devanagari, Arabic RTL). Both translation files (public/locales and src/locales) use authentic grammar and diacritics so the comparison tests each engine's handling of native ICU MessageFormat vs. standard suffix keys:

Language Code Writing System CLDR Plural Forms Standard i18next Output Tolgee ICU Output Test Status
English en Latin one / other (2) 1 goal / 2 goals 1 goal / 2 goals ✅ Identical
French fr Latin one / other (2) 1 but / 2 buts 1 but / 2 buts ✅ Identical
Hindi hi Devanagari one / other (2) 0/1 गोल / 2 गोल 0/1 गोल / 2 गोल ✅ Identical (0-as-one)
Czech cs-CZ Latin + Diacritics one / few / many / other (4) 2 gólů ❌ (falls to _other) 2 góly ✅ (native few) 🏆 Tolgee Wins
Polish pl Latin + Diacritics one / few / many / other (4) 2 goli ❌ (falls to _other) 2 gole ✅ (native few) 🏆 Tolgee Wins
Russian ru Cyrillic one / few / many / other (4) 2 голов ❌ (falls to _other) 2 гола ✅ (native few) 🏆 Tolgee Wins
Arabic ar Arabic (RTL) zero / one / two / few / many / other (6) Fails on 4 of 6 forms ❌ Correct across all 6 forms ✅ 🏆 Tolgee Wins

2. Full Engine Feature & Architecture Comparison

Feature / Architecture Standard i18next (react-i18next) Tolgee SDK + Cloud / Self-Hosted TMS
Plural handling across 7 locales _one / _other suffix keys by default — few/two/zero silently fall back to _other unless extra plugins are configured Native ICU MessageFormat — all CLDR categories (zero to other) resolved automatically within one string
Slavic & Arabic Plural Accuracy Incorrect outputs for counts 2–4 (Slavic) and dual/few/many (Arabic) 100% Grammatically Correct verified by 27 automated unit tests
Right-to-Left (RTL) Layout Requires separate manual DOM/CSS orchestration Seamlessly integrates with multilingual dashboard state (dir="rtl")
Configuration Complexity 15-line i18n.ts (+ extra setup for i18next-icu) 15-line tolgee.ts (DevTools() + FormatIcu())
Translation Storage Static JSON files in public/locales/ Connected Tolgee Cloud / Self-Hosted TMS + staticData bundle
Copy Edit Workflow Edit JSON → Commit → PR → CI/CD build → Production deploy Alt + Click → Edit in overlay → Save → Instantly live
Translator & QA Tooling None built-in (static code files) Live in-context visual editor, AI translation suggestions, screenshot context
Browser Auto-Translate Crash Risk Any React app can crash when browser translator mutates DOM nodes — adding more native locales reduces user reliance on browser auto-translate Low-friction in-context editing makes it practical to ship native localizations into many more languages, reducing the need for browser translators
Self-Hosting Options N/A (client library only) One Docker command (docker run tolgee/tolgee) or Azure Marketplace
Offline Reliability Local JSON files Automatic fallback to bundled staticData if offline/API unconfigured
License MIT MIT (Open Source SDK + Open Source Platform)

When to Use Which

Stick with i18next if:

  • You have an existing codebase with thousands of keys already in i18next format and the migration cost outweighs the benefits
  • Your target languages all follow standard two-form plural rules (English, Spanish, Portuguese, German)
  • Copy changes are infrequent and always developer-driven
  • You have custom tooling (scripts, pipelines, backend endpoints) tightly coupled to static JSON artifacts

Start with Tolgee if:

  • You are building a new React/Next.js/Vite project from scratch
  • Your target locales include Slavic languages (Czech, Polish, Russian, Slovak), Arabic, Welsh, or any CLDR language with more than two plural forms
  • You want translators, product managers, or clients to be able to edit copy without filing developer tickets
  • You want to self-host your translation infrastructure with full data ownership
  • You want AI-assisted translation suggestions built into the editorial workflow

Running the Demo Yourself

git clone <repo>
cd tolgee-demo
npm install
npm run dev     # → http://localhost:5173
npm run test    # → 27 assertions across 7 locales, proves the plural difference in code
Enter fullscreen mode Exit fullscreen mode

No Tolgee account required. The app falls back to embedded static translations from src/locales/ if VITE_TOLGEE_API_KEY is absent. Every UI feature works — language switching, the facts ticker, the live match feed, the standings table — the only thing that will not work without a key is saving changes through the Alt+Click editor to a remote TMS.

To enable in-context editing on your own Tolgee project:

# .env.local
VITE_TOLGEE_API_URL=https://app.tolgee.io
VITE_TOLGEE_API_KEY=tg_your_key_here
Enter fullscreen mode Exit fullscreen mode

Or self-host the TMS locally:

docker run -v tolgee_data:/data -p 8080:8080 tolgee/tolgee
# Then: VITE_TOLGEE_API_URL=http://localhost:8080
Enter fullscreen mode Exit fullscreen mode

Conclusion

i18next is not broken. It is an excellent library that has served the JavaScript ecosystem well for over a decade. If your application targets English and a handful of Romance languages, and translation edits go through developers, it will serve you well.

But "deceptively simple" is the right way to describe i18n. The Czech plural failure in this demo is not a corner case — it is what happens by default when you localize a React app into Czech with a standard i18next setup and do not specifically know to install and configure the i18next-icu plugin. It ships. It is wrong. Nobody catches it until a Czech-speaking user files a bug.

Tolgee was built by people who understand that the plural problem is not the only problem. The workflow is a problem. The translator experience is a problem. The browser-translate crash is a problem. The vendor lock-in of closed-source TMS platforms is a problem.

The ICU format is technically correct. The in-context editor is the right workflow. Open source and self-hostable is the right infrastructure model. This demo exists to show all three of those things running at the same time, in a real application, with code you can clone and tests you can run.


A Note on Using Both Together

This post frames i18next's default approach and Tolgee's ICU approach as alternatives, because that is the most useful way to show the difference in how they handle plurals. But in practice, the two tools are not mutually exclusive.

Tolgee ships an official @tolgee/i18next package — a first-party integration that sits on top of your existing i18next runtime. Many teams use i18next as the translation engine — keeping the familiar t() API, namespaces, and their existing JSON locale files — while adding Tolgee on top as the Translation Management System. You get the Alt+Click in-context editor, the cloud project, the activity log, AI translation suggestions, and the full translator workflow, without rewriting any of your existing useTranslation() calls.

If you already have an i18next codebase and the plural handling is not a problem for your language set, that integration path is worth looking at before considering a full migration. The two tools genuinely complement each other.

The decision tree is roughly:

  • New project, complex plural languages → Tolgee with ICU from day one. You get the full benefit of ICU's single-string plural model and the TMS workflow without any migration cost.
  • Existing i18next project, need TMS workflow → @tolgee/i18next. Keep your runtime, add Tolgee's platform on top.
  • Existing i18next project, plural accuracy gap → add i18next-icu plugin. You can adopt ICU MessageFormat within i18next without switching TMS at all.

Links

Top comments (0)