Introduction

Launching in a single market limits your app’s potential. English-speaking markets represent only about 25% of global app revenue. By localizing your app, you access the remaining 75% - users in China, Japan, Germany, Brazil, and dozens of other markets waiting for apps that speak their language.

Internationalization (i18n) is the process of designing your app so it can be adapted for different locales without engineering changes. Localization (l10n) is the actual process of adapting content for specific locales.

This guide covers both. We start with architecture decisions that make localization possible, then walk through practical implementation for React Native and native platforms.

Understanding Locales

A locale is more than a language. It is a combination of language, region, and sometimes script:

  • en-US: English, United States
  • en-GB: English, United Kingdom
  • zh-Hans: Chinese, Simplified script
  • zh-Hant: Chinese, Traditional script
  • pt-BR: Portuguese, Brazil
  • pt-PT: Portuguese, Portugal

The same language can have significant differences across regions:

  • Date formats: 04/05/2026 (US) vs 05/04/2026 (UK)
  • Number formats: 1,234.56 (US) vs 1.234,56 (Germany)
  • Spelling: color (US) vs colour (UK)
  • Vocabulary: apartment (US) vs flat (UK)

Your app should respect these differences. A user in Germany expects German dates and numbers, even if they prefer reading English text.

Architecture for Internationalization

Separ

Architecture for Internationalization Infographic ate Content from Code

The first principle: never hardcode user-facing strings.

// Bad: Hardcoded strings
function WelcomeScreen() {
  return (
    <View>
      <Text>Welcome back!</Text>
      <Text>You have 5 new messages.</Text>
    </View>
  );
}

// Good: Externalized strings
function WelcomeScreen() {
  const { t } = useTranslation();
  const messageCount = 5;

  return (
    <View>
      <Text>{t('welcome.title')}</Text>
      <Text>{t('welcome.messages', { count: messageCount })}</Text>
    </View>
  );
}

Translation File Structure

Organize translation files by locale:

src/
  locales/
    en/
      common.json
      screens.json
      errors.json
    de/
      common.json
      screens.json
      errors.json
    ja/
      common.json
      screens.json
      errors.json

This structure allows:

  • Partial loading (load only needed namespaces)
  • Independent updates (translators work on separate files)
  • Easy addition of new locales

React Native i18n Setup

We recommend i18next with react-i18next for React Native:

npm install i18next react-i18next react-native-localize

Configure i18n:

// src/i18n/config.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import * as RNLocalize from 'react-native-localize';

import en from '../locales/en/common.json';
import de from '../locales/de/common.json';
import ja from '../locales/ja/common.json';

const resources = {
  en: { translation: en },
  de: { translation: de },
  ja: { translation: ja }
};

// Get device language
const getDeviceLanguage = () => {
  const locales = RNLocalize.getLocales();
  if (locales.length > 0) {
    const { languageCode } = locales[0];
    if (Object.keys(resources).includes(languageCode)) {
      return languageCode;
    }
  }
  return 'en';
};

i18n
  .use(initReactI18next)
  .init({
    resources,
    lng: getDeviceLanguage(),
    fallbackLng: 'en',
    interpolation: {
      escapeValue: false
    },
    react: {
      useSuspense: false
    }
  });

export default i18n;

Translation files:

// locales/en/common.json
{
  "welcome": {
    "title": "Welcome back!",
    "messages": "You have {{count}} new message.",
    "messages_plural": "You have {{count}} new messages."
  },
  "navigation": {
    "home": "Home",
    "profile": "Profile",
    "settings": "Settings"
  },
  "errors": {
    "network": "Unable to connect. Please check your internet connection.",
    "generic": "Something went wrong. Please try again."
  }
}
// locales/de/common.json
{
  "welcome": {
    "title": "Willkommen zurück!",
    "messages": "Sie haben {{count}} neue Nachricht.",
    "messages_plural": "Sie haben {{count}} neue Nachrichten."
  },
  "navigation": {
    "home": "Start",
    "profile": "Profil",
    "settings": "Einstellungen"
  },
  "errors": {
    "network": "Verbindung nicht möglich. Bitte überprüfen Sie Ihre Internetverbindung.",
    "generic": "Etwas ist schief gelaufen. Bitte versuchen Sie es erneut."
  }
}

Using Translations

// components/SettingsScreen.tsx
import React from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import { useTranslation } from 'react-i18next';

