# React Native i18n: Localize an Expo App with i18next

> Set up i18n in an Expo or bare React Native app: bundled locale files, the device language, plurals, styled text with Trans, Intl formatting, right-to-left layouts, and translating the app name and permission prompts.
- **Author**: Mohit Sapat
- **Published**: 2026-10-25
- **URL**: https://www.babelize.co/blogs/react-native-i18n-expo-i18next

---

<p class="lead">
    Localizing a React Native app looks like localizing a React web app until you hit the parts that are only on mobile: translations have to ship inside the app, the language comes from the phone's settings, right-to-left needs a restart, and the app name and permission prompts live in native files that <code>t()</code> never sees. This guide sets up i18n in an Expo app with i18next step by step, then covers each of those mobile-only problems.
</p>

<h2>Quick Answer</h2>

<p>
    Install <code>i18next</code>, <code>react-i18next</code> and <code>expo-localization</code>. Put one JSON file per language in <code>src/locales/</code> and bundle them with the app. Initialize i18next once, before the first screen renders, with the device's preferred language that you have translations for. Use <code>useTranslation()</code> in components, plural keys for counts, <code>Intl</code> for numbers and dates, <code>I18nManager</code> plus a reload for right-to-left languages, and Expo's <code>locales</code> config for the app name and permission prompts.
</p>

<h2>What's Different About i18n on Mobile</h2>

<ul>
    <li><strong>Translations are bundled.</strong> A web app can fetch <code>/locales/de.json</code> on demand. A mobile app should work offline and on first launch, so the JSON files ship inside the app bundle.</li>
    <li><strong>The device picks the language.</strong> There's no URL like <code>/de/pricing</code> and no <code>Accept-Language</code> header. The phone's language list decides, and users can change it while your app is in the background.</li>
    <li><strong>Right-to-left needs a reload.</strong> React Native flips layouts for Arabic and Hebrew only after the app restarts.</li>
    <li><strong>Some text is native.</strong> The app name under the icon, iOS permission prompts and Android's <code>strings.xml</code> are not JavaScript, so your i18n library can't reach them.</li>
    <li><strong>Releases are slow to fix.</strong> A missing translation on the web is fixed with a deploy. In a store build it waits for the next review, so catching problems before release matters more.</li>
</ul>

<h2>Step 1: Install the Libraries</h2>

<pre><code>npx expo install expo-localization
npm install i18next react-i18next</code></pre>

<p>
    <code>npx expo install</code> picks the <code>expo-localization</code> version that matches your Expo SDK. In a bare React Native app without Expo, use <a href="https://github.com/zoontek/react-native-localize" target="_blank" rel="noopener noreferrer">react-native-localize</a> instead (and run <code>pod install</code>); the rest of this guide stays the same.
</p>

<h2>Step 2: Create the Locale Files</h2>

<p>One JSON file per language, with identical keys:</p>

<pre><code>// src/locales/en.json
{
  "home": {
    "welcome": "Welcome back, {{name}}",
    "signedInAs": "Signed in as &lt;bold&gt;{{name}}&lt;/bold&gt;"
  },
  "cart": {
    "items_one": "{{count}} item",
    "items_other": "{{count}} items",
    "removeTitle": "Remove item?",
    "removeMessage": "{{name}} will be removed from your cart."
  },
  "common": {
    "cancel": "Cancel",
    "remove": "Remove"
  },
  "settings": {
    "title": "Settings",
    "language": "Language"
  }
}</code></pre>

<p>
    i18next uses <code>{{name}}</code> for variables and a key suffix per plural form: <code>_one</code> and <code>_other</code> in English. The <code>&lt;bold&gt;</code> tag is for styled text inside a sentence, covered in step 5. If the format itself is new to you, <a href="/blogs/json-translation-files-explained/">JSON translation files explained</a> walks through it, and <a href="/blogs/translate-json-files-i18n/">how to translate JSON files</a> covers producing <code>de.json</code> and the rest without breaking placeholders.
</p>

<h2>Step 3: Initialize i18next with the Device Language</h2>

<pre><code>// src/i18n.ts
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { getLocales } from "expo-localization";
import en from "./locales/en.json";
import de from "./locales/de.json";
import pl from "./locales/pl.json";
import ar from "./locales/ar.json";

