+1 (415) 943-4271

Shipping in More Than One Language: i18n, RTL, and Locale-Aware Formatting in React Native

Most React Native apps we inherit were built English-first, in a single left-to-right layout, with dates and currencies formatted by hand. Then a client signs an enterprise deal in Riyadh, or marketing decides Latin America is next quarter, and localisation turns into a three-month retrofit that touches every screen.

It does not have to. Localisation is cheap if you design for it and expensive if you bolt it on. Here is the stack we use in 2026, the traps that actually bite, and what "RTL support" really costs once Android edge-to-edge and Reanimated are in the picture.

Pick the translation runtime first

Three choices dominate:

  • i18next + react-i18next — the safe default. Huge ecosystem, pluralisation via Intl.PluralRules, interpolation, namespaces, lazy-loaded bundles, and a mature story for loading translations over the air.
  • Lingui — compile-time message extraction with macros. Nicer ergonomics (you write real sentences in JSX, not keys), smaller runtime, excellent for teams that hate maintaining key files.
  • FormatJS / react-intl — if your web app already uses it and you want one message format across platforms, sharing ICU MessageFormat catalogues is a real win.

Whatever you choose, keep messages in ICU MessageFormat. Plural and select rules are not something to reinvent, and every translation vendor understands ICU.

// i18next
t('cart.items', { count })
// en: "{count, plural, one {# item} other {# items}}"
// pl: "{count, plural, one {# produkt} few {# produkty} many {# produktów} other {# produktu}}"

Polish has four plural categories. Arabic has six. A hand-rolled count === 1 ? a : b is wrong in most of the world.

Locale detection and the Intl polyfill question

expo-localization (or react-native-localize in bare apps) gives you the device locales, region, calendar, measurement system and 24-hour preference. Use the list, not the first entry, and resolve against the languages you actually ship:

import { getLocales } from 'expo-localization';

const supported = ['en', 'de', 'fr', 'ar', 'ja'];
const best = getLocales()
  .map(l => l.languageCode)
  .find(code => supported.includes(code)) ?? 'en';

On Hermes, Intl is present but historically partial. Check what your minimum RN/Hermes version actually implements for Intl.NumberFormat with currency display, Intl.RelativeTimeFormat, Intl.ListFormat and Intl.Segmenter before you rely on them; on older targets, formatjs polyfills plus locale data are the standard fallback, at a real bundle-size cost. Load only the locales you ship.

Never format money, dates or numbers by hand. Intl.NumberFormat(locale, { style: 'currency', currency }) and Intl.DateTimeFormat already know that Germany writes 1.234,56 € and that Japan does not use decimal places for yen.

Per-app language override

Users do not always want the OS language. Both platforms now support per-app language settings — iOS has had it for years, Android since 13 via LocaleManager and a locales_config.xml resource. Expo exposes this through the localization config plugin; in bare projects you add the resource and manifest entry yourself. Wire your in-app language picker to the system API rather than only to your own AsyncStorage key, so the OS settings screen and your app agree.

Whichever mechanism you use, changing language must re-render the tree. i18next's changeLanguage does this; if you cache formatted strings in module scope or in a memo keyed on nothing, half your UI will keep the old language until restart.

RTL: the part people underestimate

Arabic, Hebrew, Farsi and Urdu flip the layout, not just the text.

import { I18nManager } from 'react-native';

I18nManager.allowRTL(true);
I18nManager.forceRTL(isRtlLocale);

