Localize Your App

Expo localization: which file holds your translations

A phone on a desk beside a laptop and a row of blank cards

Expo localization: which file holds your translations

Expo localization is two separate jobs. The expo-localization package reads the device's language, region, and calendar settings, and it translates nothing. Your screen text lives in a JavaScript catalog that a library like i18n-js reads, while the app name and permission prompts live in per-language JSON files that app.json points to.

The details:

  • expo-localization gives you getLocales() and getCalendars(). It never returns a translated string, according to the Expo localization guide.
  • The JavaScript catalog, keys mapped to strings for each language, translates your screens and nothing else.
  • The expo.locales files in app.json translate the iOS display name, the Android app_name, and iOS permission prompts. They need a new build.
  • The per-app language list in iOS and Android system settings stays empty until you declare supportedLocales on the expo-localization config plugin.

What expo-localization gives you

The SDK reference describes expo-localization as an interface for native user localization information. It runs on Android, iOS, tvOS, web, and Expo Go. You pair it with a localization library (lingui-js, react-i18next, react-intl, i18n-js, or react-native-intlayer), and that library supplies the translated strings.

getLocales() returns the user's locales in the order set in device settings, with at least one element guaranteed. Each entry carries languageTag (a BCP 47 tag such as en-US), languageCode, textDirection (ltr or rtl), measurementSystem, currencyCode, currencySymbol, and regionCode. On the web, currency and measurement system come back as null, so guard for that if you ship a web build.

getCalendars() also returns at least one entry, with calendar, timeZone, uses24hourClock, and firstWeekday.

For formatting, you may not need the package at all. With Hermes enabled, the Intl API formats lists, dates, numbers, units, and plural forms on all platforms, and passing default as the locale uses the device locale. Intl won't tell you the device's currency or measurement system, though. You still get those from expo-localization.

Setting it up, step by step

Check which Expo SDK your project runs before you copy anything below. The RTL and native-strings behavior differs between SDK versions, and the current docs describe the newest one.

1. Install the package and read the locale

Run npx expo install expo-localization, then call getLocales() and log the first entry. You'll see an object whose languageCode is something like en or de, along with the other fields above.

2. Create the JavaScript catalog

Your screens read this file. The official guide's example uses i18n-js and keeps the shape plain: a map from language code to key-value pairs.

import { getLocales } from 'expo-localization';
import { I18n } from 'i18n-js';

const i18n = new I18n({
  en: { welcome: 'Hello' },
  ja: { welcome: 'こんにちは' },
});

i18n.locale = getLocales()[0].languageCode ?? 'en';
i18n.enableFallback = true;

// in a component
i18n.t('welcome');

With enableFallback on, a key missing from the Japanese set shows the English value. Once the app passes a couple dozen strings, you'll want each language in its own JSON file (en.json, ja.json) that you import into that map. If you use i18next or react-intl instead, the file shape differs; I covered those in i18next, react-intl, and vue-i18n JSON catalogs.

After this step, your screens follow the device language. The name under the icon still shows English. Step 4 handles that.

3. Declare supportedLocales on the config plugin

Newer iOS and Android versions let users pick a language per app in system settings, which usually makes a custom in-app language picker unnecessary. The OS lists only the languages your app declares. Add them to the expo-localization plugin in app.json:

{
  "expo": {
    "plugins": [
      ["expo-localization", { "supportedLocales": ["en", "ja", "de"] }]
    ]
  }
}

You can also pass an object with separate ios and android arrays. On iOS, use the language name or the ISO language designator. Plugin settings like this one can't apply at runtime, so rebuild the binary with a development build or a store build. A JavaScript reload won't pick it up. After the rebuild, open your app's page in system settings and look for the language option with the locales you declared.

Older tutorials skip this step. If that settings page shows no language choice, a missing supportedLocales is the first thing to check.

4. Add native locale files for the name, permissions, and notifications

This is the second JSON job, and your catalog plays no part in it. Set ios.infoPlist.CFBundleAllowMixedLocalizations to true, then point expo.locales at one JSON file per language:

{
  "expo": {
    "ios": {
      "infoPlist": { "CFBundleAllowMixedLocalizations": true }
    },
    "locales": {
      "ja": "./locales/native/ja.json",
      "de": "./locales/native/de.json"
    }
  }
}

Those files hold the iOS display name (CFBundleDisplayName), the Android app_name, and iOS usage-description keys, the text inside camera or location permission prompts. During prebuild, Expo writes the usage descriptions to InfoPlist.strings. In SDK 55 and later, an iOS-only Localizable.strings object in the same locale file creates native localization files, including strings for localized notifications. Copy the exact layout of these files from the guide rather than guessing.

A separate folder such as locales/native keeps these files from getting mixed up with the catalog. Rebuild, switch the device to Japanese, and the name under the icon should change along with the permission prompt.

5. Re-read the locale on Android when the app comes back

iOS resets the app when the device language changes, so reading the locale once at startup covers iOS. Android doesn't reset the app. If you copied an iOS-only snippet, your Android build keeps showing the old language after the user switches, and it looks frozen. The guide's fix is to listen with the AppState API and call getLocales() again when the app's state changes:

import { AppState } from 'react-native';