export const resources = {
  en: { translation: en },
  de: { translation: de },
  pl: { translation: pl },
  ar: { translation: ar },
} as const;

export type Language = keyof typeof resources;

// The first language in the device's list that we have translations for:
// the full tag first (pt-BR, zh-Hant), then the language (pt), else English.
function deviceLanguage(): Language {
  for (const locale of getLocales()) {
    if (locale.languageTag in resources) return locale.languageTag as Language;
    if (locale.languageCode &amp;&amp; locale.languageCode in resources) return locale.languageCode as Language;
  }
  return "en";
}

i18n.use(initReactI18next).init({
  resources,
  lng: deviceLanguage(),
  fallbackLng: "en",
  interpolation: { escapeValue: false }, // React already escapes output
});

export default i18n;</code></pre>

<p>
    <code>getLocales()</code> returns the user's languages in order of preference, so someone with "Portuguese (Brazil), English" gets Portuguese if you have it and English otherwise. Don't just take the first entry: if a user's first language is Dutch and you don't ship Dutch, their second choice is a better fallback than your default.
</p>

<p>
    Importing the JSON directly bundles it, which is what you want on mobile. TypeScript projects need <code>resolveJsonModule</code>, which <code>expo/tsconfig.base</code> already enables.
</p>

<h2>Step 4: Load It Before the First Screen</h2>

<p>
    i18next must be initialized before any component calls <code>t()</code>, or the first render shows raw keys. Import the config as the very first line of your entry point. With Expo Router, that's the root layout:
</p>

<pre><code>// src/app/_layout.tsx
import "../i18n";
import { Stack } from "expo-router";

export default function RootLayout() {
  return &lt;Stack /&gt;;
}</code></pre>

<p>
    In classic Expo, import it at the top of the file <code>main</code> points to (usually <code>index.ts</code>). In bare React Native, at the top of <code>index.js</code>. Because the resources are bundled and passed in synchronously, there's nothing to wait for.
</p>

<h2>Step 5: Translate Your Screens</h2>

<p>The basic case is text inside <code>&lt;Text&gt;</code>:</p>

<pre><code>import { Text, TextInput, View } from "react-native";
import { useTranslation } from "react-i18next";

export function Home({ name }: { name: string }) {
  const { t } = useTranslation();
  return (
    &lt;View&gt;
      &lt;Text&gt;{t("home.welcome", { name })}&lt;/Text&gt;
      &lt;TextInput placeholder={t("settings.language")} accessibilityLabel={t("settings.language")} /&gt;
    &lt;/View&gt;
  );
}</code></pre>

<p>The places that are easy to miss in a React Native codebase:</p>

<ul>
    <li><strong>Accessibility props:</strong> <code>accessibilityLabel</code> and <code>accessibilityHint</code> are read aloud by VoiceOver and TalkBack. An English label in a German app is a bug for exactly the users who depend on it.</li>
    <li><strong>Navigation titles:</strong> <code>title</code>, <code>headerTitle</code>, <code>tabBarLabel</code> and <code>drawerLabel</code> in screen options.</li>
    <li><strong>Alerts:</strong> the title, the message and every button label.</li>
    <li><strong><code>&lt;Button title&gt;</code>:</strong> it's a prop, not a child, so a search for <code>&lt;Text&gt;</code> won't find it.</li>
</ul>

<pre><code>import { Alert } from "react-native";
import { Stack } from "expo-router";

// Screen title, inside the component so t() is in scope
&lt;Stack.Screen options={{ title: t("settings.title") }} /&gt;

// Alert: title, message and buttons
Alert.alert(t("cart.removeTitle"), t("cart.removeMessage", { name: item.name }), [
  { text: t("common.cancel"), style: "cancel" },
  { text: t("common.remove"), style: "destructive", onPress: () =&gt; remove(item.id) },
]);</code></pre>

<h3>Styled words inside a sentence</h3>

<p>
    Don't split a sentence into pieces to bold one word. "Signed in as" plus a bold name becomes three fragments that translators can't reorder, and many languages put the name first. Keep it one message and let <code>&lt;Trans&gt;</code> map the tag to a styled <code>&lt;Text&gt;</code>:
</p>

<pre><code>import { Text } from "react-native";
import { Trans } from "react-i18next";

