DEV Community

Ahmed Mahmoud
Ahmed Mahmoud

Posted on

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

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

Supporting 20+ languages in a mobile app is not 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.

Here's what I learned building a language learning app with extensive multilingual support.

The i18n Library Decision

For React Native, the main options are:

  • i18next + react-i18next: Most full-featured. Supports namespaces, pluralisation, interpolation, language detection. ~20KB gzipped.
  • react-native-localize + custom solution: Lower-level, more control. Works well if your needs are simple.
  • expo-localization: Good for Expo-managed workflow apps, limited for bare React Native.

I chose i18next with react-i18next. The namespace support is critical when your translation file grows beyond 200 keys — splitting by feature (onboarding, settings, lesson, error) keeps files manageable and allows 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 },
});
Enter fullscreen mode Exit fullscreen mode

Text Expansion: The Layout Killer

English is one of the most compact written languages. When you translate UI strings to German, Finnish, or Portuguese, prepare for your buttons to overflow.

Expansion factors by target language (relative to English):

Language Avg 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%

The failure modes are predictable: buttons that wrap to two lines, truncated navigation labels, overflow in table cells, clipped input placeholder text.

The fix requires designing with worst-case text from the start. I enforce a rule: every string that appears in UI must be tested with the German translation before the component is considered complete. German reliably produces the longest strings in Latin-script languages.

Practical solutions:

// Bad: fixed width button
<TouchableOpacity style={{ width: 120 }}>
  <Text>{t('save_button')}</Text>
</TouchableOpacity>

// Good: minimum width with flexible growth
<TouchableOpacity style={{ minWidth: 120, paddingHorizontal: 16 }}>
  <Text numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.8}>
    {t('save_button')}
  </Text>
</TouchableOpacity>
Enter fullscreen mode Exit fullscreen mode

adjustsFontSizeToFit with a reasonable minimumFontScale handles most overflow cases without layout breakage. For buttons, prefer paddingHorizontal over fixed widths.

Right-to-Left Layout: Not Optional for Arabic and Hebrew

Arabic (standard in North Africa, the Middle East) and Hebrew are right-to-left scripts. If you're supporting them, you need RTL layout support — this is a global I18nManager.forceRTL(true) call that flips the entire layout, not individual components.

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
  }
}
Enter fullscreen mode Exit fullscreen mode

Caveats:

  • The RTL switch requires an app reload. Don't try to do it in-session.
  • Not all third-party components respect the RTL flag. Custom icons (chevrons, back arrows) need manual mirroring.
  • Numbers in Arabic text are still left-to-right. Mixed directionality in a single text run requires explicit bidi control characters.
  • Test on a physical device. RTL rendering in the simulator has historically had edge cases.

Font Support: The CJK Problem

React Native's default font stack handles Latin, Cyrillic, and Greek well. For CJK (Chinese, Japanese, Korean), you're relying on the system font — which is fine on iOS (PingFang SC/TC, Hiragino Sans) but inconsistent on Android where the system CJK font depends on the device manufacturer and Android version.

For a language learning app where rendering quality directly impacts the user's ability to read characters correctly, inconsistent CJK rendering is a real problem. The solution:

  1. Bundle a guaranteed CJK font (Noto Sans CJK, Source Han Sans). Accept the 2–5MB APK size increase.
  2. Use @expo-google-fonts/noto-sans-sc (and equivalents) for managed Expo apps.
  3. Apply the font to text components via a custom Text wrapper that automatically selects the correct font family based on the current language.
// components/LText.tsx — language-aware Text component
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} />;
}
Enter fullscreen mode Exit fullscreen mode

Pluralisation: Not Just "0, 1, many"

English has two plural forms: one (singular) and everything else (plural). Many languages have more. Russian has four forms (one, a few, many, other). Arabic has six. Polish has four with different rules than Russian.

i18next handles pluralisation through the _one, _other convention for simple cases, and _zero, _one, _two, _few, _many, _other for languages that require them. The CLDR (Common Locale Data Repository) defines the exact rules per language.

// locales/ru.json
{
  "items_count": "{{count}} предмет",
  "items_count_few": "{{count}} предмета",
  "items_count_many": "{{count}} предметов",
  "items_count_other": "{{count}} предмета"
}
Enter fullscreen mode Exit fullscreen mode

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 hardcode 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 extraction. i18next-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 50KB per language, 20 languages is 1MB 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);
}
Enter fullscreen mode Exit fullscreen mode

The tradeoff: 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:

  1. Snapshot tests with each locale to catch layout regressions.
  2. String length tests — assert no translated string exceeds a maximum length for UI-critical strings.
  3. RTL smoke test — a single E2E test that switches to Arabic and verifies the primary navigation flows don't break.
  4. 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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)