DEV Community

Ibukun Demehin
Ibukun Demehin

Posted on

The app speaks 19 languages: tiered i18n and the AI translation pipeline

The codebase survey was blunt: zero i18n infrastructure, roughly 660 user-facing text nodes across 53 files, a dozen alert dialogs, forty toasts, screen titles scattered across layout files. English was load-bearing everywhere.

The reason to fix it then, rather than "after launch", was the closed-test window: Google Play makes you sit in testing for fourteen days regardless, and fourteen days of real people using translated builds is worth more than fourteen days of them using English. So the app learned six languages in one pass, then thirteen more.

TL;DRi18next + react-i18next + a plural-rules polyfill (the JS engine ships a stub Intl on some platforms, and Arabic needs six plural categories), eight namespaces, static resources so the first frame already has copy. Language is per-user, not per-device — the currency pattern — cached for the first frame, mirrored on the profile so it roams, cleared and restored on account switch; the Cognito locale attribute is written at sign-up so emails can follow later. Arabic flips the shell RTL with a native flag and a one-shot restart prompt, logical direction classes, and a font trick: the Arabic face is registered under the Latin font's names, so every existing style re-faces with zero call-site changes. Two tiers — six human-reviewable launch languages, thirteen machine-translated — generated by a diff-only pipeline that forces structured output because free-form JSON kept breaking on quotes.

(Part 33 of Building CannyCart, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)

Four pillars, all cloned from patterns the app already had

The plan's insight was that the app already contained every pattern i18n needed — a per-user preference (currency), a device-detected onboarding step (country), a searchable picker (voice language). i18n was the fourth instance of each, not a new discipline.

The library layer is i18next + react-i18next — pure JS, no dev-client rebuild — with the intl-pluralrules polyfill. That polyfill matters more than it sounds: the Hermes engine ships a real Intl on some platforms and a stub on others (the trap Part 16 hit with thousands separators), and correct CLDR plural rules are essential once Arabic — with six plural categories — is in the mix. Eight feature namespaces, static resources bundled with the app so the very first frame already has copy (no flash of keys), and dayjs switches locale alongside so dates translate with the words. 568 English keys at launch.

Per-user, not per-device — the shared-phone argument

The theme preference is device-scoped, and the first instinct was to copy it. One question killed that: on a shared phone, user A picks Arabic and signs out — user B is now in Arabic with no idea how to fix it. Language must follow the person. So it copied the currency pattern instead:

  • UserProfile.locale is the durable source of truth, so the choice roams to a user's other devices.
  • A per-user AsyncStorage cache means the right language on the first frame, before the profile query resolves.
  • On account switch the language resets to auto (device language) and is restored from the incoming profile — a returning user is in their language on first paint, a brand-new user is in their phone's, and nobody inherits a stranger's.
  • Signed-out screens therefore run on the device language, which is the right default for an unknown human.

Two extra lines at sign-up write Cognito's standard locale attribute alongside. They looked like over-engineering at the time; they're what later let the verification and reset emails arrive in the user's language, because the email trigger fires before any profile row exists and can only read the pool's own attributes. (That story, and its war story, is the next part.)

First-run onboarding gained a language step ahead of currency, pre-selecting the device language — guess, then confirm, as always.

Arabic: the RTL flip, and a font trick I'm proud of

Right-to-left was the reason Arabic was in the launch tier rather than the extended one: it's the language that finds every layout assumption. Three pieces of work:

The flip itself. RTL is a native flag: allow it at init, keep the force-flag in step on every language apply, and accept that it takes effect on the next launch. A one-shot restart prompt handles that — mounted inside the onboarding gate on purpose, so a first-run Arabic choice finishes onboarding before it asks you to restart. Roughly 33 places where gap/flex-based layouts didn't already mirror had their physical direction classes replaced with logical ones (ps/pe, ms/me, start/end, border-s/border-e).

The font. Nunito has no Arabic glyphs; Arabic text needs its own face, in the same four weights (400/600/700/800), or bold headings render regular. Cairo was the one candidate with exactly that set (the obvious alternatives each lacked a weight). Then the trick: in RTL sessions, Cairo's four weights are registered under Nunito's names in the font loader. Every font-nunito* class and every raw fontFamily: "Nunito_…" string — nineteen of them — re-faces automatically. Zero call-site changes. Sometimes the best migration is lying to the font registry.

Six plurals. Spanish, French and Italian carry their CLDR _many forms; Arabic gets all six categories — and its _zero/_one/_two forms deliberately drop the {{count}} placeholder, because in Arabic the number lives in the word itself (عنصر واحد — "one item" — contains no digit). A translation pipeline that blindly demands {{count}} in every plural form produces Arabic that reads like a machine.

Two tiers, and a pipeline whose limiter is review

  • Launch tier — Spanish, French, German, Italian, Arabic: languages where a human review is realistic, and where the plural/RTL work happens.
  • Extended tier — thirteen more (Portuguese, Dutch, Polish, Turkish, Russian, Ukrainian, Hindi, Indonesian, Vietnamese, Thai, Japanese, Korean, Simplified Chinese): machine-translated, labelled as such, generated by the same pipeline. Portuguese is deliberately pt-BR.

The pipeline is a script that sends the diff of English keys to a language model and writes the results per locale. Its one hard-won rule: force a structured tool call for the output. Free-form JSON in the response kept breaking on quotes and embedded newlines — a translation of a toast with an apostrophe would take the whole locale file down. Structured output made generation zero-retry. A companion checker validates every locale against English for key parity and for the correct plural category set per language — so Arabic can't ship with two forms and French with six.

The observation from running it: review, not tokens, is the limiter. Generating thirteen languages costs minutes; deciding whether the Vietnamese is any good is the actual work, which is why the tiers are honest about which languages a human has looked at.

The one native-rebuild piece: iOS permission strings (camera, microphone, photos) are localised per language through the app config's locales, which bakes into the binary.

What's deliberately not translated (yet)

Category and aisle vocabulary — "Produce", "Dairy" — stays English-keyed internally. Those labels are display text used as keys across the app and the parsing Lambdas; translating them touches the vocabulary the whole data model keys on, which is its own plan, not a string sweep. The voice and receipt parsers already tolerate non-English input; their internal vocabulary just stays anchored.

What I took away

  • Clone the patterns you already have. Currency taught per-user prefs; onboarding taught guess-then-confirm; i18n was the fourth instance, not a new discipline.
  • Language follows the person, not the phone. The shared-device question settles it in one sentence.
  • Write the auth provider's locale attribute early — it's the only thing an email trigger can read.
  • Arabic is the layout's audit — RTL, six plurals, and placeholders that shouldn't exist.
  • Register the fallback font under the primary's names and skip the sweep.
  • Force structured output for generated files — free-form JSON breaks on the first apostrophe.
  • Label your tiers honestly. Machine-translated is fine; pretending it's reviewed isn't.

Next up

Part 34: the emails caught up with the app — verification, reset and welcome messages in the user's language — and the deploy that failed every branch with "Invalid AttributeDataType", because a Cognito user pool's attribute schema is frozen at creation and the sandbox never said so.

Which language would audit your layout hardest — and does your plural handling survive a language where "one" and "two" are their own forms?

Top comments (0)