function SettingsScreen() {
  const { t, i18n } = useTranslation();

  const changeLanguage = (lng: string) => {
    i18n.changeLanguage(lng);
  };

  return (
    <View>
      <Text style={styles.title}>{t('navigation.settings')}</Text>

      <Text style={styles.sectionTitle}>{t('settings.language')}</Text>

      <TouchableOpacity onPress={() => changeLanguage('en')}>
        <Text>English</Text>
      </TouchableOpacity>

      <TouchableOpacity onPress={() => changeLanguage('de')}>
        <Text>Deutsch</Text>
      </TouchableOpacity>

      <TouchableOpacity onPress={() => changeLanguage('ja')}>
        <Text>日本語</Text>
      </TouchableOpacity>
    </View>
  );
}

Handling Pluralization

Different

languages have different plural rules. English has two forms (singular and plural), but other languages vary:

  • Chinese, Japanese, Korean: No plural forms
  • French: Two forms (0-1 and 2+)
  • Russian, Polish: Multiple forms based on last digits
  • Arabic: Six plural forms

i18next handles this with plural suffixes:

// English
{
  "items": "{{count}} item",
  "items_plural": "{{count}} items"
}

// Russian (needs _0, _1, _2 forms)
{
  "items_0": "{{count}} предмет",
  "items_1": "{{count}} предмета",
  "items_2": "{{count}} предметов"
}

Usage:

function Cart() {
  const { t } = useTranslation();
  const itemCount = 3;

  return (
    <Text>{t('items', { count: itemCount })}</Text>
  );
}

Date, Time, and Number Formatting

Use native locale formatting APIs rather than translating formats manually.

React Native with expo-localization

npm install expo-localization
import { getLocales, getCalendars } from 'expo-localization';

// Get device locale
const locales = getLocales();
const currentLocale = locales[0]?.languageTag ?? 'en-US';

// Format numbers
const formatNumber = (value: number): string => {
  return new Intl.NumberFormat(currentLocale).format(value);
};

// Format currency
const formatCurrency = (value: number, currency: string = 'USD'): string => {
  return new Intl.NumberFormat(currentLocale, {
    style: 'currency',
    currency
  }).format(value);
};

// Format dates
const formatDate = (date: Date, style: 'short' | 'medium' | 'long' = 'medium'): string => {
  return new Intl.DateTimeFormat(currentLocale, {
    dateStyle: style
  }).format(date);
};

// Format time
const formatTime = (date: Date, style: 'short' | 'medium' = 'short'): string => {
  return new Intl.DateTimeFormat(currentLocale, {
    timeStyle: style
  }).format(date);
};

// Format relative time
const formatRelativeTime = (date: Date): string => {
  const rtf = new Intl.RelativeTimeFormat(currentLocale, { numeric: 'auto' });
  const diff = date.getTime() - Date.now();
  const diffDays = Math.round(diff / (1000 * 60 * 60 * 24));

  if (Math.abs(diffDays) < 1) {
    const diffHours = Math.round(diff / (1000 * 60 * 60));
    return rtf.format(diffHours, 'hour');
  }
  return rtf.format(diffDays, 'day');
};

Create a Formatting Hook

// hooks/useLocale.ts
import { useMemo } from 'react';
import { getLocales } from 'expo-localization';

export function useLocale() {
  const locale = useMemo(() => {
    const locales = getLocales();
    return locales[0]?.languageTag ?? 'en-US';
  }, []);

  const formatters = useMemo(() => ({
    number: (value: number) =>
      new Intl.NumberFormat(locale).format(value),

    currency: (value: number, currency: string) =>
      new Intl.NumberFormat(locale, { style: 'currency', currency }).format(value),

    date: (date: Date | string, options?: Intl.DateTimeFormatOptions) =>
      new Intl.DateTimeFormat(locale, options).format(new Date(date)),

    percent: (value: number) =>
      new Intl.NumberFormat(locale, { style: 'percent' }).format(value),

    relativeTime: (value: number, unit: Intl.RelativeTimeFormatUnit) =>
      new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(value, unit)
  }), [locale]);

  return { locale, ...formatters };
}

// Usage
function ProductPrice({ price, currency }: { price: number; currency: string }) {
  const { currency: formatCurrency } = useLocale();
  return <Text>{formatCurrency(price, currency)}</Text>;
}

Right-to-Left (RTL) Support

Languages like Arabic, Hebrew, and Persian are written right-to-left. Your app must mirror its layout for these languages.

Enabling RTL in React Native

// App.tsx
import { I18nManager } from 'react-native';
import * as RNLocalize from 'react-native-localize';

// Check if current language is RTL
const isRTL = RNLocalize.getLocales()[0]?.isRTL ?? false;

// Enable RTL if needed
if (I18nManager.isRTL !== isRTL) {
  I18nManager.allowRTL(isRTL);
  I18nManager.forceRTL(isRTL);
  // App will need to restart for changes to take effect
}

RTL-Aware Styling

