---
title: Offline & Caching
description: 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 ⚠️
```

<Callout type="warn">
  **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).
</Callout>

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

```ts title="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:

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

<Callout type="tip">
  Configure the output directory in `i18n.config.ts` so you don't need flags:
  ```ts title="i18n.config.ts"
  export const i18nConfig = {
    projectId: "acme/my-app",
    defaultLocale: "en",
    pull: { output: "./locales" },
  };
  ```
  Then just run `npx @better-i18n/cli pull`.
</Callout>

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

<Tabs items={['MMKV', 'AsyncStorage']}>
  <Tab value="MMKV">
```ts
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' }),
});
```
  </Tab>
  <Tab value="AsyncStorage">
```ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import { initBetterI18n, storageAdapter } from '@better-i18n/expo';

await initBetterI18n({
  projectId: 'acme/app',
  i18n,
  storage: storageAdapter(AsyncStorage, { localeKey: '@app:locale' }),
});
```
  </Tab>
</Tabs>

<Callout type="tip">
  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.
</Callout>

### Manual Implementation

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

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

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

<Callout type="warn">
  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()`.
</Callout>

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

```ts
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`.

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

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

<Callout type="warn">
  Do not use `useDeviceLocale: true` without `localeKey`. Without locale persistence, the device language overrides the user's in-app choice on every app restart.
</Callout>
