Skip to content
better-i18n.com
Sur cette page

Mobile apps need to work without network. @better-i18n/expo uses a network-first strategy with persistent storage so your app always gets fresh translations — and never crashes when the CDN is unavailable.

Defense in Depth #

Production mobile apps should have three layers of translation fallback. If any layer fails, the next one catches it.

Code
initBetterI18n() / changeLanguage()
│
├─ 1. CDN fetch (network-first)
│     ├─ Success → Use fresh translations + update cache
│     └─ Failure ↓
│
├─ 2. Persistent storage (MMKV / AsyncStorage)
│     ├─ Cached? → Use cached translations
│     └─ No cache ↓
│
└─ 3. staticData (bundled in app binary)
      ├─ Has locale? → Use bundled translations
      └─ No data → Raw translation keys shown ⚠️
LayerProtects againstRequired?
CDNNothing — this is the happy pathAlways active
Persistent storageApp restarts, intermittent network issuesStrongly recommended
staticDataFirst launch with no network (App Store Review, airplane mode)Strongly recommended for production

This is the production-ready pattern. All three layers active:

lib/i18n.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import { MMKV } from 'react-native-mmkv';
import { initBetterI18n, storageAdapter } from '@better-i18n/expo';

// Bundled translations — generated by: npx @better-i18n/cli pull
import en from './locales/en.json'; // [!code highlight]
import tr from './locales/tr.json'; // [!code highlight]

const mmkv = new MMKV({ id: 'i18n' });
i18n.use(initReactI18next);

export const i18nReady = initBetterI18n({
  projectId: 'acme/my-app',
  i18n,
  storage: storageAdapter(mmkv, { localeKey: '@app:locale' }), // [!code highlight]  Layer 2
  staticData: { en, tr }, // [!code highlight]  Layer 3
  useDeviceLocale: true,
});

Keeping staticData in Sync #

Use the CLI to download fresh translations before each build:

Bash
npx @better-i18n/cli pull -o ./locales

This fetches all languages from your CDN and writes them to locales/*.json. Add it to your CI/CD pipeline:

eas-build-pre-install.sh
#!/bin/bash
npx @better-i18n/cli pull

Cache Layers #

1. Resource Store (In-Memory) #

The fastest layer. Once translations are loaded into i18next's resource store, subsequent changeLanguage() calls skip the CDN entirely.

  • Survives navigation and re-renders
  • Cleared when the app is killed
  • Checked via hasResourceBundle() before any network call

2. Persistent Storage #

Translations are persisted to device storage so they survive app restarts. Used as offline fallback when the CDN is unreachable.

Storage keys (per locale):

Code
@better-i18n:{project}:{locale}:translations  → JSON translation data
@better-i18n:{project}:{locale}:meta           → { cachedAt: timestamp }

Each locale has a single cache entry that gets overwritten on every successful CDN fetch.

3. Static Data (Bundled Translations) #

Translations bundled directly in your app binary via staticData. Always available — no network or storage needed. Used as the last resort when both CDN and persistent cache fail.

Because they're shipped with the app, they may go stale between releases. Run better-i18n pull before each build to keep them current.

Persistent Storage #

storageAdapter() wraps MMKV or AsyncStorage automatically.

import { MMKV } from 'react-native-mmkv';
import { initBetterI18n, storageAdapter } from '@better-i18n/expo';

const mmkv = new MMKV({ id: 'app' });

await initBetterI18n({
  projectId: 'acme/app',
  i18n,
  storage: storageAdapter(mmkv, { localeKey: '@app:locale' }),
});

Manual Implementation #

For a fully custom store, implement the TranslationStorage interface directly:

TypeScript
await initBetterI18n({
  projectId: 'acme/app',
  i18n,
  storage: {
    getItem: async (key: string) => /* ... */,
    setItem: async (key: string, value: string) => /* ... */,
    removeItem: async (key: string) => /* ... */,
  },
});

Locale Persistence #

By default, locale selection is not persisted across app restarts. Pass localeKey to storageAdapter() to enable automatic locale persistence.

Without localeKey #

The user's language selection is lost when the app is killed. On next launch, the startup locale is resolved in this order:

Code
Startup locale resolution (no localeKey):
1. getDeviceLocale()    ← if useDeviceLocale: true
2. defaultLocale        ← fallback

With localeKey #

When localeKey is set, storageAdapter() returns a LocaleAwareTranslationStorage that reads and writes the active locale alongside translation data. On startup:

Code
Startup locale resolution (with localeKey):
1. storage.readLocale()    ← user's saved preference (wins if set)
2. getDeviceLocale()       ← if useDeviceLocale: true
3. defaultLocale           ← fallback

initBetterI18n detects the readLocale / writeLocale methods via duck-type check at runtime — no extra configuration required.

SDK Warnings #

The SDK automatically detects common misconfigurations and logs warnings:

ConditionWarning
No storage provided"Translations won't survive app restarts"
No staticData provided"App will show raw translation keys if CDN is unreachable on first launch"
Neither storage nor staticDataCRITICAL: "Zero offline fallback — will cause App Store rejection"
staticData has empty objects"Bundled fallback will not work for these locales"
staticData missing namespaces vs CDN"Missing N namespaces — won't be available offline"
useDeviceLocale: true without localeKey"User's in-app language choice will be lost on every restart"

These warnings appear in your development console to help you catch issues before submitting to the App Store.

Locale Detection Patterns #

There are two ways to handle initial locale detection. Choose one:

The SDK detects the device language on first launch, then persists the user's choice via localeKey.

TypeScript
await initBetterI18n({
  projectId: 'acme/app',
  i18n,
  useDeviceLocale: true, // [!code highlight]
  storage: storageAdapter(mmkv, { localeKey: '@app:locale' }), // [!code highlight]
});

On first launch: device language is used. After the user changes language in-app, that choice is saved and used on subsequent launches.

Pattern 2: App-managed #

Your app handles locale detection and persistence. Pass the resolved locale as defaultLocale.

TypeScript
const locale = myStorage.getLanguage() || getDeviceLanguage() || 'en';

await initBetterI18n({
  projectId: 'acme/app',
  i18n,
  useDeviceLocale: false, // [!code highlight]
  defaultLocale: locale, // [!code highlight]
  storage: storageAdapter(mmkv),
});