&lt;Text&gt;
  &lt;Trans
    i18nKey="home.signedInAs"
    values={{ name }}
    components={{ bold: &lt;Text style={styles.bold} /&gt; }}
  /&gt;
&lt;/Text&gt;</code></pre>

<p>
    The outer <code>&lt;Text&gt;</code> matters: React Native only renders strings inside a <code>&lt;Text&gt;</code>, and <code>&lt;Trans&gt;</code> doesn't add one for you.
</p>

<h2>Plurals</h2>

<p>Pass <code>count</code> and i18next picks the right key for the current language:</p>

<pre><code>t("cart.items", { count: 1 }); // "1 item"
t("cart.items", { count: 3 }); // "3 items"</code></pre>

<p>
    English needs two forms; other languages need more. Polish uses different words for 1, for 2 to 4 and for 5 and up, so <code>pl.json</code> needs four keys where English has two:
</p>

<pre><code>// src/locales/pl.json
{
  "cart": {
    "items_one": "{{count}} produkt",
    "items_few": "{{count}} produkty",
    "items_many": "{{count}} produktów",
    "items_other": "{{count}} produktu"
  }
}</code></pre>

<p>
    i18next chooses the form with <code>Intl.PluralRules</code>, which Hermes provides from React Native 0.73 and Expo SDK 50. On older versions, or with JavaScriptCore, add a polyfill such as <code>intl-pluralrules</code>, or every count falls back to <code>_other</code>. For the full set of rules per language, see the <a href="/blogs/icu-messageformat-plurals-guide/">ICU plurals guide</a>.
</p>

<h2>Let Users Change the Language</h2>

<p>
    Following the device language is the right default, but some people run their phone in English and want your app in their own language, or the reverse. Switching is one call, and remembering the choice takes two more lines:
</p>

<pre><code>// src/i18n.ts (after init)
import AsyncStorage from "@react-native-async-storage/async-storage";

i18n.on("languageChanged", (lng) =&gt; AsyncStorage.setItem("language", lng));
AsyncStorage.getItem("language").then((lng) =&gt; {
  if (lng &amp;&amp; lng in resources) i18n.changeLanguage(lng);
});

// In a settings screen
&lt;Button title="Deutsch" onPress={() =&gt; i18n.changeLanguage("de")} /&gt;</code></pre>

<p>
    Components using <code>useTranslation()</code> re-render with the new language straight away. Show each language in its own name ("Deutsch", not "German"), since a user who can't read the current language still needs to find theirs. iOS and Android 13+ also let users pick a language per app in system settings; your app sees that as the device language on the next launch.
</p>

<h2>Numbers, Dates and Currencies</h2>

<p>
    Never build "$1,234.50" or "10/26/2026" by hand. Germans write 1.234,50 EUR  and most of the world reads 10/26 as nonsense. Use <code>Intl</code> with the app's language:
</p>

<pre><code>import i18n from "./i18n";

const price = new Intl.NumberFormat(i18n.language, { style: "currency", currency: "EUR" }).format(1234.5);
// en: "EUR 1,234.50"   de: "1.234,50 EUR "

const date = new Intl.DateTimeFormat(i18n.language, { dateStyle: "medium" }).format(new Date());
// en: "Oct 25, 2026"   de: "25.10.2026"</code></pre>

<p>
    Hermes supports <code>Intl.NumberFormat</code> and <code>Intl.DateTimeFormat</code> on both platforms in current React Native versions. The <a href="/blogs/javascript-intl-api-dates-numbers-currencies/">Intl API guide</a> covers relative times, lists and units.
</p>

<h2>Right-to-Left Languages</h2>

<p>
    For Arabic, Hebrew, Persian and Urdu the whole layout should mirror: back arrows point right, rows start on the right. React Native does this through <code>I18nManager</code>, but the change only applies after a reload:
</p>

<pre><code>import { I18nManager } from "react-native";
import * as Updates from "expo-updates";
import i18n from "./i18n";

export async function applyDirection(lng: string) {
  const rtl = i18n.dir(lng) === "rtl";
  if (I18nManager.isRTL !== rtl) {
    I18nManager.allowRTL(rtl);
    I18nManager.forceRTL(rtl);
    await Updates.reloadAsync();
  }
}</code></pre>

