Shipnative
ShipnativeShipnative
Sign in
GuideSeptember 2026 · 10 min read

React Native Localization: i18n, Plurals, and RTL (2026)

Localization looks like a string-replacement problem and then turns out to be a layout problem, a formatting problem, and — the day someone asks for Arabic — an architecture problem. The good news is that the pieces are stable in 2026 and fit together in about an hour. This is the whole path: reading the device locale, wiring i18next, plural forms that are correct outside English, formatting dates and money with Intl instead of string concatenation, and the RTL switch that will silently break every tap in your app if you do it the way that looks obvious.

Six jobs, six tools

“Localization” is really six separate jobs, and most of the pain comes from trying to solve them all with the translation library.

JobUseNote
Read the device languageexpo-localizationgetLocales() — ordered preferences, plus textDirection
Look up a stringi18next + react-i18nextNamespaces, interpolation, fallback chain
Pluralizei18next (CLDR rules)key_one / key_few / key_many / key_other
Format a date or timeIntl.DateTimeFormatNever template a date by hand
Format moneyIntl.NumberFormatCurrency symbol placement is per-locale
Flip the layoutI18nManagerNative-level; requires a restart

One caveat on Intl: its availability depends on the JavaScript engine build your app ships with. Modern Hermes builds include it, but if you are on an older React Native or a custom engine configuration, check typeof Intl.NumberFormat on a real device before relying on it, and add a polyfill if it comes back undefined. Do not assume from the simulator.

The setup

One file, imported once at the top of your root layout so it runs before any screen renders:

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

import en from './locales/en.json';
import es from './locales/es.json';
import de from './locales/de.json';
import ar from './locales/ar.json';

const resources = { en: { translation: en }, es: { translation: es }, de: { translation: de }, ar: { translation: ar } };

// getLocales() is ordered by user preference. Take the first tag we
// can actually serve, otherwise fall back — never render raw keys.
const supported = Object.keys(resources);
const preferred =
  getLocales().map((l) => l.languageCode).find((code) => code && supported.includes(code)) ?? 'en';

i18n.use(initReactI18next).init({
  resources,
  lng: preferred,
  fallbackLng: 'en',
  // RN has no <html lang>; i18next needs to be told not to look for one
  compatibilityJSON: 'v4',
  interpolation: { escapeValue: false }, // RN escapes for you
  returnNull: false,
});

export default i18n;

Note the fallback is by languageCode, not languageTag. A user in Mexico reports es-MX; if you match on the full tag against an es file, you get English. Match the language, keep the region for formatting.

// Any component
import { useTranslation } from 'react-i18next';

export function Header({ user, unread }) {
  const { t } = useTranslation();
  return (
    <View>
      <Text>{t('greeting', { name: user.firstName })}</Text>
      <Text>{t('inbox.unread', { count: unread })}</Text>
    </View>
  );
}

Plurals: the thing hand-rolled i18n always gets wrong

English has two plural categories, so count === 1 ? 'item' : 'items' works and teaches you the wrong lesson. Russian has four categories. Arabic has six. Japanese has one. Any conditional you write in JavaScript is a rule that only holds for the language you wrote it in — which is why the plural form belongs in the translation file, where a translator can add the categories their language needs:

// locales/en.json
{
  "inbox": {
    "unread_one": "{{count}} unread message",
    "unread_other": "{{count}} unread messages"
  }
}

// locales/ru.json — same key, four forms, no code change
{
  "inbox": {
    "unread_one":   "{{count}} непрочитанное сообщение",
    "unread_few":   "{{count}} непрочитанных сообщения",
    "unread_many":  "{{count}} непрочитанных сообщений",
    "unread_other": "{{count}} непрочитанного сообщения"
  }
}

The call site never changes: t('inbox.unread', { count }). The same discipline applies to sentences with embedded values — never build a string by concatenating fragments, because word order is not universal and your translator has no way to fix a sentence assembled in JavaScript. One key, one whole sentence, with interpolation placeholders.

Dates and money belong to Intl, not to your translation file

The formatting rules for a date, a number, or a price are per-locale data that the platform already has. Putting them in a translation file means maintaining them by hand, forever, in every language:

import { getLocales } from 'expo-localization';

const tag = getLocales()[0]?.languageTag ?? 'en-US';

// 3 September 2026 / September 3, 2026 / 3. September 2026 — free
export const formatDate = (d: Date) =>
  new Intl.DateTimeFormat(tag, { dateStyle: 'long' }).format(d);

// $1,234.50 / 1.234,50 € / ‎1,234.50 US$ — symbol placement is per-locale
export const formatMoney = (cents: number, currency: string) =>
  new Intl.NumberFormat(tag, { style: 'currency', currency }).format(cents / 100);

// "3 days ago" / "hace 3 días" — no date library needed
const rtf = new Intl.RelativeTimeFormat(tag, { numeric: 'auto' });
export const formatAgo = (days: number) => rtf.format(-days, 'day');

Two details that matter in a real app. Use the full languageTag here, not the language code — a German user in Switzerland formats numbers differently from one in Germany. And keep currency separate from locale: an app used in France that charges in dollars should show a dollar amount formatted the French way, not convert silently because the locale changed.

RTL: the one-line change that breaks every tap

When Arabic or Hebrew gets added, the obvious-looking move is to wrap the app in a View with style={{ direction: 'rtl' }}. It looks like it works. The layout mirrors, the text aligns right, screenshots are perfect. And then nothing is tappable — every button, every tab, every list row is dead, with no error in the console and no visual hint of a problem.

