Skip to content

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

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-US.json';
import esES from './es-ES.json';
import deDE from './de-DE.json';
import frFR from './fr-FR.json';

// Full BCP 47 codes — the CLI names each file after the project's locale code.
const SUPPORTED = ['en-US', 'es-ES', 'de-DE', 'fr-FR', 'ja-JP', 'pt-BR', 'zh-CN', 'ko-KR'];

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-US',
      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-US.json:

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

Step 4 — Configure Localingos

Run localingos init and answer its prompts. It writes two files.

localingos.config.json — commit this. Project settings shared with your team and CI:

{
  "projectId": "your-project-id",
  "sourceLocale": "en-US",
  "format": "json-nested",
  "sourceFile": "src/i18n/en-US.json",
  "outputDir": "src/i18n",
  "outputPattern": "{locale}.json"
}

.localingos.json — add this to .gitignore. Your API key for local development:

{ "apiKey": "your-api-key" }

In CI, set LOCALINGOS_API_KEY instead; it takes precedence over both files. init is interactive, so for containers, provisioning scripts or AI agents, write these two files yourself — the CLI reads nothing else.

Three things worth knowing:

  • format decides your key shape. json-nested maps home.title to { "home": { "title": … } }; json-flat keeps "home.title" as one top-level key. Those are the two supported values.
  • Target locales are not configured here. They belong to the project itself — set them in the dashboard under ProjectsEditLocalesUpdate Project. The CLI writes one file per target locale the project has, so you add a language without touching your repo. Codes are full BCP 47, e.g. es-ES, pt-BR, zh-TW.
  • Placeholder preservation is automatic. Localingos extracts placeholders from the source string — {{name}}, {name}, ${count}, %s, %d, ICU fragments — and validates each one survives translation, retrying with a corrective prompt when it doesn't. There is nothing to configure.

Then push your source strings and pull back translations:

localingos sync

Translation is asynchronous

The first sync of a new key pushes it and usually has nothing to pull back yet:

Push: 10 created, 0 updated, 0 deleted, 0 unchanged
✅ 0 translations received
⏳ 10 keys pending translation: home.title, home.subtitle, …
No new translations. Run "localingos sync" again later.

That's expected. Run localingos sync (or localingos pull) again shortly to collect results. Unchanged strings are never re-translated or re-billed.

Verify completeness before committing. pull writes whatever is ready and exits 0, so a file pulled mid-translation can be missing keys with no warning and no error — at runtime that surfaces as a silent fallback to your source language. Check key counts per locale before you commit, and gate on it in CI.

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
// Match the language subtag, since locales are full tags like he-IL.
const RTL_LOCALES = ['ar', 'he', 'fa', 'ur'];
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-US.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
      - 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 change on the project in the dashboard (ProjectsEditLocales) plus one import.

Free tier: 5,000 words, counted once per target locale — so a small mobile app in three or four languages, not a whole corpus in all 56. Which plan do I need? works it out for your own string count.