React Native & Expo
How Babelize translates Expo Router, classic Expo and bare React Native apps — what it extracts, where translations are bundled, how the device language is picked, and current limitations.
Babelize translates Expo Router, classic Expo and bare React Native apps. It uses the same runtime as React on the web, react-i18next with ICU messages, but a native app can't download translations from a server or read a browser language, so the setup is different:
- translations are bundled with the app as JSON files,
- the device language picks the translation (expo-localization in Expo apps, react-native-localize in bare React Native),
- i18next is initialized before the first screen renders, so
t()never shows a raw key.
Detection
A project is treated as React Native when its package.json depends on expo or react-native. A Next.js app that also uses react-native-web stays a Next.js project.
The whole project is scanned, because React Native apps keep code in App.tsx, screens/, navigation/, hooks/ and so on. Skipped: the native ios/ and android/ projects, .expo, web-build, scripts/, the locale folder and config files (app.config.ts, babel.config.js, metro.config.js, …), plus everything Babelize always skips (node_modules, tests, type declarations).
What gets translated
| Where | Becomes |
|---|---|
<Text>Welcome back</Text> | <Text>{t("home.welcomeBack")}</Text> |
A sentence with nested <Text> and variables: <Text>Signed in as <Text style={s.bold}>{name}</Text></Text> | one message, rendered with <Trans> so the inner <Text> keeps its style |
placeholder, title (<Button title>), accessibilityLabel, accessibilityHint | placeholder={t("…")} |
Screen titles in navigation options: title, headerTitle, headerBackTitle, tabBarLabel, drawerLabel in options / screenOptions (objects or functions) | options={{ title: t("…") }} — other options (animation: 'fade', presentation: 'modal') are left alone |
Alert.alert(title, message, buttons) / Alert.prompt | the title, the message and each button's text, including two-letter labels such as OK |
Template literals and concatenations: `We charged ${total}.` | t("…", { total }) |
A default prop value: ({ label = 'New' }) | ({ label = t("…") }) |
Components get const { t } = useTranslation(). Text in a lowercase render helper (function renderEmpty() { return <Text>…</Text> }) or at module level uses the t exported by the generated i18n.ts instead, because a hook there would break the rules of hooks.
Platform files share their translations: tabs.tsx, tabs.ios.tsx and tabs.web.tsx use one key group.
Left alone: testID, nativeID, style, placeholderTextColor, keyboardType, textContentType, autoComplete, accessibilityRole, route names, navigation calls (navigation.navigate('Details')), object keys, file paths, and everything else that is data rather than copy.
Files Babelize adds
With a src/ folder (Expo Router's default) everything goes there; otherwise at the project root. Nothing new is ever added inside Expo Router's app/ folder, where every file is a route.
| File | What it is |
|---|---|
src/locales/<lang>.json | the translations, one file per language |
src/locales/index.ts | imports every locale file. Rewritten on every run, so adding or removing a language needs no manual step |
src/i18n.ts | initializes i18next with the bundled translations and the device language. Written once; you can edit it |
package.json | i18next, react-i18next, i18next-icu, intl-messageformat, and expo-localization (pinned to your Expo SDK, as npx expo install would) or react-native-localize |
The i18n config is imported as the first line of the app's entry point:
- Expo Router (
"main": "expo-router/entry"): the root layout,src/app/_layout.tsxorapp/_layout.tsx - classic Expo: the file
mainpoints to (index.ts) - bare React Native:
index.js
TypeScript projects get .ts files, JavaScript projects .js. If your app already sets up i18next itself, Babelize doesn't add a second config: it tells you how to load the bundled translations into yours.
After merging, install the new dependencies (npx expo install --fix keeps Expo modules on your SDK's versions). Bare React Native apps also need cd ios && pod install for react-native-localize.
Language
The app starts in the device's preferred language that has translations: the full tag first (pt-BR, zh-Hant), then the language (pt), falling back to the source language. To let people choose a language, the project page in the dashboard has a copyable switcher (buttons calling i18n.changeLanguage). To remember their choice across launches, store it and pass it to changeLanguage at startup:
import AsyncStorage from '@react-native-async-storage/async-storage';
import i18n from './i18n';
i18n.on('languageChanged', (lng) => AsyncStorage.setItem('language', lng));
AsyncStorage.getItem('language').then((lng) => lng && i18n.changeLanguage(lng));Hardcoded prices and dates that Babelize rewrites to Intl.NumberFormat / Intl.DateTimeFormat follow the app's language (i18next.language).
Requirements
- Expo SDK 50 or later, or React Native 0.73 or later with Hermes. Message formatting uses
Intl(plural rules, number formatting), which Hermes provides from these versions. - TypeScript projects need
resolveJsonModule, whichexpo/tsconfig.baseand@react-native/typescript-configalready enable.
Limitations
- Review the diff before merging. Files Babelize edits are reprinted, so run your formatter (Prettier,
expo lint --fix) on the pull request. - Right-to-left languages (Arabic, Hebrew, …) are translated, but switching the layout direction is up to you: call
I18nManager.forceRTL(true)for RTL languages; it takes effect after the app reloads. - Native strings — the app name, iOS permission prompts (
NSCameraUsageDescription, …), Androidstrings.xml— are not translated. Expo apps can localize them with thelocalesconfig. - Monorepos: each Babelize project translates one app. For an
apps/mobileapp in a monorepo, runbabelize translatefrom inside its folder. - Text that only exists at runtime (API data, user content) isn't in your code, so it isn't translated.
Last updated: 2026-10-15