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:
formatdecides your key shape.json-nestedmapshome.titleto{ "home": { "title": … } };json-flatkeeps"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 Projects → Edit → Locales → Update 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-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 change on the project in the dashboard (Projects → Edit → Locales) 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.