Use start/end instead of left/right:

import { StyleSheet, I18nManager } from 'react-native';

const styles = StyleSheet.create({
  container: {
    // Use start/end for RTL-aware alignment
    paddingStart: 16,
    paddingEnd: 16,
    // Use flexDirection with RTL awareness
    flexDirection: 'row' // Automatically mirrors in RTL
  },
  icon: {
    // Don't mirror icons that are RTL-aware
    transform: I18nManager.isRTL ? [{ scaleX: -1 }] : []
  },
  backButton: {
    // Back arrow should point right in RTL
    transform: I18nManager.isRTL ? [{ scaleX: -1 }] : []
  }
});

// Some icons should NOT be mirrored (e.g., logos, play buttons)
const styles2 = StyleSheet.create({
  logo: {
    // Keep logo orientation regardless of RTL
    transform: []
  }
});

Testing RTL Layouts

Force RTL for testing:

// In development, temporarily force RTL
import { I18nManager } from 'react-native';

if (__DEV__) {
  I18nManager.allowRTL(true);
  I18nManager.forceRTL(true);
}

Or use a debug setting:

function DevSettingsScreen() {
  const toggleRTL = async () => {
    I18nManager.forceRTL(!I18nManager.isRTL);
    // Requires app restart
    await RNRestart.Restart();
  };

  return (
    <TouchableOpacity onPress={toggleRTL}>
      <Text>Toggle RTL (Current: {I18nManager.isRTL ? 'RTL' : 'LTR'})</Text>
    </TouchableOpacity>
  );
}

Dynamic Content Translation

Server-Side Content

For content from your API, return localized versions:

// API response structure
interface LocalizedContent {
  title: {
    en: string;
    de: string;
    ja: string;
  };
  description: {
    en: string;
    de: string;
    ja: string;
  };
}

// Or let the server return the correct locale
interface Product {
  id: string;
  title: string; // Already localized based on Accept-Language header
  description: string;
  price: number;
  currency: string;
}

// API call with locale
async function fetchProducts(locale: string): Promise<Product[]> {
  const response = await fetch('/api/products', {
    headers: {
      'Accept-Language': locale
    }
  });
  return response.json();
}

Client-Side Selection

function useLocalizedContent<T extends Record<string, any>>(
  content: { [locale: string]: T },
  fallback: string = 'en'
): T {
  const { i18n } = useTranslation();
  const currentLang = i18n.language;

  return content[currentLang] ?? content[fallback];
}

// Usage
const notifications = {
  en: { title: 'New message', body: 'You have a new message' },
  de: { title: 'Neue Nachricht', body: 'Sie haben eine neue Nachricht' }
};

function NotificationBanner() {
  const content = useLocalizedContent(notifications);
  return <Text>{content.title}</Text>;
}

Translation Workflow

Extracting Strings

Use tools to extract translatable strings:

npm install --save-dev i18next-parser

Configure i18next-parser.config.js:

module.exports = {
  locales: ['en', 'de', 'ja'],
  output: 'src/locales/$LOCALE/$NAMESPACE.json',
  input: ['src/**/*.{ts,tsx}'],
  defaultNamespace: 'translation',
  keySeparator: '.',
  namespaceSeparator: ':',
  createOldCatalogs: false,
  useKeysAsDefaultValue: false,
  verbose: true
};

Run extraction:

npx i18next-parser

Translation Management Platforms

For production apps, use a translation management system:

Popular options:

  • Lokalise: Great API, good React Native support
  • Phrase: Enterprise features, extensive integrations
  • Crowdin: Good for open-source, community translations
  • POEditor: Simple, cost-effective

Integration example with Lokalise:

# Install CLI
npm install --save-dev @lokalise/node-api

# Download translations
npx lokalise2 file download \
  --token your_api_token \
  --project-id your_project_id \
  --format json \
  --dest src/locales

# Upload new strings
npx lokalise2 file upload \
  --token your_api_token \
  --project-id your_project_id \
  --file src/locales/en/translation.json \
  --lang-iso en

CI/CD Integration

Automate translation sync in your pipeline:

# .github/workflows/translations.yml
name: Sync Translations

on:
  push:
    branches: [main]
    paths:
      - 'src/locales/en/**'
  schedule:
    - cron: '0 6 * * *' # Daily at 6 AM

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Download latest translations
        env:
          LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
          LOKALISE_PROJECT: ${{ secrets.LOKALISE_PROJECT }}
        run: |
          npx lokalise2 file download \
            --token $LOKALISE_TOKEN \
            --project-id $LOKALISE_PROJECT \
            --format json \
            --dest src/locales

      - name: Commit updated translations
        uses: stefanzweifel/git-auto-commit-action@v5
        with:
          commit_message: 'chore: update translations'
          file_pattern: 'src/locales/**'

