Localize a React Native app: complete guide with Localingos

React Native i18n looks like web React i18n with a couple of platform-specific wrinkles: detecting the device's preferred language, supporting OTA updates via Expo or CodePush, and dealing with iOS/Android RTL. This guide covers the full stack — i18next + react-i18next for the runtime (same API as web React), expo-localization for device locale, and Localingos for the translation pipeline.

Works for both bare React Native and Expo. We'll note Expo-specific shortcuts where they apply.

Step 1 — Install

npm install react-i18next i18next expo-localization
npm install -g @localingos/cli
localingos login

If you're on bare RN (not Expo), swap expo-localization for react-native-localize:

npm install react-native-localize
cd ios && pod install   # iOS only

Step 2 — Wire i18next

src/i18n/index.ts:

import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import * as Localization from 'expo-localization';
import AsyncStorage from '@react-native-async-storage/async-storage';

import en from './en.json';
import es from './es.json';
import de from './de.json';
import fr from './fr.json';

const SUPPORTED = ['en', 'es', 'de', 'fr', 'ja', 'pt-BR', 'zh-CN', 'ko'];

async function detectLocale(): Promise<string> {
  const saved = await AsyncStorage.getItem('locale');
  if (saved && SUPPORTED.includes(saved)) return saved;
  const device = Localization.getLocales()[0]?.languageCode || 'en';
  return SUPPORTED.includes(device) ? device : 'en';
}

export async function initI18n() {
  const lng = await detectLocale();
  await i18n
    .use(initReactI18next)
    .init({
      lng,
      fallbackLng: 'en',
      compatibilityJSON: 'v4',  // required for RN plural rules
      resources: {
        en: { translation: en },
        es: { translation: es },
        de: { translation: de },
        fr: { translation: fr },
      },
      interpolation: { escapeValue: false },
    });
}

export default i18n;

Two RN-specific things:

  • compatibilityJSON: 'v4' — required because RN's Intl polyfill historically lagged web; v4 mode handles plurals using CLDR rules without depending on Intl.PluralRules.
  • AsyncStorage + Localization — replaces the web's localStorage + navigator.language combo.

App.tsx:

import React, { useEffect, useState } from 'react';
import { initI18n } from './src/i18n';
import { ActivityIndicator } from 'react-native';

export default function App() {
  const [ready, setReady] = useState(false);
  useEffect(() => { initI18n().then(() => setReady(true)); }, []);
  if (!ready) return <ActivityIndicator />;
  return <YourApp />;
}

Step 3 — Source of truth

src/i18n/en.json:

{
  "welcome": "Welcome, {{name}}",
  "cart_one": "{{count}} item in cart",
  "cart_other": "{{count}} items in cart"
}

Step 4 — Configure Localingos

localingos.json:

{
  "projectId": "your-project-id",
  "source": { "path": "src/i18n/en.json", "locale": "en" },
  "targets": {
    "path": "src/i18n/{locale}.json",
    "locales": ["es", "de", "fr", "ja", "pt-BR", "zh-CN", "ko"]
  },
  "placeholders": ["{{variable}}"]
}
localingos sync

Step 5 — Use in components

import { useTranslation } from 'react-i18next';
import { View, Text } from 'react-native';

export const Welcome: React.FC<{ name: string; itemCount: number }> = ({ name, itemCount }) => {
  const { t } = useTranslation();
  return (
    <View>
      <Text>{t('welcome', { name })}</Text>
      <Text>{t('cart', { count: itemCount })}</Text>
    </View>
  );
};

Identical API to React on web. Components written for one platform usually work on the other with no string changes.

Step 6 — Language switcher

import { useTranslation } from 'react-i18next';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Pressable, Text, View } from 'react-native';

const LOCALES = [
  { code: 'en', label: 'English' },
  { code: 'es', label: 'Español' },
  { code: 'de', label: 'Deutsch' },
];

export const LocaleSwitcher = () => {
  const { i18n } = useTranslation();
  const change = async (code: string) => {
    await i18n.changeLanguage(code);
    await AsyncStorage.setItem('locale', code);
  };
  return (
    <View>
      {LOCALES.map(l => (
        <Pressable key={l.code} onPress={() => change(l.code)}>
          <Text style={{ color: i18n.language === l.code ? 'blue' : 'black' }}>{l.label}</Text>
        </Pressable>
      ))}
    </View>
  );
};

Step 7 — RTL support

React Native handles RTL via I18nManager. Forcing RTL requires an app restart on iOS (Android handles it dynamically):

import { I18nManager } from 'react-native';
import * as Updates from 'expo-updates';

async function setRtl(isRtl: boolean) {
  if (I18nManager.isRTL !== isRtl) {
    I18nManager.allowRTL(isRtl);
    I18nManager.forceRTL(isRtl);
    if (typeof Updates !== 'undefined') {
      await Updates.reloadAsync();  // Expo
    }
  }
}

// When switching to Arabic/Hebrew/Persian
const RTL_LOCALES = ['ar', 'he', 'fa'];
i18n.on('languageChanged', (lng) => setRtl(RTL_LOCALES.includes(lng)));

Use flexDirection: 'row' and let RN auto-flip to 'row-reverse' for RTL layouts. Use start/end margin instead of left/right.

Step 8 — OTA translation updates

This is the killer feature for mobile localization: ship a translation fix without an App Store / Play Store review.

With Expo:

# Push a JS-only update with new translations
eas update --branch production --message "i18n: fix Spanish typo"

Users get the new translations on next app launch — no review queue, no version bump.

For bare RN with CodePush, same idea — translations live in JS, so they ship via CodePush updates.

Step 9 — Automate sync in CI

# .github/workflows/i18n.yml
name: i18n-sync
on:
  push: { branches: [main], paths: ['src/i18n/en.json'] }
jobs:
  sync:
    runs-on: ubuntu-latest
    permissions: { contents: write, pull-requests: write }
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm install -g @localingos/cli
      - run: localingos sync
        env: { LOCALINGOS_API_KEY: '${{ secrets.LOCALINGOS_API_KEY }}' }
      - uses: peter-evans/create-pull-request@v6
        with:
          branch: i18n/auto-sync
          title: 'chore(i18n): sync translations'
          commit-message: 'chore(i18n): sync translations'

For Expo apps, optionally chain an eas update step on PR merge so translation fixes ship to users immediately:

      - run: eas update --branch production --message "i18n: auto-sync"
        if: github.event.pull_request.merged == true

Production checklist

  • iOS Info.plist CFBundleLocalizations. Must list every locale you ship — otherwise iOS won't show them in language settings.
  • Test on real devices. RTL bugs are common and don't always surface in simulator. Test Arabic on an iPhone + Android device.
  • Bundle size. If you ship 10+ locales, lazy-load with i18next-resources-to-backend instead of bundling all locales. Cuts JS bundle weight.
  • App store metadata localization. Translated UI is half the win; localized App Store description + screenshots is the other half. Both supported by App Store Connect / Play Console.

Wrap up

A React Native / Expo app with device locale detection, runtime switching, RTL support, ICU plurals via i18next v4, and OTA translation updates. Adding a locale is one entry in localingos.json plus one import.

Free tier — 5,000 words covers most mobile app string corpora end to end.