The cause is that direction on a wrapper changes how children are laid out visually without moving the underlying touch targets with them, so the whole hit-test map ends up offset from what is drawn. Wrapped around a root navigator, it takes out navigation for the entire app. It is the worst class of bug: invisible in a screenshot, fatal in the hand.

The supported path is the native one, and it needs a restart:

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

export async function applyDirection() {
  const shouldBeRTL = getLocales()[0]?.textDirection === 'rtl';

  if (I18nManager.isRTL === shouldBeRTL) return; // already correct

  I18nManager.allowRTL(shouldBeRTL);
  I18nManager.forceRTL(shouldBeRTL);

  // The native layout direction only takes effect on a fresh start.
  // Without this the setting "does nothing" until the user force-quits.
  await Updates.reloadAsync();
}

With I18nManager.isRTL true, React Native flips flexDirection: 'row' and the logical style properties for you. Which is only useful if your styles are written logically in the first place:

  • marginStart / marginEnd instead of marginLeft / marginRight. Same for padding and borderStartWidth.
  • textAlign: 'left' becomes 'auto', which follows the text’s own direction.
  • Directional icons — back chevrons, next arrows, progress — need mirroring explicitly. Nothing flips an image for you.
  • Absolutely positioned elements keep using left and right literally. A floating action button pinned right: 20 stays on the right in Arabic unless you swap it yourself.

And test by actually switching the device to Arabic, not by forcing the flag in development. Half of RTL bugs are in the parts of the app you would not have thought to open.

The layout problems translation creates

Text length is not a constant. German and Finnish routinely run 30–40% longer than English; Japanese runs shorter but taller. Anything designed against English strings will break somewhere:

  • Fixed-width buttons truncate. Let the button size to its content with a minimum width, not a fixed one.
  • Tab bar labels are the worst offender — five tabs of German rarely fit. Consider icon-only tabs, or accept two lines.
  • numberOfLines={1} silently hides meaning in the language you cannot read. Reserve it for genuinely secondary text.
  • Concatenated UI — a label next to a value in a row — reorders in some languages. Keep it one interpolated string.

The cheap test: add a pseudo-locale whose strings are the English ones padded to 140% length, and click through the app once in it. It finds most of these in ten minutes.

Do not forget the parts outside the app

Translating the UI while the system-level strings stay English is a common half-finished state, and users notice the seam immediately:

  • Permission prompts. The camera and location strings in your app config are shown by the OS and need per-language versions, or an Arabic user is asked for their location in English.
  • Push notification bodies.These are composed on your server, which means the user’s language has to be stored server-side — a schema decision, so make it before launch rather than after.
  • Store listing. Honestly the highest-return item on this list: a localized App Store description and screenshots affect installs far more than a translated settings screen, and both stores let you add locales without a new build.

Build it multilingual from the first screen

Retrofitting i18n means touching every string in the app, which is why it keeps getting postponed until it is expensive. ShipNative generates a real React Native app from a description — describe it as multilingual and the strings come out in a translation file instead of hardcoded in the JSX, with logical style properties throughout. Preview it on your phone, then export the full Expo project and add languages as you grow.

Frequently Asked Questions

What is the best i18n library for React Native in 2026?

i18next with react-i18next, paired with expo-localization to read the device locale. It is the only option in the ecosystem that handles ICU plural categories, nesting, interpolation, and namespace splitting without you writing glue. i18n-js is a reasonable lighter choice for an app with a hundred strings and two languages; anything larger will outgrow it.

How do I get the device language in React Native?

expo-localization getLocales() returns an ordered array of the user preferences, each with languageTag, languageCode, regionCode, textDirection, and currencyCode. Take the first entry, not the deprecated single-locale field, and always fall back to your default language when the tag has no translation file — a user with their phone in Icelandic should get English, not an app full of missing-key placeholders.

How do plurals work in React Native i18n?

Do not write count === 1 ? "item" : "items". English has two plural categories; Russian and Polish have four, Arabic has six, and Japanese has one. i18next implements the CLDR plural rules, so you write key_one, key_other, key_few, key_many in each language file and call t("key", { count }). The library picks the right form per locale, and translators can add categories your source language does not have.

How do I enable RTL in a React Native app?

I18nManager.allowRTL(true) and I18nManager.forceRTL(true), then a full app reload — the native layout direction only changes on restart, which is why toggling it in a settings screen appears to do nothing. Never simulate RTL by putting direction: "rtl" on a wrapper View: it flips the visuals but breaks touch handling on everything below it.

Should translation files be bundled or fetched at runtime?

Bundle them. A fetched translation file means the first render happens before the strings arrive, so users see either raw keys or a blank screen on every cold start, and the app is unusable offline. Bundle the languages you ship, and if you need to fix a typo without a store release, push the updated bundle through an over-the-air update instead.

→

Expo OTA Updates

How to ship a translation fix without waiting on App Review.

Read guide →
→

App Store Optimization for Indie Founders

Localizing the store listing is usually worth more than localizing the app.

Read guide →

Ship a real React Native app today

Describe, preview, and export Expo code — free to start.

Build with ShipNative →
ShipNative logoShipnative

Build mobile apps with AI. Describe, preview, and ship to iOS & Android in minutes.

Features

Text to App AIApp Generator from ScreenshotPRD to Mobile App

Tools

All free toolsApp Cost CalculatorApp Name GeneratorApp Store Keyword ToolReact Native Components

Blog

All blog postsHow to Build an App Without CodingBest AI Tools for Real Mobile AppsExpo EAS App Store ChecklistLovable, Cursor & v0 for MobileBest AI App Builders in 2026React Native AI App Builder Guide

Legal

FAQTerms of ServicePrivacy Policy

© 2026 ShipNative. All rights reserved.