Testing Localization

Automated Tests

// __tests__/localization.test.tsx
import { render, screen } from '@testing-library/react-native';
import { I18nextProvider } from 'react-i18next';
import i18n from '../src/i18n/config';
import WelcomeScreen from '../src/screens/WelcomeScreen';

describe('Localization', () => {
  beforeEach(() => {
    i18n.changeLanguage('en');
  });

  it('renders English content by default', () => {
    render(
      <I18nextProvider i18n={i18n}>
        <WelcomeScreen />
      </I18nextProvider>
    );

    expect(screen.getByText('Welcome back!')).toBeTruthy();
  });

  it('renders German content when language is changed', async () => {
    await i18n.changeLanguage('de');

    render(
      <I18nextProvider i18n={i18n}>
        <WelcomeScreen />
      </I18nextProvider>
    );

    expect(screen.getByText('Willkommen zurück!')).toBeTruthy();
  });

  it('handles pluralization correctly', () => {
    render(
      <I18nextProvider i18n={i18n}>
        <MessageCount count={1} />
      </I18nextProvider>
    );

    expect(screen.getByText('You have 1 new message.')).toBeTruthy();
  });

  it('uses plural form for multiple items', () => {
    render(
      <I18nextProvider i18n={i18n}>
        <MessageCount count={5} />
      </I18nextProvider>
    );

    expect(screen.getByText('You have 5 new messages.')).toBeTruthy();
  });
});

Visual Regression Testing

Different languages affect layout due to:

  • Varying text lengths (German is often 30% longer than English)
  • Different character heights (Thai, Arabic)
  • Text direction (RTL languages)

Set up visual tests for key locales:

// e2e/localization.test.ts
describe('Visual Localization Tests', () => {
  const locales = ['en', 'de', 'ar', 'ja'];

  locales.forEach((locale) => {
    it(`renders correctly in ${locale}`, async () => {
      await device.launchApp({
        languageAndLocale: {
          language: locale,
          locale: locale
        }
      });

      await expect(element(by.id('home-screen'))).toBeVisible();
      await device.takeScreenshot(`home-screen-${locale}`);

      // Navigation to other screens
      await element(by.id('profile-tab')).tap();
      await device.takeScreenshot(`profile-screen-${locale}`);
    });
  });
});

Performance Considerations

Lazy Loading Translations

Load translation files on demand:

import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import HttpBackend from 'i18next-http-backend';

i18n
  .use(HttpBackend)
  .use(initReactI18next)
  .init({
    fallbackLng: 'en',
    backend: {
      loadPath: 'https://cdn.example.com/locales/{{lng}}/{{ns}}.json'
    },
    ns: ['common'],
    defaultNS: 'common',
    partialBundledLanguages: true,
    resources: {
      en: {
        common: require('./locales/en/common.json')
      }
    }
  });

Bundle Size

Include only the locales you support:

// metro.config.js
module.exports = {
  resolver: {
    resolverMainFields: ['react-native', 'browser', 'main'],
    // Only include supported locales from date-fns
    extraNodeModules: {
      'date-fns': require.resolve('date-fns')
    }
  },
  transformer: {
    getTransformOptions: async () => ({
      transform: {
        experimentalImportSupport: false,
        inlineRequires: true
      }
    })
  }
};

Best Practices Summary

  1. Externalize all strings: Never hardcode user-facing text
  2. Design for expansion: Allow 30% extra space for translated text
  3. Use ICU message format: Handle plurals and gender correctly
  4. Respect device locale: Default to system language
  5. Allow override: Let users choose their preferred language
  6. Test with pseudolocalization: Catch hardcoded strings early
  7. Automate translation workflow: Integrate with CI/CD
  8. Consider cultural context: Icons, colors, and imagery may need localization
  9. Handle bidirectional text: Support RTL languages properly
  10. Performance matters: Lazy load translations when possible

Conclusion

Internationalization is easier when built into your app from the start, but can be added to existing apps with planning. The key is separating content from code and using proper formatting APIs.

Start with your largest potential markets. English and Chinese together cover over 50% of global app revenue. Adding Spanish, Japanese, and German brings you to 75%.

The technical implementation is straightforward with modern tools like i18next. The larger challenge is establishing a translation workflow that keeps content current as your app evolves.

Plan for localization early, even if you are not ready to translate yet. Following these patterns makes future localization a matter of providing translations rather than re-architecting your app.


Expanding your app to global markets? Our team has localized apps for audiences across Asia, Europe, and the Americas. Contact us to discuss your internationalization needs.