Offline & Caching
How persistent caching and offline fallback works in @better-i18n/expo
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.
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 ⚠️App Store Review risk: Apple reviewers may test your app in environments where external CDN calls fail or timeout. Without layers 2 and 3, your app shows raw keys like common.button.save and gets rejected under Guideline 2.1 (App Completeness).
| Layer | Protects against | Required? |
|---|---|---|
| CDN | Nothing — this is the happy path | Always active |
| Persistent storage | App restarts, intermittent network issues | Strongly recommended |
| staticData | First launch with no network (App Store Review, airplane mode) | Strongly recommended for production |
Recommended Setup
This is the production-ready pattern. All three layers active:
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';
import tr from './locales/tr.json';
const mmkv = new MMKV({ id: 'i18n' });
i18n.use(initReactI18next);
export const i18nReady = initBetterI18n({
project: 'acme/my-app',
i18n,
storage: storageAdapter(mmkv, { localeKey: '@app:locale' }), // Layer 2
staticData: { en, tr }, // Layer 3
useDeviceLocale: true,
});Keeping staticData in Sync
Use the CLI to download fresh translations before each build:
npx @better-i18n/cli pull -o ./localesThis fetches all languages from your CDN and writes them to locales/*.json. Add it to your CI/CD pipeline:
#!/bin/bash
npx @better-i18n/cli pullConfigure the output directory in i18n.config.ts so you don't need flags:
export const i18nConfig = {
project: "acme/my-app",
defaultLocale: "en",
pull: { output: "./locales" },
};Then just run 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):
@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() (Recommended)
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({
project: 'acme/app',
i18n,
storage: storageAdapter(mmkv, { localeKey: '@app:locale' }),
});import AsyncStorage from '@react-native-async-storage/async-storage';
import { initBetterI18n, storageAdapter } from '@better-i18n/expo';
await initBetterI18n({
project: 'acme/app',
i18n,
storage: storageAdapter(AsyncStorage, { localeKey: '@app:locale' }),
});MMKV and AsyncStorage are equal choices. MMKV is faster with synchronous I/O; AsyncStorage works out of the box with Expo Go and requires no native build step.
Manual Implementation
For a fully custom store, implement the TranslationStorage interface directly:
await initBetterI18n({
project: '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:
Startup locale resolution (no localeKey):
1. getDeviceLocale() ← if useDeviceLocale: true
2. defaultLocale ← fallbackWith localeKey
When localeKey is set, storageAdapter() returns a LocaleAwareTranslationStorage that reads and writes the active locale alongside translation data. On startup:
Startup locale resolution (with localeKey):
1. storage.readLocale() ← user's saved preference (wins if set)
2. getDeviceLocale() ← if useDeviceLocale: true
3. defaultLocale ← fallbackinitBetterI18n detects the readLocale / writeLocale methods via duck-type check at runtime — no extra configuration required.
Without localeKey, the user's language choice resets to defaultLocale on every app restart — even if they previously selected a different language. For production apps, always pass localeKey to storageAdapter().
SDK Warnings
The SDK automatically detects common misconfigurations and logs warnings:
| Condition | Warning |
|---|---|
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 staticData | CRITICAL: "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:
Pattern 1: SDK-managed (recommended)
The SDK detects the device language on first launch, then persists the user's choice via localeKey.
await initBetterI18n({
project: 'acme/app',
i18n,
useDeviceLocale: true,
storage: storageAdapter(mmkv, { localeKey: '@app:locale' }),
});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.
const locale = myStorage.getLanguage() || getDeviceLanguage() || 'en';
await initBetterI18n({
project: 'acme/app',
i18n,
useDeviceLocale: false,
defaultLocale: locale,
storage: storageAdapter(mmkv),
});Do not use useDeviceLocale: true without localeKey. Without locale persistence, the device language overrides the user's in-app choice on every app restart.