--- title: 'Shipping Thirty Languages Without a Safety Net' excerpt: 'GeekBye v1.8.3 wired react-i18next and translated the whole app into 30 languages. The library part was routine. The interesting part is what was missing: no key-parity check and no string extractor — so hardcoded English leaked, a Danish typo shipped, and the changelog could not agree whether the app spoke 28, 29, or 30 languages.' date: '2026-07-16' author: 'Steven' authorAvatar: '/images/blog/authors/steven.jpg' coverImage: '/images/blog/covers/shipping-thirty-languages-without-a-safety-net.svg' tags: ['Engineering', 'i18n', 'Desktop', 'GeekBye Releases'] keywords: - 'react-i18next full app internationalization electron' - 'i18n key parity check missing translations ci' - 'extract hardcoded strings react i18next' - 'app language vs transcript language setting' - 'i18next manual pluralization slavic plural rules' - 'onboarding wizard translation react usememo stale' lastModified: '2026-07-16' tldr: 'GeekBye v1.8.3 added `react-i18next` and translated the entire interface — including two onboarding wizards — into 30 locale files (English plus 29 languages), and gave the app its own App Language setting, separate from the transcript language and the AI-response language. Wiring the library and refactoring hardcoded strings into `t(''key'')` calls was the easy 80%. The missing 20% is the story: there was no automated string extractor and no CI key-parity check, so v1.8.3 shipped with ten components still hardcoded in English (caught two weeks later), a wrong Danish word in production, missing "quit app" keys, and a commit trail that variously calls the same 30-file set 28, 29, or 30 languages. Full i18n is not a feature you finish; it is an invariant you have to keep enforcing — and the tooling to enforce it is the part nobody built.' keyTakeaways: - 'Adding react-i18next and bulk-translating strings is the easy part of internationalization; the hard part is the tooling that keeps 30 locale files in parity forever — an extractor for hardcoded strings and a CI check for missing keys, neither of which v1.8.3 shipped' - 'App language, transcript language, and AI-response language are three different settings and should be: v1.8.3 split the UI language into its own `app_language` key and seeded it from the existing AI-response language via a one-time migration, so upgraders kept the language they had already implied' - 'Without a parity net, "full i18n" rots one feature at a time: ten components were still hardcoded in English after v1.8.3 shipped, and every later feature needed a manual translate-into-29 pass — the leak is silent because your own build never complains about a missing key' - 'Manual pluralization with paired singular/plural keys (`meetingCount` / `meetingCountPlural`) is a landmine for languages with three or four plural forms — it renders grammatically wrong counts in Russian, Polish, and Czech, and nothing in the build will tell you' - 'The count in your changelog is a data point about your discipline: v1.8.3 called it 30 languages, later releases said 29, one commit said 28 — all describing the same unchanged 30 files, because nobody had a single source of truth for "how many languages do we ship"' --- Some releases are a feature. GeekBye v1.8.3 looks like one — "the app now speaks 30 languages" — but the feature was the easy part, and it's not what this post is about. Between February 17 and 19, 2026, two pull requests wired `react-i18next` into the app, refactored every hardcoded English string into a translation call, and bundled thirty locale files. That's real work, but it's the kind of work a library is designed to make routine. The interesting engineering is everything the release _didn't_ build — the safety net that would have caught what it shipped broken. ## The wiring was the easy 80% The infrastructure commit added `react-i18next` on top of `i18next` and did the conventional thing: an `i18n/index.ts` that calls `initReactI18next`, statically imports all thirty locale JSONs, and registers them under one `resources` object. No lazy loading — every language ships in the bundle. Translation strings live in `src/i18n/locales/*.json` as flat, dot-namespaced maps: `sidebar.meetings`, `startMeeting.start`, one key to one string. The migration from hardcoded English to `t('key')` was done the honest way, component group by component group, and you can read the sweep in the commit log: sidebar, navigation, and chat first; then settings, login, and onboarding; then meetings, dashboard, toasts, and navbar tooltips. Each commit replaced a batch of literal strings with translation calls. Twenty-nine non-English files landed in a single commit — a nine-thousand-line drop that translated the English source into everything from Bulgarian to Vietnamese. None of that is hard to get right, because getting it _wrong_ is loud: a missing provider crashes, a malformed JSON fails to parse, a bad import breaks the build. The library gives you guardrails for the mechanical mistakes. What it does not give you is a guardrail for the mistake that actually happened. ## Three language settings, and why that's correct Before we get to what broke, one thing v1.8.3 got exactly right, because it's a distinction a lot of apps flatten and shouldn't. After this release, GeekBye has _three_ independent language settings: - **AI-response language** (`output_language`) — what the assistant answers in. The oldest of the three, from November 2025. - **Transcript language** (`transcriptLanguage`) — what the speech engine transcribes your microphone as. Plumbed a few releases earlier, in v1.7.6. - **App language** (`app_language`) — the language of the buttons, menus, and onboarding. New in v1.8.3. These are genuinely different axes. A developer in Berlin might want a German interface, English AI answers (because that's the language of the docs they're pasting), and English transcription (because the meeting is in English). Collapsing those into one "language" setting would be wrong for that person, and the onboarding step added in this release presents all three as separate dropdowns for exactly that reason. The nice touch is the upgrade path. A one-time migration copies each existing user's `output_language` into the new `app_language` key on first launch after updating, and the provider resolves language through a fallback chain — `app_language`, then `output_language`, then `'en'`. So users who'd already told the app they wanted Spanish AI answers woke up to a Spanish interface, instead of being reset to English and made to hunt for the setting. Seeding a new preference from a related old one is a small kindness that costs one migration and saves every upgrader a papercut. ## The onboarding re-render that wouldn't There's one genuine bug worth pulling out of the i18n work, because it's a React trap anyone doing runtime language switching will hit. The onboarding wizard builds some of its option lists inside a `useMemo`. When you change your language _on the language step itself_, the UI is supposed to redraw in the new language immediately — and it didn't, because `t` wasn't in the memo's dependency array. The translation function changes identity when the language changes; if your memoized value is built from `t` but doesn't depend on it, React happily hands you the stale, previous-language version. The fix appears twice in the log — once for the wizard, once for the settings dropdown list — which tells you it's the kind of mistake you make in every place you memoize translated content until you learn to always list `t`. Onboarding also carried a wrinkle the changelog doesn't mention: because this is a white-label codebase, there are _two_ onboarding trees to translate — a GeekBye wizard and a Pavleur one — living under parallel `onboarding.*` and `onboarding.pavleur.*` key namespaces. Every onboarding string got translated twice, into two brand voices, across all twenty-nine languages. ## What the build didn't know it was missing Here's the part that makes this a lesson instead of a checklist. v1.8.3 had no automated way to extract hardcoded strings and no CI check for key parity across locales. So the definition of "done" was "someone visited the components they remembered and wrapped the strings they saw." That definition leaks. Two weeks after the release, a cleanup commit landed with the message _"translate all hardcoded English strings across 10 components into 30 languages."_ Ten components — the issue-report form, the summary and metrics tabs, the report tab, transcript and chat content, the usage-limit prompt, chat messages, the copy-code button, the floating action buttons — had shipped in v1.8.3 still speaking English to everyone, in every language. Nobody's build failed. No test went red. A German user just saw English in the report tab, and the only detector was a human eventually looking at it. The same absence produced the smaller embarrassments. A Danish translation shipped with the wrong word — `Luk application helt` instead of `Luk applikationen helt` — and was patched in a later release, because there was no native reviewer gate and no way for the pipeline to know. "Quit app" keys were missing from the initial batch and back-filled days later. And every single feature that shipped afterward — a modifier-key preset, mic mute, recap chips, overlay opacity — dragged its own "translate the new keys into 29 locales" commit behind it, forever, because nothing enforced the invariant automatically. You can even measure the disorder in the commit messages. The same thirty locale files are described, release to release, as "30 languages," "29 languages," and — in one commit — "28 locales." Nothing was added or removed; there are thirty files at every tag from v1.8.3 through v2. The number drifts because there was no single source of truth for "how many languages do we ship," so each commit author counted by hand, sometimes including English as a language and sometimes as the source. A changelog that can't hold its own count steady is a small, honest signal that the thing it describes isn't being enforced by a machine. ## The pluralization landmine One more thing worth flagging, not because it broke loudly but because it's the kind of bug this setup invites and won't warn you about. Pluralization in these locale files is done manually, with paired keys: `meetings.meetingCount` and `meetings.meetingCountPlural`, `profiles.filesAttached` and `filesAttachedPlural`. That's a clean English singular/plural split. It is also wrong for a chunk of the languages the app now claims to support. Russian, Polish, and Czech have three or four plural forms depending on the number — "1 file," "2 files," "5 files," and "21 files" can take different word endings. A two-key singular/plural scheme can't express that, so those counts render grammatically off. `i18next` has real plural machinery for exactly this; the manual pairs sidestep it, and no commit acknowledges the gap. It's the pluralization equivalent of the hardcoded-strings leak: invisible to the build, visible only to a native speaker. ## Three things this release taught us 1. **The library is the easy 80%; the tooling is the hard 20%.** Wiring `react-i18next` and bulk-translating strings is a solved problem. Keeping thirty files in parity forever is not — and it needs an extractor for hardcoded strings and a CI check for missing keys, neither of which a library ships for you. Build those _before_ you claim "full i18n," or you've claimed an invariant you have no way to hold. 2. **Split the language settings that are actually different.** UI language, transcript language, and AI-response language are three axes, and v1.8.3 was right to give each its own key — and righter still to seed the new one from a related old one via migration, so upgrading cost users nothing. 3. **Your changelog's count is a discipline metric.** When the same file set is 28, 29, and 30 languages across three commits, the number isn't the problem — the missing source of truth is. If a machine isn't counting your languages, nobody is counting them the same way twice. For the previous chapter in the v1 story, [redesigning the dashboard around one wave chart](/blog/redesigning-the-dashboard-around-one-wave-chart) (v1.8.2); for the next, [putting the release in CI, twice](/blog/putting-the-release-in-ci-twice) (v1.8.4); and for the whole arc, [the anatomy of shipping software to perfection](/blog/anatomy-of-perfection).