If you're building a mobile app in 2026 and not thinking about multi-language support, you're leaving a huge chunk of users on the table. Seriously — studies show nearly 70% of users prefer apps in their native language, and plenty will straight up uninstall if it doesn't feel localized. In this guide, we'll build a fully internationalized React Native app using Expo, react-i18next, and expo-localization. We're talking type-safe translations, dynamic language switching, RTL support, and persistent user preferences.
So, let's dive in.
Why Internationalization Matters for Mobile Apps
Internationalization (i18n for short) is about designing your app so it can adapt to different languages, regions, and cultural conventions without rewriting code for each locale. Localization (l10n) is the actual work of translating content and adjusting formats for a specific locale.
Here's the thing — getting i18n right early saves you from a world of pain later. I've seen teams try to retrofit translations into apps that hardcode strings everywhere, and honestly, it's one of the most tedious refactors in frontend development. By setting up the i18n infrastructure from the start, every new screen and feature you add automatically gets multi-language support for free.
Choosing the Right i18n Library in 2026
The React Native ecosystem has several solid i18n options these days. Here's a quick rundown of the main contenders:
- react-i18next — The most widely adopted option with over 6 million weekly npm downloads. Built on i18next, it gives you interpolation, pluralization, namespaces, lazy loading, and full TypeScript support. This is what I recommend for most projects.
- LinguiJS — A compile-time i18n library that produces tiny bundle sizes. It supports React Server Components and ICU MessageFormat natively. Worth a look if performance is your top priority.
- FormatJS (react-intl) — Great for complex message formatting with full ICU support, but it's heavier than the other options and more commonly seen in web projects.
We'll be using react-i18next throughout this guide because of its mature ecosystem, solid documentation, and seamless integration with both Expo and TypeScript.
Project Setup and Installation
Start with a fresh Expo project or add i18n to an existing one. You need three core packages:
npx expo install expo-localization
npm install i18next react-i18next
npm install @react-native-async-storage/async-storage
Here's what each one does:
- expo-localization (v55.x) — Gives you access to device locale settings including language, region, calendar, and text direction.
- i18next (v25.x) — The core internationalization engine. It handles translation resources, interpolation, pluralization, and language detection.
- react-i18next (v16.x) — React bindings that provide the
useTranslationhook,Transcomponent, and context provider. - @react-native-async-storage/async-storage — Persists the user's language preference across app sessions.
Organizing Translation Files
A clean file structure makes managing translations across multiple languages way more manageable. Create a dedicated i18n directory at the root of your source code:
src/
├── i18n/
│ ├── index.ts # i18next configuration
│ ├── types.ts # TypeScript type augmentation
│ └── locales/
│ ├── en/
│ │ └── translation.json
│ ├── es/
│ │ └── translation.json
│ ├── ar/
│ │ └── translation.json
│ └── index.ts # Exports all locale resources
├── app/
│ └── _layout.tsx
└── components/
Define your English translations as the source of truth. All other language files need to mirror this structure exactly:
// src/i18n/locales/en/translation.json
{
"common": {
"appName": "MyApp",
"loading": "Loading...",
"error": "Something went wrong",
"retry": "Try Again",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete"
},
"home": {
"title": "Welcome back, {{name}}!",
"subtitle": "Here is what is new today"
},
"settings": {
"title": "Settings",
"language": "Language",
"theme": "Theme",
"notifications": "Notifications",
"languageChanged": "Language changed to {{language}}"
},
"profile": {
"title": "Profile",
"followers_one": "{{count}} follower",
"followers_other": "{{count}} followers",
"posts_one": "{{count}} post",
"posts_other": "{{count}} posts"
}
}
// src/i18n/locales/es/translation.json
{
"common": {
"appName": "MiApp",
"loading": "Cargando...",
"error": "Algo salió mal",
"retry": "Reintentar",
"cancel": "Cancelar",
"save": "Guardar",
"delete": "Eliminar"
},
"home": {
"title": "¡Bienvenido de nuevo, {{name}}!",
"subtitle": "Esto es lo nuevo de hoy"
},
"settings": {
"title": "Configuración",
"language": "Idioma",
"theme": "Tema",
"notifications": "Notificaciones",
"languageChanged": "Idioma cambiado a {{language}}"
},
"profile": {
"title": "Perfil",
"followers_one": "{{count}} seguidor",
"followers_other": "{{count}} seguidores",
"posts_one": "{{count}} publicación",
"posts_other": "{{count}} publicaciones"
}
}
Export the locales cleanly from an index file:
// src/i18n/locales/index.ts
import en from "./en/translation.json";
import es from "./es/translation.json";
import ar from "./ar/translation.json";
export const resources = {
en: { translation: en },
es: { translation: es },
ar: { translation: ar },
} as const;
Configuring i18next with Expo
This is where the magic happens. The configuration file wires together language detection, persistence, and the i18next core. The custom language detector plugin checks AsyncStorage first (for returning users), then falls back to the device locale:
// src/i18n/index.ts
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { getLocales } from "expo-localization";
import { resources } from "./locales";
const LANGUAGE_STORAGE_KEY = "app.language";
const languageDetector = {
type: "languageDetector" as const,
async: true,
init: () => {},
detect: async (callback: (lang: string) => void) => {
try {
const storedLanguage = await AsyncStorage.getItem(LANGUAGE_STORAGE_KEY);
if (storedLanguage) {
callback(storedLanguage);
return;
}
} catch (error) {
// Silently fall back to device locale
}
const deviceLocale = getLocales()[0]?.languageCode ?? "en";
callback(deviceLocale);
},
cacheUserLanguage: async (language: string) => {
try {
await AsyncStorage.setItem(LANGUAGE_STORAGE_KEY, language);
} catch (error) {
// Storage write failed — non-critical
}
},
};
i18n
.use(languageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: "en",
supportedLngs: ["en", "es", "ar"],
interpolation: {
escapeValue: false, // React already handles XSS
},
react: {
useSuspense: false, // Avoids issues with async language detection
},
});
export default i18n;
Then import this module once in your root layout so i18next initializes when the app boots:
// app/_layout.tsx
import "../src/i18n";
import { Stack } from "expo-router";
export default function RootLayout() {
return ;
}
That's it for the basic setup. Two lines in your layout file and you're good to go.
Adding Type-Safe Translations with TypeScript
This is honestly one of my favorite features of modern i18next. You can get full TypeScript support for your translation keys, which means your IDE will autocomplete them and flag typos at compile time — not at runtime when a user sees a broken string.
Create a type augmentation file that tells TypeScript about your translation structure:
// src/i18n/types.ts (or src/@types/i18next.d.ts)
import "i18next";
import type en from "./locales/en/translation.json";
declare module "i18next" {
interface CustomTypeOptions {
defaultNS: "translation";
resources: {
translation: typeof en;
};
}
}
With this in place, your t() calls become fully typed:
// TypeScript will autocomplete and validate these keys
const { t } = useTranslation();
t("common.appName"); // ✅ Valid
t("common.nonExistent"); // ❌ Type error
t("home.title", { name: "Alex" }); // ✅ Interpolation params checked
t("profile.followers", { count: 5 }); // ✅ Plural forms resolved
What makes this so powerful is that your English translation file becomes the single source of truth. Rename or remove a key from the English JSON, and TypeScript immediately flags every usage across your entire codebase. It eliminates an entire category of runtime bugs that are notoriously hard to catch through testing alone.
Using Translations in Components
The useTranslation hook is your bread and butter. It returns the t function for simple strings and the i18n object for language management:
import { View, Text } from "react-native";
import { useTranslation } from "react-i18next";
export function HomeScreen() {
const { t } = useTranslation();
return (
{t("home.title", { name: "Alex" })}
{t("home.subtitle")}
);
}
Handling Pluralization
i18next handles plural forms automatically based on the count parameter. It supports the full range of CLDR plural rules, which matters a lot for languages with complex plural forms (Arabic has six plural categories, for example):
export function ProfileStats({ followers, posts }: Props) {
const { t } = useTranslation();
return (
{t("profile.followers", { count: followers })}
{t("profile.posts", { count: posts })}
);
}
// followers = 1 → "1 follower"
// followers = 42 → "42 followers"
Rendering Rich Text with the Trans Component
When your translations contain inline formatting (bold text, links, etc.), the Trans component lets you embed React Native components directly:
import { Trans, useTranslation } from "react-i18next";
import { Text } from "react-native";
// In your translation JSON:
// "welcome": "Welcome to MyApp . Read our terms."
export function WelcomeBanner() {
const { t } = useTranslation();
return (
,
link: ,
}}
/>
);
}
Building a Dynamic Language Switcher
You'll definitely want to let users change the app language at runtime. The good news is that the language detector plugin we configured earlier automatically persists their choice to AsyncStorage. Here's a complete language picker component:
import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
const LANGUAGES = [
{ code: "en", label: "English", nativeLabel: "English" },
{ code: "es", label: "Spanish", nativeLabel: "Español" },
{ code: "ar", label: "Arabic", nativeLabel: "العربية" },
];
export function LanguagePicker() {
const { i18n, t } = useTranslation();
const changeLanguage = async (langCode: string) => {
await i18n.changeLanguage(langCode);
};
return (
{t("settings.language")}
{LANGUAGES.map((lang) => (
changeLanguage(lang.code)}
>
{lang.nativeLabel}
{lang.label}
))}
);
}
const styles = StyleSheet.create({
container: { padding: 16 },
title: { fontSize: 18, fontWeight: "600", marginBottom: 12 },
option: {
flexDirection: "row",
justifyContent: "space-between",
padding: 14,
borderRadius: 8,
backgroundColor: "#f5f5f5",
marginBottom: 8,
},
activeOption: {
backgroundColor: "#e0f0ff",
borderWidth: 1,
borderColor: "#007AFF",
},
nativeLabel: { fontSize: 16, fontWeight: "500" },
label: { fontSize: 14, color: "#888" },
});
When i18n.changeLanguage() is called, react-i18next automatically re-renders every component that uses useTranslation. The language detector plugin persists the choice to AsyncStorage, so it survives app restarts. Pretty seamless, honestly.
Supporting RTL Languages
Right-to-left languages like Arabic, Hebrew, and Persian require the entire UI layout to mirror. This is one of those things that sounds scary but React Native actually handles pretty well. The catch? Toggling RTL requires an app restart to take effect:
import { I18nManager } from "react-native";
import * as Updates from "expo-updates";
import i18n from "../i18n";
const RTL_LANGUAGES = ["ar", "he", "fa", "ur"];
i18n.on("languageChanged", (lang: string) => {
const isRTL = RTL_LANGUAGES.includes(lang);
if (I18nManager.isRTL !== isRTL) {
I18nManager.allowRTL(isRTL);
I18nManager.forceRTL(isRTL);
// Restart the app to apply the layout direction change
Updates.reloadAsync();
}
});
Place this listener in your root layout or i18n config so it runs on every language change. The Updates.reloadAsync() call triggers a soft restart of the JavaScript bundle — it's the only reliable way to flip the layout direction globally.
The nice thing is that React Native automatically mirrors flex layout properties (flexDirection: "row" starts from the right in RTL), margin and padding start/end properties, and textAlign defaults. You generally don't need conditional styles for RTL if you use logical properties like marginStart and paddingEnd instead of marginLeft and paddingRight.
Platform-Specific Behavior
iOS and Android handle locale changes differently, and knowing about these differences upfront prevents some really subtle bugs.
iOS
- When a user changes the device language in Settings, iOS restarts the app entirely. Your i18n setup re-initializes with the new device locale automatically — no extra work needed.
- Starting with iOS 16, users can set a per-app language in system settings. If your app's
Info.plistdeclares supported locales viaCFBundleLocalizations, this system picker shows up automatically. No custom UI required.
Android
- When a user changes the device language, the app does not restart. You need to listen for app state changes and refresh the locale yourself:
import { useEffect } from "react";
import { AppState } from "react-native";
import { getLocales } from "expo-localization";
import { useTranslation } from "react-i18next";
export function useLocaleRefresh() {
const { i18n } = useTranslation();
useEffect(() => {
const subscription = AppState.addEventListener("change", (state) => {
if (state === "active") {
const deviceLocale = getLocales()[0]?.languageCode;
if (deviceLocale && deviceLocale !== i18n.language) {
i18n.changeLanguage(deviceLocale);
}
}
});
return () => subscription.remove();
}, [i18n]);
}
- Starting with Android 13, per-app language settings are available natively too. Declare your supported locales in
res/xml/locales_config.xmland reference it in yourAndroidManifest.xml.
Localizing App Metadata with Expo
Don't forget about the stuff outside your app's UI — the app display name, system permission dialogs, and store descriptions all need localization too. Expo makes this surprisingly straightforward through the app config:
// app.json
{
"expo": {
"name": "MyApp",
"ios": {
"infoPlist": {
"CFBundleAllowMixedLocalizations": true
}
},
"locales": {
"es": "./assets/locales/es.json",
"ar": "./assets/locales/ar.json"
}
}
}
Each locale file defines the localized app name and permission descriptions:
// assets/locales/es.json
{
"CFBundleDisplayName": "MiApp",
"NSCameraUsageDescription": "Esta app necesita acceso a la cámara para tomar fotos.",
"NSPhotoLibraryUsageDescription": "Esta app necesita acceso a tus fotos para seleccionar imágenes."
}
One thing to keep in mind: these strings appear in the system language, not the in-app language. They're compiled into the native binary at build time, so any changes require a new build.
Performance Optimization: Lazy Loading Translations
For apps with a ton of languages or large translation files, bundling everything upfront can bloat your initial bundle size. Lazy loading pulls translations on demand instead:
npm install i18next-http-backend
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import HttpBackend from "i18next-http-backend";
i18n
.use(HttpBackend)
.use(initReactI18next)
.init({
fallbackLng: "en",
backend: {
loadPath: "https://your-cdn.com/locales/{{lng}}/{{ns}}.json",
},
interpolation: {
escapeValue: false,
},
});
This approach pairs nicely with a translation management system (TMS) like Crowdin, Phrase, or Locize. Translators update strings through the TMS dashboard, and the app fetches the latest translations without needing a new app store release.
That said, for most apps with fewer than ten languages, bundling translations locally is still the simpler and more reliable approach. You avoid network requests and the app works perfectly offline.
Integrating i18n with Expo Router
If you're using Expo Router for navigation (and you probably should be), translations integrate naturally. Just localize screen titles by setting them dynamically in your layout files:
// app/(tabs)/_layout.tsx
import { Tabs } from "expo-router";
import { useTranslation } from "react-i18next";
export default function TabLayout() {
const { t } = useTranslation();
return (
);
}
Because useTranslation triggers a re-render when the language changes, tab titles and header titles update instantly when the user switches languages. No navigation reset needed.
Testing Your i18n Setup
Testing translations prevents regressions and makes sure every screen renders correctly in all supported languages. Here's a testing utility that wraps components with a configured i18n provider:
// test/utils/i18n-test-helper.ts
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import en from "../../src/i18n/locales/en/translation.json";
export function setupTestI18n(language = "en") {
i18n.use(initReactI18next).init({
lng: language,
resources: {
en: { translation: en },
},
interpolation: { escapeValue: false },
});
return i18n;
}
// __tests__/HomeScreen.test.tsx
import { render, screen } from "@testing-library/react-native";
import { HomeScreen } from "../src/components/HomeScreen";
import { setupTestI18n } from "./utils/i18n-test-helper";
beforeEach(() => {
setupTestI18n("en");
});
test("renders the welcome title in English", () => {
render( );
expect(screen.getByText(/welcome back/i)).toBeTruthy();
});
Here's a particularly useful one — a structural test that verifies all translation files have the same keys as the English source. This catches missing translations before they reach production:
// __tests__/translations.test.ts
import en from "../src/i18n/locales/en/translation.json";
import es from "../src/i18n/locales/es/translation.json";
import ar from "../src/i18n/locales/ar/translation.json";
function getKeys(obj: Record, prefix = ""): string[] {
return Object.entries(obj).flatMap(([key, value]) => {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === "object" && value !== null) {
return getKeys(value as Record, fullKey);
}
return [fullKey];
});
}
const baseKeys = getKeys(en);
test.each([
["Spanish", es],
["Arabic", ar],
])("%s has all translation keys", (_, locale) => {
const localeKeys = getKeys(locale);
const missingKeys = baseKeys.filter((key) => !localeKeys.includes(key));
expect(missingKeys).toEqual([]);
});
Common Pitfalls and How to Avoid Them
After working with i18n across several projects, these are the mistakes I see come up again and again:
- Hardcoded strings sneaking in — Use an ESLint plugin like
eslint-plugin-i18nextto flag raw string literals in JSX. This catches untranslated text before it ships. - String concatenation for translations — Never build translated strings by joining parts:
t("hello") + " " + name. Different languages have different word orders. Always use interpolation:t("greeting", { name }). - Forgetting text expansion — German and French translations are typically 30-40% longer than English. Design your UI with flexible layouts that can handle longer strings without truncation.
- Date and number formatting — Don't include formatted dates or currencies in your translation files. Use
Intl.DateTimeFormatandIntl.NumberFormatinstead — they adapt automatically to the device locale. - Testing only in English — Always test with your longest language and at least one RTL language. You'd be surprised how many layout issues only show up in Arabic or Hebrew.
FAQ
What is the best i18n library for React Native in 2026?
react-i18next remains the most popular and recommended choice. It has over 6 million weekly npm downloads, full TypeScript support with type-safe translation keys, built-in pluralization and interpolation, and works seamlessly with both Expo and bare React Native projects. LinguiJS is a solid alternative if compile-time performance is your top priority.
How do I detect the user's device language in Expo?
Use the getLocales() function from expo-localization. It returns an array of locale objects sorted by user preference. Grab the primary language with getLocales()[0]?.languageCode. On iOS, the app restarts when the device language changes. On Android, you'll need to listen for AppState changes and re-read the locale when the app comes back to the foreground.
How do I handle RTL languages like Arabic in React Native?
Use I18nManager.forceRTL(true) to flip the entire layout direction, then restart the app with Updates.reloadAsync(). Stick to logical style properties like marginStart and paddingEnd instead of marginLeft and paddingRight, and your layout will mirror automatically. React Native handles most RTL adjustments for you when you use these logical properties.
Can I add new languages without releasing a new app version?
Yes! Use i18next-http-backend to load translation files from a remote server or CDN. When a new language gets added, the app fetches the new translation file on the next launch. Pair this with a TMS like Crowdin or Phrase for a smooth workflow. Just keep in mind that locally bundled translations are more reliable for offline scenarios.
How do I make translation keys type-safe with TypeScript?
Create a type declaration file that augments the i18next module with your English translation structure. This gives you autocomplete, compile-time validation of every t() call, and immediate error feedback when keys are renamed or removed. You'll need i18next v23+ and TypeScript v5+ for the best experience.