Better I18NBetter I18N
Expo

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

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'; 
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 ./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

Configure the output directory in i18n.config.ts so you don't need flags:

i18n.config.ts
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() 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        ← fallback

With 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           ← fallback

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

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.

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.

On this page