# 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.

*Canonical: https://makerkit.dev/docs/react-native-supabase/i18n*

---

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

```bash
pnpm i18n:native          # regenerate and write
pnpm i18n:native:check    # verify only
```

`apps/web/**` is never written. The generator only reads it.

**Verification is enforced, not remembered.** `native-app#typecheck` depends on an uncached
`i18n:check` turbo task, so the check runs on every native typecheck and drift fails the build.
Scoping it to this one task leaves every other package's cache untouched, which `globalDependencies` would not.

---

## 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.

```json
{
  "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`:

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`:

```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). **Expect a diff on every upstream merge**: the header stamps the kit version read from root `package.json`, so it changes even when no string does.
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.
