Shipping 25 languages with English as the key
Freesbe’s apps speak 25 languages, from Spanish and Japanese to Hindi, Arabic and Hebrew. We added the first ten about ten days into the project and the next fourteen a couple of days later. That only worked because of a few early decisions about how translation fits into the code.
The decision: the English sentence is the key
There are two common ways to mark text for translation. The first uses ids:
<Button>{t("composer.send_button")}</Button>
with a separate English file that says "composer.send_button": "Send". The second uses the
English sentence itself:
<Button>{t("Send")}</Button>
We chose the second. Each language’s catalog maps the English sentence to its translation:
"Send": "Enviar".
Why:
- Code reads like the product. In a code review you see exactly what the person will read, not an id you have to look up. When most of your code is written by AI agents and read by people, that matters even more.
- A missing translation still makes sense. If a language hasn’t caught up with a new sentence,
people see the English, not
composer.send_button. - No naming debates. Nobody has to decide whether it’s
send_buttonorcomposer.send.
The usual objection is that the same English word can mean two things (“Archive” the verb and “Archive” the noun). In practice it’s rare, and the fix is to write clearer English, which helps the English readers too.
Plurals, done by the language, not by us
English has two plural forms: “1 message”, “2 messages”. Russian and Polish have more, Arabic has
six, and Japanese effectively has one. Hard-coding count === 1 ? … : … is wrong for most of the
world.
So plural messages use the language’s own rules from the browser’s Intl.PluralRules. A
translation provides a form for each category its language uses (“one”, “few”, “many”, “other”),
and the app picks the right one.
Right to left is more than dir="rtl"
Arabic and Hebrew read right to left, and switching the page direction is the easy 20 percent. The rest is a long list of small things:
- Logical CSS everywhere. We write
ms-2(margin at the start) instead ofml-2(margin on the left),ps-3instead ofpl-3,text-startinstead oftext-left. Then the layout flips by itself. - Each message decides its own direction. A Hebrew speaker in an English workspace writes
some messages in each language. Every message gets
dir="auto", so each one reads the right way. - Names are isolated. A sentence like “Maya joined” in a right-to-left language, with a left-to-right name in the middle, can come out scrambled: punctuation jumps to the wrong side, numbers attach to the wrong word. Every value inserted into a translated sentence is wrapped in invisible Unicode isolation marks, so it keeps its own direction without disturbing the sentence.
- Icons that point somewhere flip. A “back” arrow points right in Arabic. A play button does not.
Keeping 25 catalogs in step
Every feature adds new text, and a feature can ship in English first, so translation never blocks a
release. A script finds every t("…") in the code and lists what each language is still missing,
so nothing is forgotten. First drafts of new translations are made by AI and checked in context in
the running app.
With many branches adding strings to the same catalogs every day, merge conflicts were constant. We fixed that with a custom git merge driver that merges catalogs string by string, which we wrote about in Running many AI coding agents on one repo.
What we turned down
- Id keys. The industry default, and the reason so many apps show
settings.titlesomewhere when a translation is missing. - Translating on the fly. Machine-translating text as the page loads sounds easy, but it’s slow, costs per view, and gives different words for the same button on different days.
- English only until we’re bigger. Adding languages later means finding every hard-coded string in a large codebase. Adding them early meant every string was marked from the start.
What we’d tell another team
Mark every string for translation from the first screen, even if you only ship English. Use logical CSS from day one. And test your app in a right-to-left language at least once a week: it’s the fastest way to find layout that only works by accident.
To change Freesbe’s language, see Language in the help center.