Internationalization in the Expo app | Expo Supabase SaaS Kit
Understand how the native message catalogue inherits web's strings and keeps only a local delta, what each drift check means, and how to add a locale.
The native app does not own a message catalogue. It inherits web's strings and keeps only a local delta on top, so a string fixed on web propagates to native on the next generation. This page covers that model, the drift checks that keep it honest, and the steps to add a locale.
1. The model
apps/web/i18n/messages/<locale>/*.json upstream-owned base — never edited by us │ ▼apps/native/lib/i18n/ overrides/<locale>/*.json key MUST exist in web, value MUST differ additions/<locale>/*.json key MUST NOT exist in web │ ▼ messages.generated.ts committed; the only thing the app importsThe two local trees have opposite invariants, to easily detect upstream changes conflict and keep reasoning for overrides. marketing is deliberately not inherited — native has no blog, changelog, pricing, contact, or legal screens.
Commands
pnpm i18n:native # regenerate (also runs automatically before expo start/ios/android)pnpm i18n:native:check # verify only; also runs inside `pnpm typecheck`apps/web/** is never written. The generator only reads it.
2. Why the generated file is .ts and not .json
as const on a TypeScript object literal keeps every message a literal type. use-intl's TranslateArgs branches on string extends Value: widened strings get the permissive overload, literals get ICU arguments parsed by @schummar/icu-type-parser.
So emitting .ts buys ICU-argument checking at every call site. Concretely, when upstream reparameterizes a string:
apps/web/i18n/messages/en/auth.json: "signInHeading": "Sign in to {productName}" ↓ pnpm i18n:nativeapp/(auth)/sign-in.tsx(95,14): error TS2554: Expected 2-3 arguments, but got 1.3. Overrides
An override says "web's string is wrong for native". Each carries a required why, because JSON has no comments and the question "do we still need this?" has to be answerable from the file.
{ "multiFactorModalHeading": { "value": "Open your authenticator app with the button below, …", "why": "native deep-links to the authenticator app and offers a copyable setup key; web renders a QR code" }}4. Drift checks
All of these fail the generator except where noted, and nothing is written when any of them fires — emitting output while an override is orphaned would silently drop a string.
| # | Class | What it means |
|---|---|---|
| 1 | orphan override | the key you override no longer exists in web — upstream renamed or removed it. Find the new name or delete the override. |
| 2 | redundant override | web's value now equals yours. Delete the override; the divergence is over. |
| 3 | shadowed addition | upstream now ships a key you invented. Inherit it, or promote yours to an override if the meanings differ. |
| 4 | shape drift / shape conflict | web turned a flat key into a group of keys, or vice versa, and a local override or addition still targets the old shape. |
| 5 | ICU drift | web's placeholder set no longer matches your override's — one of you gained or lost a {param} or a <tag>. |
| 6 | new namespace / locale | upstream added a message file or locale dir not listed in the generator config. Inherit it or skip it explicitly. |
| 7 | stale generated file | someone changed web strings or the local delta without regenerating — or hand-edited the generated file. |
| 8 | inherited whitespace (warning) | an inherited value has leading/trailing whitespace. Upstream's defect; override it if it shows. |
| 9 | malformed ICU | unbalanced braces that would throw at runtime. Quote-aware, so an escaped '{' counts as a literal, not an opening brace. |
| 10 | unbuilt locale delta | additions/<locale>/ or overrides/<locale>/ exists for a locale not in LOCALES, so nothing reads it. Add the locale or delete the directory. |
| — | missing translation | a key exists in the default locale but not another one — see §5. |
5. Adding a locale
The pipeline is locale-aware end to end; nothing hardcodes en. The generator emits generatedLocales and generatedDefaultLocale, and provider.tsx derives its supported list and fallback from them.
To add one — say de:
- Ensure
apps/web/i18n/messages/de/exists. - Add
'de'toLOCALESintooling/scripts/src/i18n-native.mjs. - Create
apps/native/lib/i18n/additions/de/andoverrides/de/, mirroringen's key set. - Import
@formatjs/intl-pluralrules/locale-data/de.jsinapps/native/app/_layout.tsx— Hermes ships withoutIntl.PluralRules, and the polyfill needs per-locale data. pnpm i18n:native.
An incomplete locale is refused. Every locale must resolve to the same key set as DEFAULT_LOCALE; the generator names every missing key. Skipping step 3 fails with 50 missing translation de: … lines rather than shipping raw keys to German users. This also keeps the generated AppConfig.Messages type sound, since it is derived from the default locale's shape.
Doing step 3 but not step 2 is caught too — the delta directory would never be read, so it reports unbuilt locale delta instead of silently going nowhere.
Step 4 is the one thing nothing enforces. A missing locale-data/<locale>.js import fails only at runtime, and only on plural messages, so it can pass every check and still break in the app.
6. Runtime keys
Keys built at runtime cannot be checked against the catalogue. They go through one helper, apps/native/lib/i18n/dynamic.ts:
tDynamic(t, `roles.${role}.label`, role)grep tDynamic is the audit list of places where inherited web copy can reach the UI without review. The helper forces an explicit fallback, so a missing key shows something sensible instead of a raw key. Current callers: use-role-label.ts, subscription-status-badge.tsx, tab-stack.tsx, web-only-notice.tsx.
<Trans> from @kit/mobile-ui/trans accepts runtime keys by design and casts to never, so its call sites are not ICU-checked. A scan over its literal keys is a possible follow-up.
7. Upstream-merge runbook
pnpm i18n:native— fix anything it reports (§4).git diff apps/native/lib/i18n/messages.generated.ts— a precise list of every string native will now render differently. Review each for native suitability. Nothing else produces this list.pnpm typecheck— catches ICU-param changes at the call sites (it covers native).- Run the app.
Never move, delete, or reformat apps/web/i18n/messages/. Upstream owns those files and edits them often; relocating them would cause a delete/modify conflict on every pull. Inheriting in place is what makes this cheap.