Building a React Native App for 20+ Languages: Lessons in i18n

Published: (February 23, 2026 at 07:13 PM EST)
8 min read
Source: Dev.to

Source: Dev.to

Supporting more than twenty languages in a mobile app isn’t a checklist item – it’s a continuous engineering commitment that touches every layer of the stack: UI layout, typography, data storage, API design, and release workflows.

Below are the key lessons I learned while building a language‑learning app with extensive multilingual support.

The i18n Library Decision

For React Native the main options are:

LibraryHighlightsSize (gzipped)
i18next + react‑i18nextFull‑featured (namespaces, pluralisation, interpolation, language detection)~20 KB
react‑native‑localize + custom solutionLower‑level, more control; good for simple needs
expo‑localizationIdeal for Expo‑managed workflow; limited for bare RN

I chose i18next with react‑i18next. Namespace support is critical once your translation file exceeds ~200 keys – splitting by feature (onboarding, settings, lesson, error) keeps files manageable and enables lazy loading.

// i18n.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import { getLocales } from 'expo-localization';

i18n
  .use(initReactI18next)
  .init({
    resources: {
      en: { translation: require('./locales/en.json') },
      es: { translation: require('./locales/es.json') },
      // …
    },
    lng: getLocales()[0].languageCode ?? 'en',
    fallbackLng: 'en',
    interpolation: { escapeValue: false },
  });

Text Expansion: The Layout Killer

English is one of the most compact written languages. When you translate UI strings to German, Finnish, or Portuguese, buttons and labels can overflow.

Expansion factors (relative to English)

LanguageAvg. text expansion
German+25 % – 35 %
Finnish+25 % – 30 %
Portuguese+20 % – 30 %
Spanish+15 % – 25 %
French+15 % – 20 %
Japanese–15 % – 25 % (usually shorter)
Chinese–30 % – 40 %

Typical failure modes

  • Buttons wrap to two lines
  • Truncated navigation labels
  • Overflow in table cells
  • Clipped input placeholder text

How I mitigate the risk

  • Design with the worst‑case (German) text from the start.
  • Enforce a rule: every UI string must be tested with the German translation before the component is considered complete – German reliably produces the longest strings in Latin‑script languages.

Practical code examples

// ❌ Bad: fixed‑width button
<Button style={{ width: 120 }}>
  {t('save_button')}
</Button>
// ✅ Good: minimum width with flexible growth
<Button style={{ minWidth: 120, paddingHorizontal: 16 }}>
  {t('save_button')}
</Button>

adjustsFontSizeToFit with a reasonable minimumFontScale handles most overflow cases without breaking the layout. For buttons, prefer paddingHorizontal over a hard‑coded width.

Right‑to‑Left Layout: Not Optional for Arabic & Hebrew

Arabic (standard in North Africa & the Middle East) and Hebrew are right‑to‑left (RTL) scripts. Supporting them requires a global RTL flag, not per‑component tweaks.

import { I18nManager } from 'react-native';
import * as Updates from 'expo-updates';

async function activateRTL() {
  if (!I18nManager.isRTL) {
    I18nManager.forceRTL(true);
    await Updates.reloadAsync(); // app restart required
  }
}

Caveats

  • The RTL switch forces an app reload – you can’t toggle it on‑the‑fly.
  • Not all third‑party components respect the RTL flag; custom icons (chevrons, back arrows) need manual mirroring.
  • Numbers inside Arabic text remain left‑to‑right; mixed directionality may require explicit bidi control characters.
  • Always test on a physical device – RTL rendering in simulators has historically shown edge cases.

Font Support: The CJK Problem

React Native’s default font stack covers Latin, Cyrillic, and Greek well. For CJK (Chinese, Japanese, Korean) the system font is used:

  • iOS – reliable (PingFang SC/TC, Hiragino Sans)
  • Android – inconsistent; depends on manufacturer and OS version

In a language‑learning app, rendering quality directly impacts readability, so we need a guaranteed font.

Solutions

  1. Bundle a CJK font (e.g., Noto Sans CJK, Source Han Sans). Expect a 2–5 MB APK increase.
  2. For managed Expo apps, use @expo-google-fonts/noto-sans-sc (and equivalents).

Create a language‑aware Text wrapper that selects the appropriate font family automatically:

// components/LText.tsx
import { Text, TextProps } from 'react-native';
import { useLanguage } from '../hooks/useLanguage';

const FONT_MAP: Record<string, string> = {
  zh: 'NotoSansSC',
  ja: 'NotoSansJP',
  ko: 'NotoSansKR',
  ar: 'NotoSansArabic',
  default: 'System',
};

export function LText({ style, ...props }: TextProps) {
  const { language } = useLanguage();
  const fontFamily = FONT_MAP[language] ?? FONT_MAP.default;
  return <Text style={[{ fontFamily }, style]} {...props} />;
}

