Tutorials

React Native Localization with Expo — Ship Your App in 50+ Languages

Eray Gündoğmuş
Eray Gündoğmuş
·15 min read
Share
React Native Localization with Expo — Ship Your App in 50+ Languages

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:

PackagePurpose
expo-localizationDevice locale detection and calendar/number formatting preferences
i18nextTranslation runtime (key resolution, interpolation, pluralization)
react-i18nextReact bindings for i18next (hooks, components, context)
@better-i18n/react-nativeCDN 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:

  1. CDN fetch — Try to load translations from Better i18n CDN
  2. Offline cache — If CDN is unreachable, use cached translations from AsyncStorage
  3. 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:

  1. You update translations in the Better i18n platform (via AI Drawer, editor, or MCP)
  2. You publish the changes to CDN
  3. The next time the app's cache TTL expires and the user opens the app, fresh translations are fetched
  4. 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.