forceRTL on the old architecture required an app restart to take effect. On the New Architecture, layout direction is better handled per-tree, and modern RN lets you set direction on a view subtree — but treat a full-app language switch as a restart-level event anyway (expo-updates's reloadAsync or a native restart module) unless you have tested every screen mid-flip.

The mechanical work:

  • Use logical styles everywhere. marginStart / marginEnd / paddingStart / paddingEnd / start / end instead of left/right. Yoga flips these automatically. left: 12 does not flip.
  • textAlign: 'left' is a bug. Use 'start'/'end', or leave it unset and let the writing direction decide.
  • Flip directional icons (back chevrons, send arrows, progress indicators) with transform: [{ scaleX: I18nManager.isRTL ? -1 : 1 }]. Do not flip logos, media controls or clocks.
  • Animations flip too. A Reanimated slide-in from translateX: 300 slides in from the wrong side under RTL. Multiply your X offsets by a direction constant, and check gesture handlers: swipe-to-delete and drawer edges both mirror.
  • Mixed content. Latin product names, phone numbers and code snippets inside Arabic paragraphs need bidi isolation. Wrapping user-generated strings in Unicode isolate characters (\u2068\u2069) prevents the classic mangled punctuation.
  • Numerals. Some Arabic locales expect Eastern Arabic-Indic digits, some Western. Intl.NumberFormat with the right numberingSystem decides for you; ask the client, do not guess.

Text layout and typography

Translated strings are longer. German runs 30–40% longer than English; Finnish worse. Set numberOfLines deliberately, allow wrapping, and stop pinning button widths to the English string. Pair this with dynamic type: a long German label at the largest accessibility font size is the worst case that breaks your layout, and it is a real user configuration.

Also check your fonts. Many bundled brand fonts have no Arabic, Cyrillic, Thai or CJK coverage, so the system silently falls back to something that does not match — often with different metrics and line heights. Either ship a font family with the ranges you need or define per-script font stacks.

Making translation a pipeline, not a chore

The thing that keeps localisation healthy is automation:

  1. Extract keys from source in CI (Lingui's extractor, i18next-parser) and fail the build on missing or unused keys.
  2. Sync with a TMS — Crowdin, Lokalise, Phrase. Push on merge to main, pull translations as a PR.
  3. Type-check your keys. i18next with resources typed from your English catalogue turns a typo'd key into a compile error instead of a visible cart.item_s in production.
  4. Ship translations over the air where it makes sense. Catalogues are just JSON; an EAS Update or a CDN fetch with a cached fallback lets you fix a bad string without a store review.
  5. Localise the store listing too. App Store and Play metadata, screenshots and what's-new text are separate from the app bundle and are usually what marketing forgets.

Testing that RTL actually works

  • A pseudo-locale build ([!!Ĥéļļö Wöŕļđ!!]) surfaces hard-coded strings and truncation before any translator is involved.
  • Run your Maestro suite with the app forced into Arabic. Flow-level RTL coverage catches flipped gestures and off-screen buttons that unit tests never see.
  • Snapshot a handful of key screens in LTR and RTL at the largest font size. It is the cheapest regression net available.
  • Add a lint rule banning left/right style keys and raw textAlign: 'left' in new code.

A realistic schedule

For a mid-sized app being localised for the first time:

  • Week 1 — runtime choice, provider wiring, locale detection, per-app language setting, string extraction of the existing UI. This is the bulk of the mechanical work and it is mostly search-and-replace.
  • Week 2 — formatting pass (money, dates, numbers, relative times), font coverage, layout fixes for longer strings.
  • Week 3 — RTL pass if you need it: logical styles, icon flipping, animation and gesture direction, bidi isolation, plus a restart-safe language switch.
  • Ongoing — TMS pipeline, pseudo-locale build in CI, RTL smoke run.

Teams that do this before the first international deal spend roughly three weeks. Teams that do it after signing one spend the same three weeks under a contractual deadline, with a designer and a QA lead attached, which is why it feels like a quarter.

Checklist

  • ICU MessageFormat catalogues; no hand-rolled plurals
  • Locale resolved from the device locale list against shipped languages
  • Intl capability verified on your minimum Hermes version; polyfills scoped to shipped locales
  • All money/date/number formatting through Intl
  • Per-app language setting wired to the OS on both platforms
  • Logical start/end styles; no left/right in layout code
  • Directional icons, animations and gestures mirrored under RTL
  • Bidi isolation for mixed-script user content
  • Fonts cover every script you ship
  • Extraction, TMS sync and key typing automated in CI
  • Pseudo-locale and forced-RTL runs in the test suite
  • Store listings, screenshots and push notification copy localised

If you are planning a market launch and want an audit of how far your React Native codebase is from being localisable — or want an engineer to run the i18n and RTL pass alongside your product team — get in touch. It pairs naturally with the New Architecture and Expo migration work we already do.