Steven
Steven8 min read

Shipping Thirty Languages Without a Safety Net

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.

Engineering
i18n
Desktop
GeekBye Releases
Shipping Thirty Languages Without a Safety Net

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 (v1.8.2); for the next, putting the release in CI, twice (v1.8.4); and for the whole arc, the anatomy of shipping software to perfection.