The webapp currently ships with eight locale catalogs: English, Italian, French, German, Spanish, Portuguese, Polish and Turkish. Japanese and Korean sit on the backlog. There is exactly one person maintaining all of them, and that person — me — speaks two of those languages well enough to argue in them.
This post is about how that works in practice, because "solo dev does i18n" usually ends one of two ways: the product stays English-only forever, or the translations rot into a museum of half-updated strings. I've been trying to find the third way, and most of what makes it work is process, not tooling.
The lockstep rule
The single most important rule in the codebase is this: every new or changed user-visible string ships in all eight catalogs, in the same change. Not "English now, translations later." Not "add a TODO." The pull request that introduces the string introduces it eight times, or it doesn't merge.
This sounds like bureaucracy for a team of one. It's the opposite — it's the only thing that keeps a team of one honest. Deferred translation work is invisible work. It has no ticket, no failing test, no user complaint until the day a Polish user hits a raw translation key in the middle of a page. By then the missing string is one of two hundred missing strings, and backfilling them means re-deriving context I had perfectly loaded in my head months earlier, for free, at the moment I wrote the English copy.
Translating a string when you write it costs a couple of minutes. Translating it six months later costs archaeology.
What CI sees — and what it can't
I have parity test suites that walk the locale files and fail if the wired languages drift out of shape: a key present in English but missing in Italian breaks the build. That's the safety net, and it has caught real mistakes.
But here's the uncomfortable detail: the parity suites only cover the locales that are actually wired into the language switcher. The staged catalogs — the ones translated and committed but not yet user-selectable — are invisible to CI. A missing key in a staged catalog fails nothing. No test goes red. The drift just accumulates silently until the day the switcher lights that language up, at which point you discover the debt all at once.
The lesson generalises well beyond i18n: CI only protects the paths it exercises. Anything staged, flagged off or dark-launched is running on author discipline alone. You can either accept that and make the discipline explicit — my rule is that the staged catalogs are treated exactly like the wired ones in every pull request — or you can extend the tests to cover dark surfaces too. What you cannot do is assume green CI means the whole codebase is consistent. Green CI means the tested subset is consistent.
Register, glossary, and the tone problem
Machine translation gets you 80% of the way, and the remaining 20% is where products feel foreign. Two things close the gap.
First, a glossary of anchored terms. Certain words must translate the same way every single time — the names of things in the product, recurring verbs on buttons, the words in navigation. Without an anchor list, "settings" becomes three different words across three screens and the product feels stitched together. The glossary lives in the repo, next to the conventions doc, so it's in context every time strings get written.
Second, an explicit register decision per language. German has a formal/informal split, so do French, Italian, Spanish and Polish. A gaming platform talking to its users like a tax office is wrong; the informal register is a deliberate, documented choice, decided once and applied everywhere. This is the kind of decision that is cheap to make on day one and brutally expensive to retrofit, because changing register touches every string in the catalog.
Machine-assisted, human-checked
I won't pretend I hand-translate eight languages. The workflow is machine-assisted translation with the glossary and register rules enforced at generation time — the same rules-in-context discipline that governs the rest of my tooling — followed by human spot checks: my own for the languages I can read, and beta testers for the ones I can't. Testers surface the howlers: idioms translated literally, strings that overflow their buttons, dates formatted for the wrong side of the Atlantic.
That last category deserves its own mention. Length is a layout problem disguised as a translation problem. A button sized lovingly around "Save" will strain around "Speichern" and break around "Zapisz zmiany." I now treat every UI surface as needing to survive roughly 1.5× the English string length, and the responsive test pass includes at least one long-language spot check.
Why start at eight instead of adding later
The obvious question: why carry eight catalogs before launch, as one person, when English-plus-one would do? Because retrofitting i18n is one of the most expensive migrations a codebase can absorb. Every hardcoded string is a landmine; extraction means touching effectively every component; and the extraction changes conflict with all feature work in flight. Teams postpone it for years because the cost grows with the codebase.
Starting with the pipeline in place inverted the cost curve for me. The marginal cost of the eighth language is small — the same keys, one more file, and the tooling was already built for two. The cost of the first non-English language is the real bill, and I chose to pay it when the codebase was tiny.
There's also a market reason. This platform is being built from Europe, for players who mostly do not have English as a first language. Gaming is one of the most international consumer spaces that exists; shipping English-only into it always felt like building a product for someone else's audience.
The takeaway
If you're solo and tempted by i18n: the tooling is the easy part. What matters is one non-negotiable process rule (strings ship in all languages, same change), one honest acknowledgment (CI can't see your dark locales — discipline has to), and two decisions made early and written down (glossary anchors, register per language). That's the whole system. It fits in a page of a conventions doc, and it's the difference between eight languages and eight liabilities.