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 imports

The 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:native
app/(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.

#ClassWhat it means
1orphan overridethe key you override no longer exists in web — upstream renamed or removed it. Find the new name or delete the override.
2redundant overrideweb's value now equals yours. Delete the override; the divergence is over.
3shadowed additionupstream now ships a key you invented. Inherit it, or promote yours to an override if the meanings differ.
4shape drift / shape conflictweb turned a flat key into a group of keys, or vice versa, and a local override or addition still targets the old shape.
5ICU driftweb's placeholder set no longer matches your override's — one of you gained or lost a {param} or a <tag>.
6new namespace / localeupstream added a message file or locale dir not listed in the generator config. Inherit it or skip it explicitly.
7stale generated filesomeone changed web strings or the local delta without regenerating — or hand-edited the generated file.
8inherited whitespace (warning)an inherited value has leading/trailing whitespace. Upstream's defect; override it if it shows.
9malformed ICUunbalanced braces that would throw at runtime. Quote-aware, so an escaped '{' counts as a literal, not an opening brace.
10unbuilt locale deltaadditions/<locale>/ or overrides/<locale>/ exists for a locale not in LOCALES, so nothing reads it. Add the locale or delete the directory.
missing translationa 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:

  1. Ensure apps/web/i18n/messages/de/ exists.
  2. Add 'de' to LOCALES in tooling/scripts/src/i18n-native.mjs.
  3. Create apps/native/lib/i18n/additions/de/ and overrides/de/, mirroring en's key set.
  4. Import @formatjs/intl-pluralrules/locale-data/de.js in apps/native/app/_layout.tsx — Hermes ships without Intl.PluralRules, and the polyfill needs per-locale data.
  5. 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

  1. pnpm i18n:native — fix anything it reports (§4).
  2. 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.
  3. pnpm typecheck — catches ICU-param changes at the call sites (it covers native).
  4. 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.