Table of Contents
Table of Contents
- Why Mobile i18n Is Different
- 1. Offline First
- 2. Device Locale
- 3. Bundle Size
- 4. App Store Metadata
- 5. RTL Support
- Project Setup
- 1. Device Locale Detection
- 2. Configure i18next
- 3. Translation Hook Usage
- 4. In-App Language Picker
- 5. RTL Layout Support
- RTL-Aware Styles
- 6. OTA Translation Updates
- 7. Date, Number, and Currency Formatting
- 8. Testing Localized Components
- 9. App Store Localization
- Production Checklist
- Conclusion
- Related Resources
Mobile apps live on the user's device, not on a server you control. That single difference makes localization in React Native fundamentally different from web localization. You need to handle offline access, device locale changes, app store metadata in multiple languages, and OTA translation updates — all while keeping your bundle size reasonable.
This guide walks you through localizing a React Native app with Expo, from device locale detection to production-ready OTA translation delivery. We will use expo-localization for device settings, i18next for the translation runtime, and Better i18n's CDN for translation delivery. For teams also building a web front end, our complete Next.js i18n guide for 2026 covers the equivalent web-side stack.
Why Mobile i18n Is Different
Before we dive into code, let us understand why mobile localization has unique challenges:
1. Offline First
Web apps can fetch translations on every request. Mobile apps cannot assume network availability. Your translations must be bundled with the app as a fallback, with OTA updates fetching fresh content when connectivity allows.
2. Device Locale
On the web, you detect locale from URLs, cookies, or Accept-Language headers. On mobile, the device has a system locale that the user sets in their phone's settings. Your app should respect this preference by default, with an optional in-app language picker.
3. Bundle Size
Web apps can code-split translations by locale and load them on demand. Mobile apps ship a single binary. Every language you bundle increases your app size. For 50+ languages, this can add megabytes to your download. This is one of the core challenges covered in our broader guide on mobile app localisation, which compares approaches across frameworks.
4. App Store Metadata
Google Play and the App Store require localized metadata (title, description, screenshots) for each supported language. This is a separate workflow from in-app translations. On Android, our Android localization guide covers the Play Store metadata workflow in depth.
5. RTL Support
Arabic, Hebrew, Persian, and Urdu require right-to-left (RTL) layout. React Native has built-in RTL support through I18nManager, but you need to handle it explicitly — layout flipping, text alignment, and directional icons.
Project Setup
Start with a fresh Expo project or add to an existing one:
npx create-expo-app my-localized-app
cd my-localized-app
Install the dependencies:
npx expo install expo-localization
npm install i18next react-i18next @better-i18n/react-native
What each package does:
| Package | Purpose |
|---|---|
expo-localization | Device locale detection and calendar/number formatting preferences |
i18next | Translation runtime (key resolution, interpolation, pluralization) |
react-i18next | React bindings for i18next (hooks, components, context) |
@better-i18n/react-native | CDN delivery, offline caching, and OTA updates for Better i18n |
1. Device Locale Detection
The first step is detecting the user's preferred language. Expo provides expo-localization for this:
// lib/i18n/locale.ts
import { getLocales, getCalendars } from "expo-localization";
export function getDeviceLocale(): string {
const locales = getLocales();
if (locales.length === 0) {
return "en";
}
// The first locale is the user's preferred language
const preferred = locales[0];
// Return the language code (e.g., "en", "fr", "tr")
// Use languageTag for full locale (e.g., "en-US", "fr-FR")
return preferred.languageCode ?? "en";
}
export function getDeviceRegion(): string | null {
const locales = getLocales();
return locales[0]?.regionCode ?? null;
}
export function isRTL(): boolean {
const locales = getLocales();
return locales[0]?.textDirection === "rtl";
}
Important: getLocales() returns an ordered array of the user's preferred languages. On iOS, this comes from Settings → General → Language & Region. On Android, it comes from Settings → System → Language. Always use the first element as the primary preference.
2. Configure i18next
Set up i18next with device locale detection and Better i18n CDN delivery:
// lib/i18n/config.ts
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { createBetterI18nBackend } from "@better-i18n/react-native";
import { getDeviceLocale } from "./locale";
// Bundled fallback translations (shipped with the app)
import en from "../../locales/en.json";
import es from "../../locales/es.json";
const fallbackResources = {
en: { translation: en },
es: { translation: es },
};
const betterI18nBackend = createBetterI18nBackend({
project: "acme/mobile-app",
cacheTTL: 60 * 60 * 1000, // Cache translations for 1 hour
offlineStorage: true, // Persist to AsyncStorage for offline use
});
i18n
.use(betterI18nBackend)
.use(initReactI18next)
.init({
lng: getDeviceLocale(),
fallbackLng: "en",
resources: fallbackResources, // Used when CDN is unreachable
interpolation: {
escapeValue: false, // React Native handles escaping
},
react: {
useSuspense: true,
},
});
export default i18n;
How the fallback chain works:
- CDN fetch — Try to load translations from Better i18n CDN
- Offline cache — If CDN is unreachable, use cached translations from AsyncStorage
- Bundled resources — If no cache exists, use the translations shipped with the app
This three-tier strategy ensures your app always has translations, even on first launch without internet.
3. Translation Hook Usage
Use the useTranslation hook in your components:
// screens/HomeScreen.tsx
import { View, Text, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
export function HomeScreen() {
const { t } = useTranslation();
return (
<View style={styles.container}>
<Text style={styles.title}>{t("home.title")}</Text>
<Text style={styles.subtitle}>
{t("home.welcome", { name: "Developer" })}
</Text>
<Text style={styles.count}>
{t("home.itemCount", { count: 5 })}
</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20 },
title: { fontSize: 24, fontWeight: "bold" },
subtitle: { fontSize: 16, marginTop: 8 },
count: { fontSize: 14, marginTop: 4, color: "#666" },
});
Translation file structure:
{
"home": {
"title": "Welcome",
"welcome": "Hello, {{name}}!",
"itemCount_one": "{{count}} item",
"itemCount_other": "{{count}} items"
}
}
Pluralization: i18next handles pluralization automatically. Use _one, _other, _few, _many suffixes for different plural forms. This is critical for languages like Arabic (6 plural forms), Polish (3 forms), and Russian (3 forms). For a full exploration of pluralization rules across languages, see our dedicated pluralization guide.
4. In-App Language Picker
While the app should default to the device locale, users often want to override it. Here is a language picker component:
// components/LanguagePicker.tsx
import { View, Text, TouchableOpacity, FlatList, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import AsyncStorage from "@react-native-async-storage/async-storage";
const LANGUAGES = [
{ code: "en", name: "English", nativeName: "English" },
{ code: "es", name: "Spanish", nativeName: "Español" },
{ code: "fr", name: "French", nativeName: "Français" },
{ code: "de", name: "German", nativeName: "Deutsch" },
{ code: "tr", name: "Turkish", nativeName: "Türkçe" },
{ code: "ja", name: "Japanese", nativeName: "日本語" },
{ code: "ko", name: "Korean", nativeName: "한국어" },
{ code: "ar", name: "Arabic", nativeName: "العربية" },
];
export function LanguagePicker() {
const { i18n } = useTranslation();
const handleLanguageChange = async (code: string) => {
await i18n.changeLanguage(code);
await AsyncStorage.setItem("userLanguage", code);
};
return (
<FlatList
data={LANGUAGES}
keyExtractor={(item) => item.code}
renderItem={({ item }) => (
<TouchableOpacity
style={[
styles.item,
item.code === i18n.language && styles.selected,
]}
onPress={() => handleLanguageChange(item.code)}
>
<Text style={styles.nativeName}>{item.nativeName}</Text>
<Text style={styles.name}>{item.name}</Text>
</TouchableOpacity>
)}
/>
);
}
const styles = StyleSheet.create({
item: {
flexDirection: "row",
justifyContent: "space-between",
padding: 16,
borderBottomWidth: 1,
borderBottomColor: "#eee",
},
selected: { backgroundColor: "#f0f7ff" },
nativeName: { fontSize: 16, fontWeight: "600" },
name: { fontSize: 14, color: "#666" },
});
Persistence: When the user manually selects a language, save it to AsyncStorage. On app launch, check for a saved preference before falling back to the device locale:
// In your i18n config initialization
const savedLanguage = await AsyncStorage.getItem("userLanguage");
const initialLanguage = savedLanguage || getDeviceLocale();
5. RTL Layout Support
For Arabic, Hebrew, and other RTL languages, React Native needs explicit configuration:
// lib/i18n/rtl.ts
import { I18nManager } from "react-native";
import * as Updates from "expo-updates";
const RTL_LANGUAGES = ["ar", "he", "fa", "ur"];
export async function handleRTL(languageCode: string) {
const shouldBeRTL = RTL_LANGUAGES.includes(languageCode);
const isCurrentlyRTL = I18nManager.isRTL;
if (shouldBeRTL !== isCurrentlyRTL) {
I18nManager.forceRTL(shouldBeRTL);
I18nManager.allowRTL(shouldBeRTL);
// RTL changes require an app restart to take effect
if (!__DEV__) {
await Updates.reloadAsync();
}
}
}
Critical note: I18nManager.forceRTL() does not take effect until the app restarts. In development, you can use hot reload, but in production you need to call Updates.reloadAsync() to apply the layout direction change.
RTL-Aware Styles
Use I18nManager.isRTL to conditionally flip styles:
import { I18nManager, StyleSheet } from "react-native";
const styles = StyleSheet.create({
row: {
flexDirection: I18nManager.isRTL ? "row-reverse" : "row",
},
icon: {
marginLeft: I18nManager.isRTL ? 0 : 8,
marginRight: I18nManager.isRTL ? 8 : 0,
},
});
For Expo SDK 50+, you can use the start and end properties instead of left and right:
const styles = StyleSheet.create({
container: {
paddingStart: 16, // Left in LTR, Right in RTL
paddingEnd: 8, // Right in LTR, Left in RTL
},
});
6. OTA Translation Updates
The killer feature: update translations without submitting a new app version to the store.
// lib/i18n/ota.ts
import { AppState } from "react-native";
import i18n from "./config";
export function setupOTAUpdates() {
// Check for new translations when the app comes to foreground
const subscription = AppState.addEventListener("change", (state) => {
if (state === "active") {
refreshTranslations();
}
});
return () => subscription.remove();
}
async function refreshTranslations() {
try {
// The Better i18n backend handles cache invalidation
// This triggers a fresh fetch if the cache TTL has expired
await i18n.reloadResources(i18n.language);
} catch (error) {
// Silent fail — the app continues with cached translations
console.warn("Translation refresh failed:", error);
}
}
How OTA works with Better i18n:
- You update translations in the Better i18n platform (via AI Drawer, editor, or MCP)
- You publish the changes to CDN
- The next time the app's cache TTL expires and the user opens the app, fresh translations are fetched
- The UI updates automatically through React's re-render cycle
No app store submission. No binary update. Translations go live in minutes. For a broader look at how OTA translation delivery fits into a cross-platform localization strategy, our guide on OTA translations for mobile and web covers the architecture decisions in depth.
7. Date, Number, and Currency Formatting
Use expo-localization for locale-aware formatting:
// lib/i18n/formatting.ts
import { getLocales } from "expo-localization";
export function formatNumber(value: number): string {
const locale = getLocales()[0]?.languageTag ?? "en-US";
return new Intl.NumberFormat(locale).format(value);
}
export function formatCurrency(value: number, currency: string = "USD"): string {
const locale = getLocales()[0]?.languageTag ?? "en-US";
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
}).format(value);
}
export function formatDate(date: Date, options?: Intl.DateTimeFormatOptions): string {
const locale = getLocales()[0]?.languageTag ?? "en-US";
return new Intl.DateTimeFormat(locale, options).format(date);
}
export function formatRelativeTime(date: Date): string {
const locale = getLocales()[0]?.languageTag ?? "en-US";
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
const diffMs = date.getTime() - Date.now();
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
if (Math.abs(diffDays) < 1) {
const diffHours = Math.round(diffMs / (1000 * 60 * 60));
return rtf.format(diffHours, "hour");
}
return rtf.format(diffDays, "day");
}
Note: React Native's Hermes engine supports Intl APIs since Hermes 0.12. If you are on an older version, add the intl-pluralrules and @formatjs/intl-numberformat polyfills.
8. Testing Localized Components
Test your components with different locales:
// __tests__/HomeScreen.test.tsx
import { render, screen } from "@testing-library/react-native";
import { I18nextProvider } from "react-i18next";
import i18n from "../lib/i18n/config";
import { HomeScreen } from "../screens/HomeScreen";
function renderWithI18n(component: React.ReactElement, locale: string = "en") {
i18n.changeLanguage(locale);
return render(
<I18nextProvider i18n={i18n}>{component}</I18nextProvider>
);
}
describe("HomeScreen", () => {
it("renders in English", () => {
renderWithI18n(<HomeScreen />, "en");
expect(screen.getByText("Welcome")).toBeTruthy();
});
it("renders in Spanish", () => {
renderWithI18n(<HomeScreen />, "es");
expect(screen.getByText("Bienvenido")).toBeTruthy();
});
it("handles pluralization", () => {
renderWithI18n(<HomeScreen />, "en");
expect(screen.getByText("5 items")).toBeTruthy();
});
});
9. App Store Localization
Your app store listing needs separate localization. Expo supports this through app.json:
{
"expo": {
"name": "My App",
"locales": {
"es": "./locales/store/es.json",
"fr": "./locales/store/fr.json",
"de": "./locales/store/de.json",
"tr": "./locales/store/tr.json",
"ja": "./locales/store/ja.json"
}
}
}
Each locale file contains the app store metadata:
{
"CFBundleDisplayName": "Mi Aplicación",
"NSLocationWhenInUseUsageDescription": "Necesitamos tu ubicación para mostrarte tiendas cercanas."
}
For iOS, this localizes the app name on the home screen and permission dialogs. For Android, you need to handle Play Store metadata through the Google Play Console or Fastlane.
Production Checklist
Before shipping your localized React Native app:
- Default locale detection works from device settings
- Fallback translations are bundled with the app binary
- Offline mode works with cached translations
- OTA updates fetch fresh translations on foreground
- RTL layout is tested with Arabic or Hebrew
- Pluralization works for all target languages
- Date/number formatting respects device locale
- Language picker persists user preference
- App store metadata is translated for all target markets
- Bundle size is reasonable (check with
npx expo export) - Performance — translations load without blocking the UI
Conclusion
Localizing a React Native app with Expo is more involved than web localization, but the tooling has matured significantly. With expo-localization for device preferences, i18next for the translation runtime, and Better i18n for CDN delivery and OTA updates, you can ship a production-ready multilingual app without the complexity of managing translation files by hand.
The key insight: bundle minimal translations, deliver the rest from CDN. This keeps your app size small while supporting 50+ languages through OTA updates. Your users get translations in their language, and you do not need to submit a new app version every time a translation changes.
For teams looking to extend the same principles to cross-platform Flutter apps, our Flutter intl localization guide walks through the equivalent setup for the Dart ecosystem.
Related Resources
- Next.js i18n Guide — Web-side i18n with Better i18n
- Developer-First Localization — Why developer-first platforms win
- Internationalization Testing — Automated testing strategies for multilingual apps
- Better i18n Documentation — Full platform documentation