AppState.addEventListener('change', (state) => {
  if (state === 'active') {
    i18n.locale = getLocales()[0].languageCode ?? 'en';
    // store the locale in state or context so components re-render
  }
});

Setting i18n.locale won't redraw what's already on screen, so keep the current locale in React state or a context.

6. Check right-to-left for your SDK

In SDK 58 and later, RTL support is on by default, and layout direction follows React Native's I18nManager. The app renders right-to-left when the device language is RTL, such as Arabic or Hebrew. On iOS, that language must also appear in your supportedLocales. The same applies in Expo Go, development builds, and apps built with EAS Build or npx expo prebuild. On earlier SDKs, RTL was on by default except in Expo Go, where it was off.

Two plugin options control this, and both need a new binary. supportsRTL defaults to true. forcesRTL defaults to false and forces RTL layout whatever the device language. The guide limits forced RTL to testing, or to apps localized only for RTL locales, so don't wire it up as the language switch in a production build.

A dynamic I18nManager.allowRTL or forceRTL override won't stick in Expo Go either, because Expo Go resets RTL preferences each time you open the launcher or a project. Test RTL in a development build.

Filling the catalog for more than two languages

The official guide admits that creating the translations is the large part of the job, and then it ends. Steps 1 to 6 take an afternoon. The catalog takes longer, because 300 keys in 10 languages means 3,000 strings, and each one has to keep its key and its placeholders intact.

The usual indie routes are writing the languages you speak, pasting chunks into a chatbot, or paying for a file-based tool. Whichever you pick, the same things break:

  • A placeholder gets translated, reordered into broken syntax, or dropped. Your library then shows a raw token, or the sentence loses its number or name. Whatever placeholder format your library uses, every target string needs the same placeholders as the source.
  • A key gets renamed or skipped, and the fallback shows English in the middle of a German screen.
  • An unescaped quote or a trailing comma makes the whole JSON file fail to parse, and the language disappears.

Before you merge a new language, diff the key sets against your source file, count placeholders per string, and parse every file. A short script does all three. The longer walk-through is in translate a localization JSON file, and where AI translation holds up covers which strings deserve a human read.

Don't start with 50 languages because you can. Pick the markets where your app already gets downloads; which App Store languages to do first has a shortlist. The native locale files are small, often just a name and a few permission prompts, so translating those by hand for each language you add is manageable.

Common mistakes

  • Translating the catalog and expecting the icon name and permission prompts to follow. Those need the expo.locales files and a rebuild.
  • Changing supportedLocales, supportsRTL, or forcesRTL and reloading JavaScript instead of building a new binary.
  • Reading the locale once at startup and shipping that to Android.
  • Testing an I18nManager.forceRTL override in Expo Go and concluding RTL is broken.

Translating the catalog with Localize Your App

The catalog step was the release chore I kept postponing, so Localize Your App focuses on it. It's a web tool that translates app localization files with AI, and i18n JSON is one of the formats you can upload. You can translate into all 50 App Store Connect localizations at once, or a subset you choose.

Placeholders are locked, a glossary keeps product terms consistent, and translation memory reuses approved strings, with those repeats free. You review source and target side by side, edit any line, and download the same format or a different supported one.

Pricing is one-time credits with no subscription: Starter is $9 for 8,000 credits, Indie $25 for 40,000, Studio $45 for 150,000, with a 14-day full refund.

The limits: it translates string files, so steps 1 to 6 stay with you. It doesn't submit your app, and there's no CLI or git sync. The expo.locales files aren't an i18n catalog, so handle those few strings yourself or copy them into a supported file first.

FAQ

Why do permission prompts and the app name stay in English after I translate the screens?

Your translated catalog only feeds your React components. The app name and iOS permission prompts come from the native locale files that expo.locales in app.json points to, and they take effect after a new build.

Does an i18n library cover system prompts, or only in-app UI?

Libraries like i18n-js or react-i18next translate the text your JavaScript renders. iOS usage descriptions, the display name, and the Android app_name sit in native files that Expo generates from the expo.locales JSON during prebuild.

How do I read the device language in Expo and load the matching dictionary?

Call getLocales() from expo-localization and take getLocales()[0].languageCode, which always exists because the array has at least one element. Set that as your library's locale with a fallback such as en, and on Android read it again through AppState when the app becomes active.

Is a full restart required to flip between right-to-left and left-to-right?

On iOS, changing the device language resets the app on its own. The supportsRTL and forcesRTL plugin settings need a new binary, and a dynamic I18nManager.forceRTL override doesn't stick in Expo Go, so test any in-app direction change in a development build.

Why does turning on right-to-left rearrange the whole interface?

Layout direction follows React Native's I18nManager, so RTL mirrors the layout of the screen along with the text. That's the expected result for Arabic and Hebrew readers.

Is it reasonable to disable right-to-left support?

If you ship Arabic or Hebrew, turning it off gives those users right-to-left text inside a left-to-right layout, so budget time for the mirrored screens instead. If you ship no RTL language, supportsRTL defaults to true; switch the device to Arabic once on each platform and look at your main screens before you decide to change it.

If your catalog works in English and the other languages are the part you keep putting off, translate your first file and read the result side by side before it goes into a build.

Ship your app in 50 languages by tonight

Upload your localization file, review side by side, download ready-to-import files for every language. One-time credits from $9 — no subscription.

No subscription. Credits never expire.