An Expo app has two i18n jobs: find the device language, and show your strings in that language. expo-localization handles the first. ReadyNative's i18n category handles the second with one of three options: i18next, Lingui or none. For most apps, pick i18next. Pick Lingui when you want ICU message syntax and a catalog CLI. Pick none while you are still English-only.
The choice matters less than it looks. Screens call the same functions under all three, and both libraries run in Expo Go, so the translation layer never needs a development build.
The comparison in one table
| i18next | Lingui | |
|---|---|---|
| Message syntax | JSON keys and {{name}}; ICU only through the i18next-icu plugin | ICU MessageFormat in the catalog; screens still write {{name}} |
| Plurals | count option with _one and _other keys | plural and select in ICU, with CLDR categories |
| Catalog | src/locales/<code>.json, keyed by the English sentence | src/locales/<code>.json, flat id to string |
| Catalog CLI | None in the module; i18next-cli exists upstream | bun run i18n:extract, which runs lingui extract |
| Runs in Expo Go | Yes | Yes |
| Macros | None | None in ReadyNative; no Babel macro |
| Ecosystem | The bigger plugin set, per the module docs | Runtime API, React bindings and the CLI |
| Default | Yes, recommended in the module | Pick it at setup |
i18next: the default, with the bigger ecosystem
i18next runs through react-i18next. It keeps the surface your screens already use: i18n.t(key, vars) and useT(). The keys are the English sentences, so a screen reads the same with or without the module:
import { useT } from "@/lib/i18n";
import { Text } from "@/components/ui";
const t = useT();
<Text>{t("Version {{version}}", { version })}</Text>;Add a string by writing the English sentence in code, then add it to every src/locales/*.json. A missing key falls back to English, and then to the key itself. Plurals use the count option with _one and _other keys in the catalog, as i18next documents.
The module turns off keySeparator and nsSeparator. i18next's defaults are a dot and a colon, so turning them off keeps sentences that contain either one as valid keys.
Lingui: ICU messages and a catalog CLI
Lingui's runtime API, @lingui/core with @lingui/react, fills the same slot in src/lib/i18n.ts. Its catalogs use ICU MessageFormat, the syntax Lingui documents for plurals, select and ordinals, and the plural categories follow CLDR. Screens still write {{name}}; the module converts it to {name} at the t boundary, so the catalog entry reads:
{
"Attachment {name} saved": "Adjunto {name} guardado"
}A plural in Lingui's syntax looks like this:
{count, plural, one {# message} other {# messages}}Two details shape the work. The module ships no Babel macro, so the t and Trans macros do not work. You write i18n.t, useT() or the Trans component with an id, and edit the catalogs by hand. The bun run i18n:extract script runs lingui extract. It normalises catalogs and never deletes ids, but it only sees literal i18n.t and i18n._ calls, not the t() from useT().
Runtime compilation has a cost. Lingui's docs say the message compiler is usually left out of production builds to keep the bundle small. The module compiles catalogs at runtime through setMessagesCompiler(compileMessage), so expect that code in your app.
expo-localization: the device locale
Both options read the device language with expo-localization, which Expo Go includes. getLocales() returns the user's locales in the order they set them, and the list always has at least one entry:
import { getLocales } from "expo-localization";
const deviceLanguage = getLocales()[0].languageCode ?? "en";languageCode can be null, so keep a fallback. languageTag keeps the region, as in en-US or es-419, and is never null.
The platforms differ once the app is running. On iOS the values stay the same while the app runs, and a language change resets the app. On Android the user can change the language without a restart, so Expo's guide says to call getLocales() again whenever AppState reports the app back in the foreground. If a component needs the current value, the useLocales() hook rerenders when the OS settings change.
Expo's guide also says there is still no way to read the temperature unit from user preferences. On Android it suggests a lookup table by locale, and it notes that iOS users can change the unit themselves.
Plurals depend on Intl: test on a device
Plural forms come from Intl.PluralRules, and the docs do not agree on whether Hermes has it. Expo's localization guide says that with Hermes you can use the Intl API on all platforms. The i18next plurals page says the Hermes engine still does not implement Intl.PluralRules, and asks for a polyfill. Lingui's React Native tutorial, written as of 08/2024, recommends polyfilling Intl.PluralRules and Intl.Locale.
Check each plural on a device in every language you ship, not only in English. Until the docs agree, the device is the only proof.
When each one wins
- i18next: most apps. Pick it for the bigger plugin ecosystem, plurals and formatters. ICU is available through the i18next-icu plugin, which replaces i18next's message handling when you turn it on.
- Lingui: you want ICU messages from the start and the lingui extract CLI, and you can live with catalogs edited by hand.
- none: English only, with zero dependencies. The shim keeps i18n.t and useT, so adding translations later is a setup call rather than a refactor.
How ReadyNative wires it
The option is chosen at setup. Either library adds expo-localization and its config plugin, writes src/lib/i18n.ts, registers I18nProvider at order 60, and stores the user's language under readynative:language through your storage adapter. Pick Lingui with:
bun run setup --i18n linguiPass i18next for the default, or none for the shim. With --with-examples, setup adds the /examples/i18n route. To change the language in code, call setLanguage(code). useLanguage() reads the current one, and deviceLanguage() reports what the OS says.
The option pages are on the stack pages: i18next and Lingui.
Read next: the i18n module docs, and Expo's localization guide.
FAQ
Should I use i18next or Lingui in Expo?
For most apps, i18next. It has the bigger plugin ecosystem, and both libraries run in Expo Go. Pick Lingui when you want ICU MessageFormat messages and the lingui extract CLI, and you can edit the catalogs by hand.
How do I get the device language in Expo?
Install expo-localization with npx expo install expo-localization, then read getLocales()[0].languageCode. The value can be null, so fall back to a default such as 'en'. On Android, call getLocales() again when the app returns to the foreground.
Do plurals work in React Native i18n?
Plurals depend on Intl.PluralRules. Expo says the Intl API works on all platforms with Hermes, but i18next's docs say Hermes still lacks Intl.PluralRules and needs a polyfill. Check each plural on a device in every language you ship.
How do I change the app language at runtime?
Call setLanguage(code), and read the current language with useLanguage(). For Arabic, setLanguage('ar') also flips the layout to right-to-left and restarts the app.
More guides
- Unistyles 3 with Expo: a working setup
- Expo environment variables: which are public?
- TanStack Query in Expo: offline and refetch
- Expo zustand: persist a store across restarts
- jest-expo setup, Expo Router tests and fixes
- Expo auth: Clerk vs Supabase vs Better Auth
- AsyncStorage vs MMKV vs expo-sqlite/kv-store
- Expo android widgets: expo-widgets on SDK 58
- Tamagui in Expo: setup, styling and themes
- Best paywall tools for Expo in 2026
- Expo widgets on iOS: a working setup
- RevenueCat subscriptions in an Expo Router app
- Supabase auth in Expo: email, Apple and Google
- Expo push notifications: setup, token, testing
- Expo Go vs development build: limitations
- The best Expo boilerplates, compared