Steven's Knowledge

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

Featurei18next + react-i18nextreact-intl (FormatJS)Lingui
Bundle size (minified)~40 kB (core + react bindings)~30 kB~5 kB (core)
TypeScript supportExcellent (module augmentation for key safety)Good (built-in types)Excellent (compiled catalog types)
Plural rulesICU via plugin, CLDR-compliantNative ICU MessageFormatICU MessageFormat
InterpolationBuilt-in {{variable}} syntaxICU {variable} syntaxICU or JS template syntax
Context (gender, etc.)Built-in (_male, _female suffixes)ICU selectICU select
Namespace supportFirst-class (split by feature/screen)No namespaces (flat message catalog)Catalogs per file
Lazy loadingBuilt-in backend pluginsManual (dynamic import)Built-in lazy catalogs
Ecosystem / plugins30+ plugins (backends, detectors, caches)Limited plugin surfaceSmaller ecosystem
Extraction toolingi18next-parser, scannerFormatJS CLI (formatjs extract)@lingui/cli extract
React Native supportMature, well-documentedWorks but web-centric docsGood, 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 detection

Configuration

// 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.json

Language 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 of t('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' namespace

Type-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 PropertyLogical Equivalent
paddingLeft / paddingRightpaddingStart / paddingEnd
marginLeft / marginRightmarginStart / marginEnd
borderLeftWidth / borderRightWidthborderStartWidth / 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:

LocaleCurrencyDateRelative
en$1,299.00January 15, 20265 days ago
zhUS$1,299.002026年1月15日5天前
ar١٬٢٩٩٫٠٠ US$١٥ يناير ٢٠٢٦قبل ٥ أيام
de1.299,00 $15. Januar 2026vor 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

PlatformStrengthsPricing Model
CrowdinGitHub/GitLab sync, in-context editor, MTFree for open source; per-seat for teams
LokaliseOTA updates, screenshot context, task managementPer-seat
Phrase (formerly Memsource)Enterprise TMS, branching, workflowsPer-seat, enterprise tiers
POEditorSimple, affordable, API-drivenPer-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-CN

Never 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.ts

Anti-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
EnglishSubmitbaseline
GermanAbsenden+33%
FrenchSoumettre+50%
RussianОтправить+50%
FinnishLä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.

On this page