●ACQUISITION — Rork makes its first acquisition, buying Paperline, a macOS app that generates native Swift apps with AI●FUNDING — The $15M seed led by Left Lane Capital backs Rork's push to redefine how mobile apps are built and monetized●GROWTH — Rork Max reportedly hit $1.5M ARR within three days of launch and doubled annual revenue in two weeks●ENGINE — Rork Max runs on Claude Code and Claude Opus 4.6, the first web Swift builder aiming to replace Xcode●SPLIT — Standard Rork uses React Native (Expo); Rork Max generates native Swift across the whole Apple ecosystem●PRICING — Start free; paid plans begin at $25/month, with Rork Max at $200/month●ACQUISITION — Rork makes its first acquisition, buying Paperline, a macOS app that generates native Swift apps with AI●FUNDING — The $15M seed led by Left Lane Capital backs Rork's push to redefine how mobile apps are built and monetized●GROWTH — Rork Max reportedly hit $1.5M ARR within three days of launch and doubled annual revenue in two weeks●ENGINE — Rork Max runs on Claude Code and Claude Opus 4.6, the first web Swift builder aiming to replace Xcode●SPLIT — Standard Rork uses React Native (Expo); Rork Max generates native Swift across the whole Apple ecosystem●PRICING — Start free; paid plans begin at $25/month, with Rork Max at $200/month
Why Untranslated Strings Leak to Production Every Time You Add a Language — A Catalog and Gap-Detection Design for Rork (Expo) Apps
A design for stopping untranslated strings from leaking into production after you localize a Rork-generated Expo app. Covers a single source-of-truth catalog, CI-based missing/extra key detection, explicit fallback chains, plurals, and pseudolocalization for layout — with implementation.
Why Untranslated Strings Leak to Production Every Time You Add a Language
I once added two languages to one of my indie apps over a weekend. I dropped in two translation files, scanned the screens, and shipped because nothing looked wrong. A few days later a report came in: one heading on one screen was still showing in English. The reason was mundane — a key that didn't exist when I wrote those two language files had been added later by a separate feature. The new key only lived in the default language; the two languages I'd added never received it.
That was when it clicked: untranslated strings don't leak when you add a language — they leak when you add a key. Multilingual apps rarely break at the translation step. They break in the operational drift where key churn and translation coverage quietly fall out of sync. This article shares the design I use to stop that drift mechanically rather than by eyeballing, with the implementation included.
Why Translations Leak "Later"
When you first create your translation files, every language holds the same set of keys. But apps grow. Add a feature and you add strings, and those strings normally land only in a language you can write yourself (for most of us, Japanese or English). Propagating them to the rest is a separate step, and that step introduces a time lag.
The trap is that most i18n libraries stay silent on a missing key. They render the key name itself, or quietly fall back to the default language. Not crashing is a virtue, but because a missing translation isn't an error, nobody notices until it's in production. Manual review collapses under the product of screen count and language count: three languages over twenty screens is sixty combinations, and dynamic state multiplies that further. No human reviews all of it.
So the design has a single starting point: guarantee translation coverage with mechanical diffing, not human attention. Let's build that up from the foundation.
Put the String Catalog in a Single Source of Truth
The first move is to centralize strings as a typed catalog. Treat the default language (Japanese, in my case) as the source of truth, and treat its key set as a contract every other language must satisfy.
// i18n/catalog.ts — the default language (ja) is the source of truth for keysexport const ja = { common: { save: "保存", cancel: "キャンセル", }, paywall: { title: "すべての機能を解放", cta: "続ける", restore: "購入を復元", },} as const;// Derive the key type from the default language; other languages must satisfy itexport type Catalog = typeof ja;// Receive other languages as Catalog (not DeepPartial) to catch missing keys at compile timeexport const en: Catalog = { common: { save: "Save", cancel: "Cancel" }, paywall: { title: "Unlock everything", cta: "Continue", restore: "Restore purchases" },};
The crucial part is typing en as Catalog rather than a DeepPartial. If en is missing a key, TypeScript fails at compile time. The classic accident — adding a key to the default language and forgetting to add it to English — turns red in your editor before CI even runs.
But types only protect languages that exist as files typed against the catalog. Languages you receive as JSON from a translator, or seed via machine translation, sit outside the type system. That's what the next mechanical check is for.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Reframes the real cause of leaked translations as the gap between key churn and translation coverage, and shows how to close it by design
✦A CI script that mechanically diffs keys across every locale to block untranslated strings before release, plus fallback chains, plurals, and pseudolocalization — the exact setup I run on my own multilingual indie apps
✦How to measure translation coverage as a number on a dashboard, and a checklist that turns adding a language into safe, routine work
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Add a script that compares every locale's key set against the default language and reports both missing keys (untranslated) and extra keys (dead strings whose translations linger after the source was deleted). Both accumulate if ignored.
// scripts/i18n-check.mjs — diff keys across all locales and fail via exit codeimport { ja } from "../i18n/catalog.js";import * as locales from "../i18n/locales/index.js";const flatten = (obj, prefix = "") => Object.entries(obj).flatMap(([k, v]) => { const key = prefix ? `${prefix}.${k}` : k; return typeof v === "object" && v !== null ? flatten(v, key) : [key]; });const base = new Set(flatten(ja));let failed = false;for (const [locale, dict] of Object.entries(locales)) { const keys = new Set(flatten(dict)); const missing = [...base].filter((k) => !keys.has(k)); const extra = [...keys].filter((k) => !base.has(k)); const coverage = (((base.size - missing.length) / base.size) * 100).toFixed(1); console.log(`[${locale}] coverage ${coverage}% missing ${missing.length} extra ${extra.length}`); if (missing.length) { console.error(` ✗ untranslated: ${missing.slice(0, 10).join(", ")}${missing.length > 10 ? " …" : ""}`); failed = true; } if (extra.length) { console.warn(` △ dead keys: ${extra.slice(0, 10).join(", ")}`); }}process.exit(failed ? 1 : 0);
Wire this into your package.jsonlint or a pre-push hook and the build turns red if even one locale has untranslated strings. I paste the coverage lines straight into the PR comment, so "English 100%, German 92%, 12 untranslated" is visible at a glance. Keeping dead keys as a warning rather than a hard failure is deliberate — right after you delete a string, its stale translations linger for a moment, and that's normal.
Where you catch the gap determines how much it costs. The table below is the "the later you notice, the more it hurts" order I've lived through.
Where it's caught
Time to notice
Fix cost
Production exposure
Editor (type error)
As you type
Minimal
None
CI (key diff)
At push
Low
None
Manual QA
Pre-release (luck-dependent)
Medium
None to low
User report
After production
High (rebuild/re-review)
Yes
Stop it with the two-stage net of types and CI, and manual QA is freed to judge translation quality instead of pointlessly verifying translation existence by hand.
Design the Fallback Chain on Purpose
Even so, there are moments a missing translation slips through — a language whose machine-translation pass didn't land in time, a string added right before release. For those moments, decide what the user sees explicitly rather than leaving it to the library.
The worst outcome is the key name (a string like paywall.title) rendering verbatim. Forbid that, and define a fallback chain like "this language → English → default (Japanese)."
// i18n/index.ts — an explicit fallback chainimport i18n from "i18next";import { initReactI18next } from "react-i18next";import * as Localization from "expo-localization";i18n.use(initReactI18next).init({ resources: { ja: { translation: ja }, en: { translation: en }, de: { translation: de } }, lng: Localization.getLocales()[0]?.languageCode ?? "en", fallbackLng: ["en", "ja"], // chain: current → English → Japanese returnEmptyString: false, // don't let an empty-string translation stop the fallback parseMissingKeyHandler: (key) => { if (__DEV__) console.warn(`[i18n] missing key: ${key}`); return ""; // never show the key name; return empty and defer to the chain },});
The key points are sealing off key-name rendering in parseMissingKeyHandler and warning only in development. Making fallbackLng an array spells out the chain, so if German is missing the user gets English, and if English is missing too they get Japanese. From the user's side it lands at "not my language, but I get the meaning" — and the worst case, a raw key name, never happens.
A fallback is a safety net, not an excuse to tolerate untranslated strings. CI is the real defense; the fallback is the last sheet that keeps the one string that still slipped through from looking broken.
Don't Bake Plurals and Word Forms Into Keys
Alongside missing strings, plurals break easily. "3 items / 1 item" follows different number-based form rules per language. English has two forms (singular/plural), but Russian and Arabic have more. Hand-rolling this with a ternary makes the branch you wrote with English instincts fall apart in other languages.
Follow ICU plural categories (one, other, and so on) and let the library pick the form.
// in the catalog: express ICU plurals plainlyen: { cart: { items_one: "{{count}} item", items_other: "{{count}} items" },},ja: { cart: { items_other: "{{count}} 件" }, // Japanese has no singular/plural split, so other only},// at the call sitet("cart.items", { count: n }); // the library picks one/other based on n
Japanese needs only other since word form doesn't change with number, while English carries one and other. The i18n-check script compares keys including the _one / _other suffixes, so forgetting _one in English is detected as a missing translation. By placing plurals in the catalog as branched keys, plurals ride along inside the coverage check.
Catch Layout Breakage Early with a Pseudolocale
Translation is a matter of length, not just meaning. German often runs 20–30% longer than English, pushing button text past its edge or wrapping to a second line that shoves the elements below it down. To catch this before real translations arrive, use a pseudolocale.
// i18n/pseudo.ts — transform the default language to fake length and character varietyexport const pseudoize = (s: string): string => { const map: Record<string, string> = { a: "á", e: "é", i: "í", o: "ó", u: "ú" }; const expanded = s.replace(/[aeiou]/g, (c) => map[c] ?? c); // 30% longer to mimic the widest language; brackets reveal truncation const pad = "~".repeat(Math.ceil(expanded.length * 0.3)); return `⟦${expanded} ${pad}⟧`;};
Switch the language to the pseudolocale in a development build and every string shows up 30% longer, accented, and wrapped in ⟦ ⟧. If text is truncated, one bracket is missing; if it overflows, you see it instantly. The accented characters also flush out missing font glyphs. I wire this into the dev build as a hidden toggle and flip it once whenever I build a new screen. The benefit is confirming "whatever language arrives, it fits" before handing anything to a translator.
Turn Adding a Language Into Routine Work
Once these parts are in place, adding a language stops being "ship and pray" and becomes routine. Here are the steps I run when I add a new language.
Lock the default-language (ja) catalog as the baseline for i18n-check (clean out dead keys at this point).
Create the new locale file and seed every key with machine translation (leave no empty keys).
Run node scripts/i18n-check.mjs and confirm 100% coverage, zero missing.
Walk the main screens in the real locale (not the pseudolocale) and look only for overflow and awkward wrapping.
On screens with plurals (cart counts, list counts) actually render 0/1/2/many to verify word forms.
Pass CI, merge, and split OTA versus store delivery based on release size.
Seeding every key with machine translation isn't about quality — it's about driving untranslated keys to zero so the check passes. You raise quality by hand afterward; the safe move is to first reach a state with no gaps.
Keeping coverage as a recorded metric makes operational health visible. I log each language's coverage per release and review monthly whether any one language keeps slipping. If it does, that's a sign string additions for that language aren't keeping up with the translation step.
Closing
The genuinely hard part of localization isn't translation itself — it's holding coverage steady in an app whose keys never stop churning. Stop it the moment you type with types, stop key diffs in CI, land the last straggler gracefully with a fallback, and find length problems early with a pseudolocale. Layer those four and untranslated strings shift from "something users find" to "something machines block."
Start by dropping in a single scripts/i18n-check.mjs and running it before every push. Just seeing coverage as a number quietly tells you which language to invest in next. As an indie developer, I hope it helps anyone growing a multilingual app the same way.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.