Back to Blog
React Native
16 min read

React Native i18n with Expo: How to Add Multi-Language Support (4 Languages in 30 Minutes)

Paweł Karniej·March 2026

React Native i18n with Expo: How to Add Multi-Language Support (4 Languages in 30 Minutes)

March 2026

Adding multi-language support to a React Native app takes about 30 minutes with expo-localization and react-i18next. You install two packages, create your translation JSON files, configure an i18n instance, and wrap your app with a provider. That is the entire process. Ship React Native ships with 4 languages (English, Spanish, French, German) pre-configured so you can skip the setup entirely and start adding your own translations from day one.

This guide covers the full i18n workflow: setting up expo-localization with react-i18next, organizing translation files that stay maintainable as your app grows, using translations in components, building a language switcher, handling RTL languages, and the ASO benefits of shipping your app in multiple languages.

Table of Contents

  • Why i18n Matters
  • Setting Up expo-localization + react-i18next
  • Organizing Translation Files
  • Using Translations in Components
  • Language Switching
  • RTL Support
  • expo-localization vs react-native-localize
  • Common i18n Mistakes
  • ASO Benefits of Multi-Language Apps
  • When NOT to Bother with i18n
  • How Ship React Native Handles i18n
  • Why i18n Matters

    72% of app downloads come from non-English markets. That number surprised me the first time I saw it, but it makes sense when you think about it. The App Store and Google Play serve users in over 175 countries and 40+ languages. If your app is English-only, you are invisible in most of those markets.

    I have seen this firsthand. One of my apps was getting steady but unremarkable downloads -- about 30 per day, almost all from the US and UK. I added Spanish and German translations over a weekend. Within a month, downloads had gone up to around 80 per day, with roughly half coming from Mexico, Spain, Germany, and Austria. The app was the same. The features were the same. The only difference was that the App Store listing and the in-app text were now in languages people actually spoke.

    Beyond downloads, there is a retention argument. Users who see an app in their native language are significantly more likely to keep using it. It signals that the developer cares about their market, and it removes the small but constant friction of reading an interface in a second language.

    The good news is that React Native i18n is not hard. With expo-localization for detecting the device language and react-i18next for managing translations, you can have a working multi-language setup in 30 minutes.

    Setting Up expo-localization + react-i18next

    Start by installing the required packages:

    npx expo install expo-localization
    npm install react-i18next i18next

    expo-localization gives you access to the device's locale settings -- the language, region, calendar type, and whether the device uses RTL. react-i18next is the React binding for i18next, which is the most widely used internationalization framework in the JavaScript ecosystem.

    Next, create your i18n configuration file. I keep mine at src/i18n/index.ts:

    import i18n from "i18next";
    import { initReactI18next } from "react-i18next";
    import { getLocales } from "expo-localization";
    
    import en from "./locales/en.json";
    import es from "./locales/es.json";
    import fr from "./locales/fr.json";
    import de from "./locales/de.json";
    
    const deviceLanguage = getLocales()[0]?.languageCode ?? "en";
    
    i18n.use(initReactI18next).init({
      resources: {
        en: { translation: en },
        es: { translation: es },
        fr: { translation: fr },
        de: { translation: de },
      },
      lng: deviceLanguage,
      fallbackLng: "en",
      interpolation: {
        escapeValue: false,
      },
    });
    
    export default i18n;

    The getLocales() function from expo-localization returns an array of the user's preferred locales, ordered by preference. We grab the first one's language code and use it as the initial language. If the device language is not one we support, react-i18next falls back to English.

    Now import this configuration in your app entry point. If you are using Expo Router, add it to your root layout:

    // app/_layout.tsx
    import "../src/i18n";
    // ... rest of your layout

    That single import executes the i18n setup. react-i18next's initReactI18next plugin automatically creates a React context, so you do not need to manually wrap your app in a provider.

    Organizing Translation Files

    I have tried several approaches to organizing translation files, and the one that scales best is a flat structure with namespaced keys. Here is the folder layout I use in Ship React Native:

    src/
      i18n/
        index.ts
        locales/
          en.json
          es.json
          fr.json
          de.json

    Each JSON file follows the same structure with dot-notation-style key grouping:

    {
      "common": {
        "save": "Save",
        "cancel": "Cancel",
        "delete": "Delete",
        "loading": "Loading...",
        "error": "Something went wrong"
      },
      "auth": {
        "signIn": "Sign In",
        "signUp": "Sign Up",
        "email": "Email",
        "password": "Password",
        "forgotPassword": "Forgot Password?",
        "signInWith": "Sign in with {{provider}}"
      },
      "settings": {
        "title": "Settings",
        "language": "Language",
        "theme": "Theme",
        "notifications": "Notifications",
        "deleteAccount": "Delete Account",
        "deleteAccountConfirm": "Are you sure? This action cannot be undone."
      },
      "subscription": {
        "title": "Go Premium",
        "monthlyPrice": "{{price}}/month",
        "yearlyPrice": "{{price}}/year",
        "feature_one": "Unlimited access",
        "feature_other": "{{count}} features included",
        "restore": "Restore Purchases"
      }
    }

    And the Spanish equivalent (es.json):

    {
      "common": {
        "save": "Guardar",
        "cancel": "Cancelar",
        "delete": "Eliminar",
        "loading": "Cargando...",
        "error": "Algo sali\u00f3 mal"
      },
      "auth": {
        "signIn": "Iniciar Sesi\u00f3n",
        "signUp": "Registrarse",
        "email": "Correo electr\u00f3nico",
        "password": "Contrase\u00f1a",
        "forgotPassword": "\u00bfOlvidaste tu contrase\u00f1a?",
        "signInWith": "Iniciar sesi\u00f3n con {{provider}}"
      }
    }

    A few conventions that keep things maintainable:

    • Group by feature, not by screen. Keys like auth.signIn are reusable across multiple screens. Keys like loginScreen.button are not.
    • Use interpolation for dynamic values. Never concatenate translated strings with variables. Use {{variable}} placeholders so translators can position them correctly for each language's grammar.
    • Keep keys in English. The key itself should describe what the string is: auth.forgotPassword, not auth.str_042. This makes it possible to work with the codebase even without the translation files open.

    Using Translations in Components

    The useTranslation hook from react-i18next gives you a t() function that looks up keys in the current language's translation file:

    import { useTranslation } from "react-i18next";
    import { View, Text, TouchableOpacity } from "react-native";
    
    export function SettingsScreen() {
      const { t } = useTranslation();
    
      return (
        <View>
          <Text>{t("settings.title")}</Text>
          <TouchableOpacity>
            <Text>{t("settings.language")}</Text>
          </TouchableOpacity>
          <TouchableOpacity>
            <Text>{t("settings.deleteAccount")}</Text>
          </TouchableOpacity>
        </View>
      );
    }

    For interpolation, pass an object as the second argument to t():

    // Translation: "Sign in with {{provider}}"
    <Text>{t("auth.signInWith", { provider: "Google" })}</Text>
    // Output: "Sign in with Google"
    
    // Translation: "{{price}}/month"
    <Text>{t("subscription.monthlyPrice", { price: "$9.99" })}</Text>
    // Output: "$9.99/month"

    Pluralization uses i18next's built-in plural rules. Define keys with _one, _other (and _zero, _two, _few, _many for languages that need them):

    // Translation keys:
    // "feature_one": "{{count}} feature included"
    // "feature_other": "{{count}} features included"
    
    <Text>{t("subscription.feature", { count: 1 })}</Text>
    // Output: "1 feature included"
    
    <Text>{t("subscription.feature", { count: 5 })}</Text>
    // Output: "5 features included"

    This is important because pluralization rules vary dramatically across languages. English has two forms (singular and plural). Arabic has six. Polish has three. i18next handles all of this automatically based on the CLDR plural rules for each language.

    Language Switching

    Detecting the device language happens automatically in the setup we configured earlier. But most apps also need a manual language picker so users can override the default. Here is how I build one, using Zustand to persist the preference:

    // src/stores/languageStore.ts
    import { create } from "zustand";
    import { persist, createJSONStorage } from "zustand/middleware";
    import AsyncStorage from "@react-native-async-storage/async-storage";
    import i18n from "../i18n";
    
    type LanguageStore = {
      language: string;
      setLanguage: (lang: string) => void;
    };
    
    export const useLanguageStore = create<LanguageStore>()(
      persist(
        (set) => ({
          language: i18n.language,
          setLanguage: (lang: string) => {
            i18n.changeLanguage(lang);
            set({ language: lang });
          },
        }),
        {
          name: "language-storage",
          storage: createJSONStorage(() => AsyncStorage),
        }
      )
    );

    Then a simple language picker component:

    import { useLanguageStore } from "../stores/languageStore";
    import { View, Text, TouchableOpacity } from "react-native";
    
    const LANGUAGES = [
      { code: "en", label: "English" },
      { code: "es", label: "Espa\u00f1ol" },
      { code: "fr", label: "Fran\u00e7ais" },
      { code: "de", label: "Deutsch" },
    ];
    
    export function LanguagePicker() {
      const { language, setLanguage } = useLanguageStore();
    
      return (
        <View>
          {LANGUAGES.map((lang) => (
            <TouchableOpacity
              key={lang.code}
              onPress={() => setLanguage(lang.code)}
              style={{
                backgroundColor: language === lang.code ? "#007AFF" : "#f0f0f0",
                padding: 12,
                borderRadius: 8,
                marginBottom: 8,
              }}
            >
              <Text
                style={{
                  color: language === lang.code ? "#fff" : "#333",
                }}
              >
                {lang.label}
              </Text>
            </TouchableOpacity>
          ))}
        </View>
      );
    }

    When the app launches, you need to restore the persisted language choice. Add this to your i18n config or root layout:

    const storedLanguage = useLanguageStore.getState().language;
    if (storedLanguage) {
      i18n.changeLanguage(storedLanguage);
    }

    The i18n.changeLanguage() call triggers a re-render of every component that uses the useTranslation hook. The entire UI updates instantly -- no app restart required.

    RTL Support

    If you plan to support Arabic, Hebrew, Persian, or Urdu, you need to handle right-to-left (RTL) text direction. React Native has built-in RTL support through the I18nManager API:

    import { I18nManager } from "react-native";
    import * as Updates from "expo-updates";
    
    export function setRTL(isRTL: boolean) {
      if (I18nManager.isRTL !== isRTL) {
        I18nManager.allowRTL(isRTL);
        I18nManager.forceRTL(isRTL);
        // RTL changes require an app restart to take effect
        Updates.reloadAsync();
      }
    }

    The catch is that RTL layout changes require an app restart -- they cannot be applied on the fly like language changes. This is a React Native limitation, not an i18next one. You need to call I18nManager.forceRTL() and then reload the app.

    Hook this into your language change logic:

    const RTL_LANGUAGES = ["ar", "he", "fa", "ur"];
    
    function handleLanguageChange(lang: string) {
      i18n.changeLanguage(lang);
      const shouldBeRTL = RTL_LANGUAGES.includes(lang);
      if (I18nManager.isRTL !== shouldBeRTL) {
        setRTL(shouldBeRTL);
      }
    }

    For styling, use start and end instead of left and right in your stylesheets. React Native automatically flips these in RTL mode:

    // Do this:
    { paddingStart: 16, paddingEnd: 8 }
    
    // Not this:
    { paddingLeft: 16, paddingRight: 8 }

    expo-localization vs react-native-localize

    Both libraries provide access to device locale information, but they differ in important ways:

    | Feature | expo-localization | react-native-localize |

    |---|---|---|

    | Expo compatibility | Native, works out of the box | Requires custom dev client |

    | Device locale detection | Yes, via getLocales() | Yes, via getLocales() |

    | Calendar system | Yes (getCalendars()) | Yes |

    | Temperature units | Yes | Yes |

    | RTL detection | Yes | Yes |

    | Locale change listener | No (check on app foreground) | Yes, real-time event listener |

    | Currency formatting | Via getLocales() region data | Via getCurrencies() |

    | Install size impact | Included in Expo SDK | Adds native module (~50KB) |

    My recommendation: if you are using Expo (which you should be for most React Native projects in 2026), use expo-localization. It is maintained by the Expo team, requires zero native configuration, and covers the two things you actually need -- detecting the device language and checking if the device uses RTL.

    The one advantage react-native-localize has is real-time locale change listening. If a user changes their device language while your app is in the background, react-native-localize can detect that and trigger a re-render. expo-localization only checks at app startup. In practice, this matters less than it sounds -- users change their device language very rarely, and checking on app foreground is sufficient for almost every use case.

    Common i18n Mistakes

    After building several multi-language apps, these are the mistakes I see most often:

    Hardcoded strings in components. This is the most common one. You set up i18n properly, translate all your screen text, and then forget that your error messages, alert titles, toast notifications, and placeholder text are still hardcoded in English. Every user-visible string needs to go through t(). Every single one.

    Forgetting to translate error messages. API errors, validation messages, and permission request explanations are often added late in development and never make it into the translation files. I keep a checklist: if a string is visible to the user, it goes in the translation file. No exceptions.

    Not testing RTL layout. Even if you do not support Arabic or Hebrew yet, test your layouts in RTL mode. It is easy to do in the iOS Simulator (Edit > Accessibility > Right-to-Left layout direction) and catches layout issues early. Flexbox layouts with start/end positioning work in both directions. Hardcoded left/right values do not.

    Translation files growing unmaintainable. This happens when you do not enforce a consistent key naming convention from the start. After a few months, you end up with keys like settings.title, settingsScreen.header, settings_page_title, and settingsPageTitle -- all referring to the same concept. Pick a convention on day one and stick to it. I use camelCase keys grouped by feature, and I have never regretted it.

    Concatenating translated strings. Never do t("hello") + " " + name. Different languages have different word orders. Spanish might put the name before the greeting. Always use interpolation: t("greeting", { name }) with a translation string like "Hello, {{name}}".

    ASO Benefits of Multi-Language Apps

    App Store Optimization in multiple languages is one of the highest-leverage things you can do for organic downloads. Here is why:

    When you localize your App Store listing (title, subtitle, description, keywords), the App Store indexes your app for keywords in every language you support. If your app is a "habit tracker" and you add Spanish translations, you now also rank for "rastreador de habitos" -- a keyword with virtually zero competition compared to its English equivalent.

    The download conversion rates speak for themselves. A localized App Store listing converts at a significantly higher rate than an English-only listing shown to non-English speakers. Apple and Google both surface localized apps more prominently in local search results.

    Here is what I do for every app I ship:

  • Translate the App Store metadata (title, subtitle, description) into each supported language
  • Create localized screenshots showing the app in that language
  • Localize the keyword field (iOS) with high-volume terms in each language
  • Ensure the in-app experience matches the listing language
  • You do not need professional translators for this. I use a combination of AI translation for the initial draft and then have native speakers review the output. For the four languages in Ship React Native (English, Spanish, French, German), this process takes about a day.

    The compounding effect is real. Each new language opens up an entirely new market of users who would never have found your app otherwise. And because most indie developers do not bother with localization, the competition in non-English keywords is dramatically lower.

    When NOT to Bother with i18n

    i18n is not always worth the effort. Here are the situations where I skip it:

    MVP stage. If you are still validating whether anyone wants your app, do not spend time on translations. Ship in English, measure product-market fit, and add languages later. i18n is much easier to add after the fact than people think, especially if you use react-i18next from the start (even with only English translations, you are building the right architecture).

    English-only market. Some apps are inherently tied to a single market. A US tax calculator, a UK transit app, or a tool that integrates with English-only APIs -- these do not benefit from localization. Focus your time elsewhere.

    Solo developer with no translation budget. Machine translation has gotten dramatically better, but it still produces awkward results for UI text. Short phrases, buttons, and error messages are particularly tricky. If you cannot afford a native speaker review, bad translations can hurt more than English-only. Users notice and they do not like it.

    Content-heavy apps. If your app is primarily user-generated content or fetches content from an API, translating the UI chrome gives diminishing returns. Your settings screen being in French does not help if all the articles are in English.

    That said, if your app has broad international appeal and you plan to grow beyond the English-speaking market, set up i18n early. It is 30 minutes of work that makes adding languages trivial later.

    How Ship React Native Handles i18n

    Ship React Native comes with a complete i18n setup out of the box. Four languages are pre-configured -- English, Spanish, French, and German -- with react-i18next and expo-localization wired up and ready to use.

    The translation files cover all the boilerplate screens: authentication flows, settings, subscription screens, onboarding, error states, and common UI elements. You get the folder structure, the i18n configuration, the language picker component, and the Zustand store for persisting language preferences -- all working together from the first npx expo start.

    Adding a new language is a three-step process: duplicate one of the existing JSON files, translate the strings, and add the new language to the config. No native configuration, no rebuilding, no fiddling with Xcode or Android Studio.

    If you are building a React Native app that needs to reach international users, this is the kind of infrastructure that takes 30 minutes to build from scratch but takes zero minutes when it is already done for you. Check out Ship React Native to see the full i18n setup along with everything else included in the template.


    Related posts: