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.
| Job | Use | Note |
|---|---|---|
| Read the device language | expo-localization | getLocales() — ordered preferences, plus textDirection |
| Look up a string | i18next + react-i18next | Namespaces, interpolation, fallback chain |
| Pluralize | i18next (CLDR rules) | key_one / key_few / key_many / key_other |
| Format a date or time | Intl.DateTimeFormat | Never template a date by hand |
| Format money | Intl.NumberFormat | Currency symbol placement is per-locale |
| Flip the layout | I18nManager | Native-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/marginEndinstead ofmarginLeft/marginRight. Same for padding andborderStartWidth.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
leftandrightliterally. A floating action button pinnedright: 20stays 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.