<p>
    Call it after <code>changeLanguage</code> and at startup. Then check your styles: <code>marginStart</code> and <code>paddingEnd</code> flip automatically, <code>marginLeft</code> doesn't, and icons that imply direction (arrows, progress) may need a mirrored version. The same ideas on the web are covered in <a href="/blogs/rtl-support-css-logical-properties/">RTL support with CSS logical properties</a>.
</p>

<h2>Translate the App Name and Permission Prompts</h2>

<p>
    The name under the icon and messages like "Allow access to your camera?" are native strings. In Expo, translate them with the <code>locales</code> config:
</p>

<pre><code>// app.json
{
  "expo": {
    "ios": {
      "infoPlist": { "CFBundleAllowMixedLocalizations": true }
    },
    "locales": {
      "de": "./languages/de.json"
    }
  }
}</code></pre>

<pre><code>// languages/de.json
{
  "ios": {
    "CFBundleDisplayName": "Meine App",
    "NSCameraUsageDescription": "Wir brauchen die Kamera, um Belege zu scannen."
  },
  "android": {
    "app_name": "Meine App"
  }
}</code></pre>

<p>
    These are applied at build time, so they need a new native build, not just an over-the-air update. App Store and Google Play listings (description, screenshots, keywords) are separate again and live in the store consoles.
</p>

<h2>Test Before You Ship</h2>

<ul>
    <li><strong>Change the simulator's language,</strong> not just the in-app setting, to test the device-language path and the first launch.</li>
    <li><strong>Try German or Finnish first.</strong> Their words run long, so they find truncated buttons and tab labels faster than any other language.</li>
    <li><strong>Turn on an RTL language</strong> and walk every screen for icons and paddings that didn't flip.</li>
    <li><strong>Use pseudo-localization</strong> to catch hardcoded strings: replace every value with an accented, padded version like "[Ŵéļçöɱé ƀåçķ!!]", and any plain English left on screen was never translated. The <a href="/blogs/pseudo-localization-testing-guide/">pseudo-localization guide</a> shows how.</li>
    <li><strong>Check in CI</strong> that every locale file has the same keys and placeholders as <code>en.json</code>, so a missing key fails the build instead of reaching a store review.</li>
</ul>

<h2>Automating It With Babelize</h2>

<p>
    The slow part of all this is the first pass: finding every string in <code>&lt;Text&gt;</code>, props, navigation options and alerts, inventing keys, and wiring <code>t()</code> in. <a href="/">Babelize</a> (our product) does that pass for Expo Router, classic Expo and bare React Native apps, in beta:
</p>

<pre><code>npx babelize translate --to de,pl,ar
git diff</code></pre>

<p>
    It extracts text from <code>&lt;Text&gt;</code>, <code>placeholder</code>, <code>title</code>, accessibility props, screen options and <code>Alert.alert</code>, keeps styled sentences as one message rendered with <code>&lt;Trans&gt;</code>, writes the locale files and an <code>i18n.ts</code> that bundles them and picks the device language, and imports it at the top of your entry point. Reruns translate only new or changed strings.
</p>

<p>
    What it doesn't do: switch the layout direction for RTL languages, or translate native strings (the app name, permission prompts, <code>strings.xml</code>), so the RTL and <code>locales</code> steps above are still yours. Because React Native support is in beta, review the diff before merging. The <a href="https://docs.babelize.co/integrations/react-native" target="_blank" rel="noopener noreferrer">React Native docs</a> list exactly what it changes.
</p>

<h2>Checklist</h2>

<ul>
    <li>Bundle one JSON file per language and import the i18n config before the first screen.</li>
    <li>Pick the first device language you have translations for, not just the first one.</li>
    <li>Translate accessibility labels, navigation titles, alerts and <code>&lt;Button title&gt;</code>, not only <code>&lt;Text&gt;</code>.</li>
    <li>Keep styled sentences whole with <code>&lt;Trans&gt;</code>.</li>
    <li>Give each language its own plural keys, and make sure <code>Intl.PluralRules</code> exists.</li>
    <li>Format numbers, dates and prices with <code>Intl</code> and the app's language.</li>
    <li>Handle RTL with <code>I18nManager</code> and a reload, and use start/end styles.</li>
    <li>Translate the app name and permission prompts with Expo's <code>locales</code> config.</li>
    <li>Test with a long language, an RTL language and pseudo-localization before every release.</li>
</ul>
---
- [All articles](https://www.babelize.co/blogs)