Internationalization
React Native i18n — i18next, react-intl, RTL support, dynamic locale, number/date formatting
Internationalization
Internationalization (i18n) in React Native is not a bolt-on concern — it is a structural decision that affects your component architecture, your build pipeline, and your layout system. Retrofitting i18n into a mature codebase means touching every screen, every string, and every hardcoded date or number format. Starting correctly costs hours; retrofitting costs weeks.
This page covers library selection, production-grade i18next configuration, translation file organization, RTL support, locale-aware formatting, and the workflows that keep translations in sync across teams and languages.
Library Selection
Comparison
| Feature | i18next + react-i18next | react-intl (FormatJS) | Lingui |
|---|---|---|---|
| Bundle size (minified) | ~40 kB (core + react bindings) | ~30 kB | ~5 kB (core) |
| TypeScript support | Excellent (module augmentation for key safety) | Good (built-in types) | Excellent (compiled catalog types) |
| Plural rules | ICU via plugin, CLDR-compliant | Native ICU MessageFormat | ICU MessageFormat |
| Interpolation | Built-in {{variable}} syntax | ICU {variable} syntax | ICU or JS template syntax |
| Context (gender, etc.) | Built-in (_male, _female suffixes) | ICU select | ICU select |
| Namespace support | First-class (split by feature/screen) | No namespaces (flat message catalog) | Catalogs per file |
| Lazy loading | Built-in backend plugins | Manual (dynamic import) | Built-in lazy catalogs |
| Ecosystem / plugins | 30+ plugins (backends, detectors, caches) | Limited plugin surface | Smaller ecosystem |
| Extraction tooling | i18next-parser, scanner | FormatJS CLI (formatjs extract) | @lingui/cli extract |
| React Native support | Mature, well-documented | Works but web-centric docs | Good, smaller RN community |
Recommendation
i18next is the recommended default for React Native projects. Its namespace system maps cleanly to feature-based architectures, its plugin ecosystem handles language detection and lazy loading without custom code, and its community is the largest. react-intl is a solid alternative if your team already uses FormatJS on the web and wants consistency. Lingui offers the smallest bundle but has a thinner React Native ecosystem.
Decision Tree
- If you need namespace-based splitting per feature or screen: i18next
- If your team is already invested in ICU MessageFormat and uses FormatJS on web: react-intl
- If bundle size is the primary constraint (small utility app): Lingui
- If you have no existing preference: i18next
Setup with i18next
Installation
npm install i18next react-i18next
npm install expo-localization # Expo: device locale detection
# or
npm install react-native-localize # bare RN: device locale detectionConfiguration
// src/i18n/i18n.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import { getLocales } from 'expo-localization';
import commonEn from './locales/en/common.json';
import commonZh from './locales/zh/common.json';
import authEn from './locales/en/auth.json';
import authZh from './locales/zh/auth.json';
import ordersEn from './locales/en/orders.json';
import ordersZh from './locales/zh/orders.json';
const deviceLocale = getLocales()[0]?.languageCode ?? 'en';
i18n.use(initReactI18next).init({
lng: deviceLocale,
fallbackLng: 'en',
defaultNS: 'common',
ns: ['common', 'auth', 'orders'],
resources: {
en: {
common: commonEn,
auth: authEn,
orders: ordersEn,
},
zh: {
common: commonZh,
auth: authZh,
orders: ordersZh,
},
},
interpolation: {
escapeValue: false, // React Native does not use innerHTML
},
react: {
useSuspense: false, // Suspense is not fully stable in RN
},
});
export default i18n;Set useSuspense: false in React Native. React Suspense support in React Native is not fully stable. Enabling it can cause blank screens during language loading. Use the ready flag from useTranslation instead if you need to handle loading states.
Namespace-Based Organization
Organize translations by feature domain, not by screen. Namespaces let you lazy-load translation bundles and keep files manageable as you scale to dozens of languages.
src/i18n/
i18n.ts # Configuration
locales/
en/
common.json # Shared strings (buttons, labels, errors)
auth.json # Login, registration, password reset
orders.json # Order list, detail, checkout
settings.json # User settings, preferences
zh/
common.json
auth.json
orders.json
settings.json
ar/
common.json
auth.json
orders.json
settings.jsonLanguage Detection
With expo-localization:
import { getLocales } from 'expo-localization';
const deviceLocale = getLocales()[0]?.languageCode ?? 'en';
// e.g., 'en', 'zh', 'ar', 'ja'With react-native-localize (bare React Native):
import { getLocales } from 'react-native-localize';
const deviceLocale = getLocales()[0]?.languageCode ?? 'en';Both libraries return the device locale list ordered by user preference. Use the first entry as the initial language, falling back to your default.
Lazy Loading Translations
For apps with many languages, bundling every translation file inflates the JS bundle. Load translations on demand:
// src/i18n/i18n.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import { getLocales } from 'expo-localization';
const loadResource = async (lng: string, ns: string) => {
// Dynamic import — only loads the requested language + namespace
const resource = await import(`./locales/${lng}/${ns}.json`);
return resource.default;
};
const languageDetector = {
type: 'languageDetector' as const,
detect: () => getLocales()[0]?.languageCode ?? 'en',
init: () => {},
cacheUserLanguage: () => {},
};
i18n
.use(languageDetector)
.use(initReactI18next)
.use({
type: 'backend' as const,
read(lng: string, ns: string, callback: (err: any, data: any) => void) {
loadResource(lng, ns)
.then((data) => callback(null, data))
.catch((err) => callback(err, null));
},
})
.init({
fallbackLng: 'en',
defaultNS: 'common',
ns: ['common'],
interpolation: { escapeValue: false },
react: { useSuspense: false },
});
export default i18n;Metro bundler requires known paths for dynamic imports. The dynamic import pattern with locale and namespace interpolation works when Metro can statically analyze the possible paths at build time. If you use a remote backend (fetching from a CDN), use the i18next-http-backend plugin instead.
Translation Files
JSON Namespace Structure
// src/i18n/locales/en/common.json
{
"app": {
"name": "MyApp"
},
"button": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"confirm": "Confirm",
"retry": "Try Again"
},
"error": {
"generic": "Something went wrong. Please try again.",
"network": "No internet connection.",
"timeout": "Request timed out."
},
"label": {
"loading": "Loading...",
"empty": "Nothing to show"
}
}Interpolation
Insert dynamic values into translated strings with double curly braces:
// src/i18n/locales/en/orders.json
{
"greeting": "Hello, {{name}}",
"orderSummary": "You have {{count}} items totaling {{total}}",
"lastUpdated": "Last updated: {{date}}"
}const { t } = useTranslation('orders');
// "Hello, Alice"
t('greeting', { name: user.name });
// "You have 3 items totaling $29.97"
t('orderSummary', { count: 3, total: formatCurrency(2997) });Plurals
i18next uses suffixed keys for pluralization. The suffix names follow CLDR plural categories:
// src/i18n/locales/en/orders.json
{
"itemCount_one": "{{count}} item",
"itemCount_other": "{{count}} items",
"cartStatus_zero": "Your cart is empty",
"cartStatus_one": "{{count}} item in your cart",
"cartStatus_other": "{{count}} items in your cart"
}// src/i18n/locales/ar/orders.json
{
"itemCount_zero": "{{count}} عنصر",
"itemCount_one": "عنصر واحد",
"itemCount_two": "عنصران",
"itemCount_few": "{{count}} عناصر",
"itemCount_many": "{{count}} عنصرًا",
"itemCount_other": "{{count}} عنصر"
}Arabic has six plural forms. i18next resolves the correct form automatically based on the count value and the active language's CLDR rules.
t('itemCount', { count: 0 }); // en: "0 items" ar: "0 عنصر"
t('itemCount', { count: 1 }); // en: "1 item" ar: "عنصر واحد"
t('itemCount', { count: 2 }); // en: "2 items" ar: "عنصران"
t('itemCount', { count: 5 }); // en: "5 items" ar: "5 عناصر"Always pass count as a number, not a string. i18next uses numeric comparison to select the plural form. Passing count: "5" instead of count: 5 can silently select the wrong plural category in some languages.
Context-Based Translations
Context suffixes handle variations like grammatical gender:
// src/i18n/locales/en/common.json
{
"welcomeBack": "Welcome back",
"welcomeBack_male": "Welcome back, sir",
"welcomeBack_female": "Welcome back, madam",
"friendInvite": "Invite a friend",
"friendInvite_male": "Invite him",
"friendInvite_female": "Invite her"
}t('welcomeBack', { context: user.gender }); // "Welcome back, sir"Context and plurals can be combined: key_male_one, key_male_other, key_female_one, key_female_other.
Nesting Translations
Reference other keys within a translation using $t():
{
"app": {
"name": "MyApp"
},
"welcomeMessage": "Welcome to $t(app.name)! You have {{count}} notifications.",
"welcomeMessage_one": "Welcome to $t(app.name)! You have {{count}} notification."
}Nesting keeps your app name consistent across all translations without duplicating it.
Default Values and Fallback Chains
// Inline default — used when the key is missing from translation files
t('newFeature.title', { defaultValue: 'New Feature' });
// Fallback chain — try keys in order until one resolves
t(['error.payment.declined', 'error.payment.generic', 'error.generic']);The fallback language chain is configured globally:
i18n.init({
fallbackLng: {
'zh-TW': ['zh', 'en'], // Traditional Chinese falls back to Simplified, then English
'pt-BR': ['pt', 'en'], // Brazilian Portuguese falls back to Portuguese, then English
default: ['en'],
},
});Using Translations in Components
useTranslation Hook
import { useTranslation } from 'react-i18next';
import { View, Text, Pressable } from 'react-native';
function OrderHeader({ orderCount }: { orderCount: number }) {
const { t } = useTranslation('orders');
return (
<View>
<Text accessibilityRole="header">{t('header.title')}</Text>
<Text>{t('header.subtitle', { count: orderCount })}</Text>
<Pressable onPress={handleRefresh}>
<Text>{t('common:button.retry')}</Text>
</Pressable>
</View>
);
}The t function accepts a namespace prefix (common:button.retry) to reference keys outside the component's default namespace.
Trans Component for Rich Text
When translated strings contain markup (bold, links, line breaks), use the Trans component to interpolate React elements:
import { Trans, useTranslation } from 'react-i18next';
import { Text, StyleSheet } from 'react-native';
function TermsNotice() {
const { t } = useTranslation();
return (
<Text style={styles.notice}>
<Trans
i18nKey="legal.termsNotice"
components={{
bold: <Text style={styles.bold} />,
link: (
<Text
style={styles.link}
accessibilityRole="link"
onPress={() => Linking.openURL('https://example.com/terms')}
/>
),
}}
/>
</Text>
);
}
const styles = StyleSheet.create({
notice: { fontSize: 14, color: '#666' },
bold: { fontWeight: '700' },
link: { color: '#007AFF', textDecorationLine: 'underline' },
});{
"legal": {
"termsNotice": "By continuing, you agree to our <bold>Terms of Service</bold> and <link>Privacy Policy</link>."
}
}Namespace Scoping
Load multiple namespaces per component when needed:
function CheckoutScreen() {
// Loads both 'orders' and 'common' namespaces
const { t, ready } = useTranslation(['orders', 'common']);
if (!ready) return <LoadingSpinner />;
return (
<View>
<Text>{t('orders:checkout.title')}</Text>
<Text>{t('common:button.confirm')}</Text>
</View>
);
}Type-Safe Keys with TypeScript
Augment the i18next module to get autocomplete and compile-time errors for invalid keys:
// src/i18n/i18next.d.ts
import 'i18next';
import type commonEn from './locales/en/common.json';
import type authEn from './locales/en/auth.json';
import type ordersEn from './locales/en/orders.json';
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'common';
resources: {
common: typeof commonEn;
auth: typeof authEn;
orders: typeof ordersEn;
};
}
}With this declaration, TypeScript will:
- Autocomplete namespace and key strings in
t()calls - Error on typos like
t('buton.save')instead oft('button.save') - Validate interpolation variables
const { t } = useTranslation('orders');
t('greeting', { name: 'Alice' }); // OK
t('greeting'); // TS error: missing required interpolation 'name'
t('nonexistent.key'); // TS error: key does not exist in 'orders' namespaceType-safe keys require i18next v23+ and react-i18next v13+. Earlier versions do not support the CustomTypeOptions interface. If you are stuck on older versions, use the @typescript-eslint rule approach to lint for invalid keys instead.
Dynamic Locale Switching
Changing Language at Runtime
import { useTranslation } from 'react-i18next';
import { View, Text, Pressable, StyleSheet, I18nManager } from 'react-native';
import { MMKV } from 'react-native-mmkv';
import RNRestart from 'react-native-restart';
const storage = new MMKV();
const LANG_KEY = 'user-language';
const LANGUAGES = [
{ code: 'en', label: 'English', rtl: false },
{ code: 'zh', label: '中文', rtl: false },
{ code: 'ar', label: 'العربية', rtl: true },
{ code: 'he', label: 'עברית', rtl: true },
] as const;
function LanguageSwitcher() {
const { i18n, t } = useTranslation();
const changeLanguage = async (langCode: string) => {
const lang = LANGUAGES.find((l) => l.code === langCode);
if (!lang) return;
// Persist preference
storage.set(LANG_KEY, langCode);
// Change language in i18next
await i18n.changeLanguage(langCode);
// Handle RTL change
const isCurrentlyRTL = I18nManager.isRTL;
if (lang.rtl !== isCurrentlyRTL) {
I18nManager.forceRTL(lang.rtl);
I18nManager.allowRTL(lang.rtl);
// RTL changes require an app restart on Android
RNRestart.restart();
}
};
return (
<View style={styles.container}>
<Text style={styles.heading}>{t('settings.language')}</Text>
{LANGUAGES.map((lang) => (
<Pressable
key={lang.code}
style={[
styles.option,
i18n.language === lang.code && styles.optionActive,
]}
onPress={() => changeLanguage(lang.code)}
accessibilityRole="radio"
accessibilityState={{ checked: i18n.language === lang.code }}
accessibilityLabel={lang.label}
>
<Text style={styles.optionText}>{lang.label}</Text>
</Pressable>
))}
</View>
);
}
const styles = StyleSheet.create({
container: { padding: 16, gap: 8 },
heading: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
option: {
padding: 12,
borderRadius: 8,
borderWidth: 1,
borderColor: '#E5E5EA',
},
optionActive: {
borderColor: '#007AFF',
backgroundColor: '#F0F7FF',
},
optionText: { fontSize: 16 },
});Persisting Preference
Load the persisted language before i18next initializes:
// src/i18n/i18n.ts
import { MMKV } from 'react-native-mmkv';
import { getLocales } from 'expo-localization';
const storage = new MMKV();
const LANG_KEY = 'user-language';
function getInitialLanguage(): string {
// 1. Check persisted preference
const saved = storage.getString(LANG_KEY);
if (saved) return saved;
// 2. Fall back to device locale
const deviceLocale = getLocales()[0]?.languageCode ?? 'en';
// 3. Verify we support this locale
const supported = ['en', 'zh', 'ar', 'he', 'ja'];
return supported.includes(deviceLocale) ? deviceLocale : 'en';
}
i18n.init({
lng: getInitialLanguage(),
// ...rest of config
});Use MMKV, not AsyncStorage, for language persistence. Language must be read synchronously before i18n.init() runs. AsyncStorage is async and would require a loading state before the app can render any text. MMKV is synchronous and reads in under 1ms.
Restarting vs Hot-Switching
For LTR-to-LTR language changes (English to Chinese), i18next hot-switches instantly — all useTranslation hooks re-render with the new language. No restart needed.
For LTR-to-RTL changes (English to Arabic), an app restart is required because I18nManager.forceRTL does not take effect until the native layout engine reinitializes. This is a React Native limitation, not an i18next limitation.
RTL (Right-to-Left) Support
I18nManager API
React Native provides I18nManager for RTL control:
import { I18nManager } from 'react-native';
I18nManager.isRTL; // boolean: current layout direction
I18nManager.forceRTL(true); // force RTL layout
I18nManager.allowRTL(true); // allow RTL (must be called before forceRTL)The Restart Problem
I18nManager.forceRTL sets a flag that the native layout engine reads on startup. Calling it at runtime does not flip the existing layout. On Android, the app must be fully restarted. On iOS, reloading the JS bundle is sometimes sufficient, but a full restart is more reliable.
import RNRestart from 'react-native-restart';
function applyRTL(isRTL: boolean) {
if (I18nManager.isRTL !== isRTL) {
I18nManager.allowRTL(isRTL);
I18nManager.forceRTL(isRTL);
RNRestart.restart();
}
}Expo users: use Updates.reloadAsync() instead of react-native-restart. It reloads the JS bundle without a full native restart, which is faster but may not cover all RTL layout edge cases. Test thoroughly on both platforms.
Style Flipping
React Native automatically flips flexDirection: 'row' in RTL mode. Use logical properties (Start/End) instead of physical properties (Left/Right):
import { StyleSheet, I18nManager } from 'react-native';
const styles = StyleSheet.create({
// Good: automatically flips in RTL
row: {
flexDirection: 'row', // becomes row-reverse in RTL
paddingStart: 16, // left in LTR, right in RTL
paddingEnd: 8,
marginStart: 12,
borderStartWidth: 2,
},
// Bad: hardcoded physical direction — breaks in RTL
rowBroken: {
flexDirection: 'row',
paddingLeft: 16, // always left, even in RTL
paddingRight: 8,
marginLeft: 12,
},
});| Physical Property | Logical Equivalent |
|---|---|
paddingLeft / paddingRight | paddingStart / paddingEnd |
marginLeft / marginRight | marginStart / marginEnd |
borderLeftWidth / borderRightWidth | borderStartWidth / borderEndWidth |
left / right (positioning) | start / end |
textAlign: 'left' | textAlign: 'left' (does not auto-flip; use 'auto') |
RTL-Safe Styles Example
import { View, Text, Image, StyleSheet, I18nManager } from 'react-native';
function ListItem({ title, subtitle, avatar }: Props) {
return (
<View style={styles.container}>
<Image
source={{ uri: avatar }}
style={styles.avatar}
accessibilityIgnoresInvertColors
/>
<View style={styles.content}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.subtitle}>{subtitle}</Text>
</View>
<Image
source={require('../assets/chevron-right.png')}
style={[
styles.chevron,
I18nManager.isRTL && styles.chevronRTL,
]}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row', // auto-flips in RTL
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
},
avatar: {
width: 48,
height: 48,
borderRadius: 24,
marginEnd: 12, // logical: right in LTR, left in RTL
},
content: {
flex: 1,
},
title: {
fontSize: 16,
fontWeight: '600',
textAlign: 'auto', // follows layout direction
},
subtitle: {
fontSize: 14,
color: '#666',
textAlign: 'auto',
},
chevron: {
width: 20,
height: 20,
marginStart: 8,
},
chevronRTL: {
transform: [{ scaleX: -1 }], // flip the chevron icon for RTL
},
});Common RTL Bugs
Absolute positioning. left and right do not auto-flip. Use start and end:
// Bad: popup always appears on the right
{ position: 'absolute', right: 16, top: 0 }
// Good: popup appears on the logical end
{ position: 'absolute', end: 16, top: 0 }Transform animations. translateX values must be negated in RTL:
const translateX = useSharedValue(0);
const openDrawer = () => {
translateX.value = withTiming(I18nManager.isRTL ? -250 : 250);
};Icons. Directional icons (arrows, chevrons, back buttons) must be mirrored in RTL. Non-directional icons (search, settings, home) must not be mirrored.
Text truncation. numberOfLines with textAlign: 'right' on long text can cause the ellipsis to appear on the wrong side. Use textAlign: 'auto' and writingDirection: 'auto'.
Testing RTL in Development
Force RTL without changing languages to test layout behavior in isolation:
// App.tsx — development only
if (__DEV__) {
// Uncomment to test RTL layout with English text
// I18nManager.forceRTL(true);
}On Android emulators, enable RTL via developer settings: Settings > Developer Options > Force RTL layout direction. This does not require an app restart.
Number, Date, and Currency Formatting
Intl API on Hermes
Hermes (React Native's default JS engine) supports Intl.NumberFormat, Intl.DateTimeFormat, and Intl.PluralRules since React Native 0.72+. No polyfills needed for modern Hermes.
import { useTranslation } from 'react-i18next';
function useLocaleFormatters() {
const { i18n } = useTranslation();
const locale = i18n.language;
return {
formatNumber(value: number): string {
return new Intl.NumberFormat(locale).format(value);
},
formatCurrency(cents: number, currency = 'USD'): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
}).format(cents / 100);
},
formatDate(date: Date | string, options?: Intl.DateTimeFormatOptions): string {
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
...options,
}).format(new Date(date));
},
formatRelativeTime(value: number, unit: Intl.RelativeTimeFormatUnit): string {
return new Intl.RelativeTimeFormat(locale, {
numeric: 'auto',
}).format(value, unit);
},
};
}Usage in Components
function OrderCard({ order }: { order: Order }) {
const { t } = useTranslation('orders');
const { formatCurrency, formatDate, formatRelativeTime } = useLocaleFormatters();
const daysSinceOrder = Math.floor(
(Date.now() - new Date(order.createdAt).getTime()) / 86400000,
);
return (
<View style={styles.card}>
<Text style={styles.title}>{t('orderNumber', { id: order.id })}</Text>
<Text style={styles.total}>{formatCurrency(order.totalCents, order.currency)}</Text>
<Text style={styles.date}>{formatDate(order.createdAt)}</Text>
<Text style={styles.relative}>
{formatRelativeTime(-daysSinceOrder, 'day')}
</Text>
</View>
);
}Output varies by locale:
| Locale | Currency | Date | Relative |
|---|---|---|---|
en | $1,299.00 | January 15, 2026 | 5 days ago |
zh | US$1,299.00 | 2026年1月15日 | 5天前 |
ar | ١٬٢٩٩٫٠٠ US$ | ١٥ يناير ٢٠٢٦ | قبل ٥ أيام |
de | 1.299,00 $ | 15. Januar 2026 | vor 5 Tagen |
Polyfills for Older Hermes Versions
If you must support React Native < 0.72 or an older Hermes build without Intl:
npm install @formatjs/intl-numberformat @formatjs/intl-datetimeformat @formatjs/intl-pluralrules @formatjs/intl-relativetimeformat// src/polyfills/intl.ts — import at app entry before anything else
import '@formatjs/intl-pluralrules/polyfill';
import '@formatjs/intl-pluralrules/locale-data/en';
import '@formatjs/intl-pluralrules/locale-data/zh';
import '@formatjs/intl-pluralrules/locale-data/ar';
import '@formatjs/intl-numberformat/polyfill';
import '@formatjs/intl-numberformat/locale-data/en';
import '@formatjs/intl-numberformat/locale-data/zh';
import '@formatjs/intl-numberformat/locale-data/ar';
import '@formatjs/intl-datetimeformat/polyfill';
import '@formatjs/intl-datetimeformat/add-golden-tz';
import '@formatjs/intl-datetimeformat/locale-data/en';
import '@formatjs/intl-datetimeformat/locale-data/zh';
import '@formatjs/intl-datetimeformat/locale-data/ar';
import '@formatjs/intl-relativetimeformat/polyfill';
import '@formatjs/intl-relativetimeformat/locale-data/en';
import '@formatjs/intl-relativetimeformat/locale-data/zh';
import '@formatjs/intl-relativetimeformat/locale-data/ar';Each locale-data import adds to your bundle. Only import locale data for languages your app actually supports. Importing all locale data from @formatjs/intl-numberformat adds over 900 kB to your bundle.
Percentage and Compact Notation
// Percentage
new Intl.NumberFormat('en', { style: 'percent' }).format(0.156);
// "16%"
// Compact notation (1.2K, 3.4M)
new Intl.NumberFormat('en', { notation: 'compact' }).format(1234567);
// "1.2M"
new Intl.NumberFormat('zh', { notation: 'compact' }).format(1234567);
// "123万"Translation Workflow
Translation Management Platforms
| Platform | Strengths | Pricing Model |
|---|---|---|
| Crowdin | GitHub/GitLab sync, in-context editor, MT | Free for open source; per-seat for teams |
| Lokalise | OTA updates, screenshot context, task management | Per-seat |
| Phrase (formerly Memsource) | Enterprise TMS, branching, workflows | Per-seat, enterprise tiers |
| POEditor | Simple, affordable, API-driven | Per-project |
All four support JSON export in the flat or nested format i18next expects. Crowdin and Lokalise offer the best React Native DX with first-party i18next integration.
CI Integration
Push source strings and pull translations as part of your CI pipeline:
# .github/workflows/i18n-sync.yml
name: Sync Translations
on:
push:
branches: [main]
paths:
- 'src/i18n/locales/en/**'
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Upload source strings to Crowdin
uses: crowdin/github-action@v2
with:
upload_sources: true
download_translations: false
project_id: ${{ secrets.CROWDIN_PROJECT_ID }}
token: ${{ secrets.CROWDIN_TOKEN }}
pull:
runs-on: ubuntu-latest
if: github.event_name == 'schedule'
steps:
- uses: actions/checkout@v4
- name: Download translations from Crowdin
uses: crowdin/github-action@v2
with:
upload_sources: false
download_translations: true
create_pull_request: true
project_id: ${{ secrets.CROWDIN_PROJECT_ID }}
token: ${{ secrets.CROWDIN_TOKEN }}Handling Missing Translations
Configure i18next to report missing keys in development and fall back gracefully in production:
i18n.init({
saveMissing: __DEV__,
missingKeyHandler: (lngs, ns, key, fallbackValue) => {
if (__DEV__) {
console.warn(`[i18n] Missing key: ${ns}:${key} for languages: ${lngs.join(', ')}`);
}
},
// Show the key itself as fallback (useful for spotting missing translations in QA)
parseMissingKeyHandler: (key) => {
if (__DEV__) return `@@${key}@@`; // visually obvious in dev
return key; // graceful fallback in production
},
});Context for Translators
Provide descriptions alongside your translation keys so translators understand where and how strings are used:
// src/i18n/locales/en/orders.json
{
"checkout": {
"title": "Checkout",
"title_description": "Page heading shown at the top of the checkout screen",
"confirmButton": "Place Order",
"confirmButton_description": "Primary CTA button at the bottom of the checkout form; triggers payment",
"itemCount_one": "{{count}} item",
"itemCount_other": "{{count}} items",
"itemCount_description": "Shown in the cart summary; count is the number of distinct products"
}
}Crowdin and Lokalise both support _description suffixed keys or separate metadata files. Configure your TMS to display these as translator notes.
Machine Translation as Starting Point
Use machine translation (DeepL, Google Translate) for initial drafts, then route through human review. This works well for languages where you have no in-house speaker but need to ship quickly.
# Example: use Crowdin's MT pre-translation via CLI
crowdin pre-translate --method mt --engine-id deepl --language-id zh-CNNever ship machine-translated strings without human review for user-facing content. MT handles straightforward UI strings well but struggles with context-dependent phrasing, humor, legal text, and culturally sensitive content. Always flag MT-generated strings for review in your TMS.
Testing i18n
Wrapping Components with I18nextProvider
Create a test utility that provides the i18n context:
// test/i18nTestUtils.tsx
import i18n from 'i18next';
import { I18nextProvider, initReactI18next } from 'react-i18next';
import { render, type RenderOptions } from '@testing-library/react-native';
const testI18n = i18n.createInstance();
testI18n.use(initReactI18next).init({
lng: 'en',
fallbackLng: 'en',
ns: ['common', 'orders'],
defaultNS: 'common',
resources: {
en: {
common: require('../src/i18n/locales/en/common.json'),
orders: require('../src/i18n/locales/en/orders.json'),
},
},
interpolation: { escapeValue: false },
});
export function renderWithI18n(
ui: React.ReactElement,
options?: RenderOptions,
) {
return render(
<I18nextProvider i18n={testI18n}>{ui}</I18nextProvider>,
options,
);
}
export { testI18n };// features/orders/screens/__tests__/OrderList.test.tsx
import { renderWithI18n, testI18n } from '../../../../test/i18nTestUtils';
import { OrderList } from '../OrderList';
describe('OrderList', () => {
it('renders translated header', () => {
renderWithI18n(<OrderList orders={[]} />);
expect(screen.getByText('Your Orders')).toBeTruthy();
});
});Testing Locale Switching
import { act } from '@testing-library/react-native';
import { renderWithI18n, testI18n } from '../../../../test/i18nTestUtils';
// Add Chinese translations to the test instance
testI18n.addResourceBundle('zh', 'orders', {
'header': { 'title': '您的订单' },
});
describe('OrderList locale switching', () => {
afterEach(() => {
act(() => { testI18n.changeLanguage('en'); });
});
it('switches to Chinese', () => {
renderWithI18n(<OrderList orders={[]} />);
expect(screen.getByText('Your Orders')).toBeTruthy();
act(() => { testI18n.changeLanguage('zh'); });
expect(screen.getByText('您的订单')).toBeTruthy();
});
});Snapshot Testing Across Locales
Run snapshot tests across all supported locales to catch layout issues from text length variations:
const LOCALES = ['en', 'zh', 'ar', 'de', 'ja'] as const;
describe.each(LOCALES)('OrderCard in %s locale', (locale) => {
beforeEach(() => {
act(() => { testI18n.changeLanguage(locale); });
});
it('renders without layout overflow', () => {
const tree = renderWithI18n(
<OrderCard order={mockOrder} />,
);
expect(tree.toJSON()).toMatchSnapshot();
});
});German and Finnish produce the longest strings in European languages. If your layout survives German translations (often 30-40% longer than English), it will likely survive all European languages. Arabic and Hebrew test RTL layout. Japanese and Chinese test CJK line breaking.
CI Validation for Missing Translation Keys
Add a script that compares keys across all locale files and fails CI on missing translations:
// scripts/check-translations.ts
import fs from 'fs';
import path from 'path';
const LOCALES_DIR = path.join(__dirname, '../src/i18n/locales');
const SOURCE_LANG = 'en';
function getKeys(obj: Record<string, any>, prefix = ''): string[] {
return Object.entries(obj).flatMap(([key, value]) => {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'object' && value !== null) {
return getKeys(value, fullKey);
}
return [fullKey];
});
}
const sourceLangs = fs.readdirSync(LOCALES_DIR);
const namespaces = fs.readdirSync(path.join(LOCALES_DIR, SOURCE_LANG))
.filter((f) => f.endsWith('.json'))
.map((f) => f.replace('.json', ''));
let hasErrors = false;
for (const ns of namespaces) {
const sourceFile = path.join(LOCALES_DIR, SOURCE_LANG, `${ns}.json`);
const sourceKeys = getKeys(JSON.parse(fs.readFileSync(sourceFile, 'utf-8')));
for (const lang of sourceLangs) {
if (lang === SOURCE_LANG) continue;
const targetFile = path.join(LOCALES_DIR, lang, `${ns}.json`);
if (!fs.existsSync(targetFile)) {
console.error(`MISSING FILE: ${lang}/${ns}.json`);
hasErrors = true;
continue;
}
const targetKeys = getKeys(JSON.parse(fs.readFileSync(targetFile, 'utf-8')));
const missing = sourceKeys.filter((k) => !targetKeys.includes(k));
if (missing.length > 0) {
console.error(`[${lang}/${ns}] Missing ${missing.length} keys: ${missing.join(', ')}`);
hasErrors = true;
}
}
}
if (hasErrors) {
process.exit(1);
}
console.log('All translation keys are present.');# In CI pipeline
- name: Check translations
run: npx ts-node scripts/check-translations.tsAnti-Patterns
Hardcoded Strings in Components
// Bad: impossible to translate, invisible to extraction tools
<Text>Your order has been confirmed</Text>
<Pressable><Text>Submit</Text></Pressable>
// Good: all user-facing strings go through t()
<Text>{t('order.confirmed')}</Text>
<Pressable><Text>{t('common:button.submit')}</Text></Pressable>Every hardcoded string is a translation you will miss. Use eslint-plugin-i18next to lint for raw string literals in JSX.
String Concatenation for Translated Text
// Bad: word order varies by language; this breaks in German, Japanese, Arabic
const message = t('hello') + ' ' + user.name + ', ' + t('welcomeBack');
// Good: use interpolation — translators control word order
// en: "Hello {{name}}, welcome back!"
// ja: "{{name}}さん、おかえりなさい!"
const message = t('greeting', { name: user.name });String concatenation assumes English word order. Interpolation lets translators rearrange the sentence structure for their language.
Assuming Text Length
// Bad: fixed-width container clips German and Finnish translations
<View style={{ width: 80 }}>
<Text numberOfLines={1}>{t('button.submit')}</Text>
</View>
// Good: flexible layout that accommodates longer strings
<View style={{ minWidth: 80, paddingHorizontal: 12 }}>
<Text numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.8}>
{t('button.submit')}
</Text>
</View>| Language | "Submit" | Length vs English |
|---|---|---|
| English | Submit | baseline |
| German | Absenden | +33% |
| French | Soumettre | +50% |
| Russian | Отправить | +50% |
| Finnish | Lähettää | +33% |
| Arabic | إرسال | -17% |
| Chinese | 提交 | -67% |
Design for the longest translation (typically German or Finnish), not the English string.
Hardcoded Date and Number Formats
// Bad: US-centric, broken for every other locale
const formatted = `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`;
const price = `$${(cents / 100).toFixed(2)}`;
// Good: locale-aware formatting
const formatted = new Intl.DateTimeFormat(i18n.language).format(date);
const price = new Intl.NumberFormat(i18n.language, {
style: 'currency',
currency: order.currency,
}).format(cents / 100);The date 01/02/2026 means January 2nd in the US, February 1st in most of Europe, and 2026年1月2日 in Japan. Never format dates or numbers manually.
Using I18nManager.forceRTL Without Restart Handling
// Bad: layout is corrupted — forceRTL took effect but views are not re-laid-out
I18nManager.forceRTL(true);
// user sees a broken half-RTL, half-LTR screen
// Good: restart the app after changing RTL direction
I18nManager.forceRTL(true);
I18nManager.allowRTL(true);
RNRestart.restart();Always pair forceRTL with an app restart. Show a brief toast or alert informing the user that the app will restart to apply the new language direction.