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