Now every piece of UI text automatically uses the correct font for the current language.

Pluralisation: Not Just “0, 1, many”

English has two plural forms (singular & plural). Many languages have more:

LanguageNumber of plural forms
Russian4 (one, few, many, other)
Arabic6
Polish4 (different rules from Russian)

i18next follows the CLDR (Common Locale Data Repository) rules and uses suffixes such as _zero, _one, _two, _few, _many, _other.

Example – Russian pluralisation

// locales/ru.json
{
  "items_count_one": "{{count}} элемент",
  "items_count_few": "{{count}} элемента",
  "items_count_many": "{{count}} элементов",
  "items_count_other": "{{count}} элемента"
}

Usage in code:

import { useTranslation } from 'react-i18next';

function ItemCounter({ count }: { count: number }) {
  const { t } = useTranslation();
  return <Text>{t('items_count', { count })}</Text>;
}

i18next automatically selects the correct key based on the count value and the current locale’s plural rules.

TL;DR Checklist

Item
i18n libraryUse i18next + react-i18next; organise translations by namespace.
LayoutDesign for German‑length strings; avoid fixed widths; use adjustsFontSizeToFit.
RTLCall I18nManager.forceRTL(true) + app reload; test on real devices.
FontsBundle a reliable CJK font (or use Expo Google Fonts); wrap Text to switch families.
PluralisationLeverage i18next’s CLDR‑based plural rules; provide all required keys per language.

By treating internationalisation as a first‑class concern from day 1, you avoid costly retro‑fits and deliver a polished experience to users worldwide.

Pluralisation in i18next

{
  "items_count_zero": "{{count}} предметов",
  "items_count_one": "{{count}} предмет",
  "items_count_few": "{{count}} предмета",
  "items_count_many": "{{count}} предметов",
  "items_count_other": "{{count}} предмета"
}

The traps

  • JavaScript’s Intl.PluralRules is your friend for runtime pluralisation outside i18next.
  • Don’t embed numbers in translated strings if you can avoid it. Let the UI compose the number and the pluralised noun separately.
  • Date and number formats are locale‑specific. Use Intl.DateTimeFormat and Intl.NumberFormat — never hard‑code separators.

Translation Workflow: The Human Problem

Technical i18n is the easy part. Managing translations for 20+ languages is an operational challenge.

The workflow that works at small scale

  1. Source strings only in English – never translate from a translation. The telephone‑game error accumulates.
  2. Automated key extractioni18next-parser scans your codebase and generates a keys‑only JSON for translators.
  3. Translation memory – tools like Weblate, Crowdin, or even a shared Google Sheet with a translation‑memory script save significant cost and improve consistency.
  4. Machine translation first‑pass + human review – DeepL for European languages, Google Cloud Translation for Asian languages. Human review catches idiom errors and context mismatches that MT misses.
  5. Screenshot context for translators – a string like “back” is ambiguous without seeing the UI. Tools like Crowdin’s in‑context editor or automated screenshot generation remove ambiguity.

The worst outcome is inconsistent terminology — using three different words for the same concept across screens because three different translators worked on three different screens without a glossary. Build a glossary early and enforce it.

Performance: Lazy‑Loading Locales

Bundling 20+ locale files adds up. At even 50 KB per language, 20 languages is ~1 MB of translation JSON loaded at startup — most of which the user never needs.

Lazy‑loading solution

i18n.use(initReactI18next).init({
  partialBundledLanguages: true,
  resources: {
    en: { translation: require('./locales/en.json') }, // bundle default
  },
  backend: {
    loadPath: `${FileSystem.documentDirectory}locales/{{lng}}/{{ns}}.json`,
  },
});

// On language change:
async function switchLanguage(lng: string) {
  await downloadLocaleIfNeeded(lng); // fetch from CDN, write to FileSystem
  await i18n.changeLanguage(lng);
}

Trade‑off – first‑launch latency on a language change. Accept a loading state the first time a non‑bundled language is selected; subsequent loads are instant from the filesystem cache.

The Testing Problem

Automated testing for i18n is under‑invested in most projects. A minimum‑viable approach:

  • Snapshot tests with each locale to catch layout regressions.
  • String‑length tests – assert no translated string exceeds a maximum length for UI‑critical strings.
  • RTL smoke test – a single E2E test that switches to Arabic and verifies the primary navigation flows don’t break.

Missing‑translation linting – a CI step that fails if any key present in the English locale is absent from other locales.

# CI step using i18next-parser output
for lang in es fr de ja zh ar ko; do
  node scripts/check-missing-keys.js --base en --target $lang
done

i18n debt compounds faster than most technical debt. Catching missing translations in CI rather than in production is worth the setup cost.

I’m building Pocket Linguist, an AI‑powered language tutor for iOS. It uses spaced repetition, camera translation, and conversational AI to help you reach conversational fluency faster. Try it free.

0 views
Back to Blog

Related posts

Read more »