How to add i18n to a React Native app (Expo and bare workflow)
React Native i18n traditionally means react-i18next with react-native-localize for locale detection. It works, but it's a lot of moving parts: a localization library, a key naming convention, JSON locale files bundled at build time, and a separate package for native locale detection.
This guide shows the keyless alternative for both Expo and bare React Native projects. You'll go from zero to multilingual in 5–10 minutes, with native locale detection and offline caching via MMKV or AsyncStorage.
TL;DR
npx expo install i18n-keyless-react react-native-mmkv
npx expo prebuild
import * as I18nKeyless from "i18n-keyless-react";
import { MMKV } from "react-native-mmkv";
const storage = new MMKV();
I18nKeyless.init({
API_KEY: "YOUR_API_KEY",
storage,
languages: { primary: "en", supported: ["en", "fr", "de", "es"] },
});
import { Text } from "react-native";
import { I18nKeyless } from "i18n-keyless-react";
<Text><I18nKeyless>Welcome to Acme</I18nKeyless></Text>
That's it. Same SDK as web React (i18n-keyless-react — single package, both targets). See the quick-setup guide for the full reference.
Prerequisites
- Expo SDK 50+ (or bare React Native 0.74+) for full compatibility with modern storage adapters.
- An API key from i18n-keyless.com.
Step 1: Install the SDK and a storage adapter
The SDK needs a key/value storage to cache translations. Two options:
Option A — MMKV (recommended)
Faster than AsyncStorage, synchronous API, smaller bundle impact.
npx expo install i18n-keyless-react react-native-mmkv
npx expo prebuild
prebuild generates the native iOS / Android directories (Expo's "config plugins" workflow). Skip if you're already in bare React Native.
Option B — AsyncStorage
Pure JavaScript, async API, works everywhere including Expo Go. Slightly slower for large translation caches but fine for most apps.
npx expo install i18n-keyless-react @react-native-async-storage/async-storage
npx expo prebuild
Both work. MMKV is meaningfully faster on cold starts when you have hundreds of cached strings; AsyncStorage is simpler if you want zero native config.
Step 2: Initialize at app entry
In your App.tsx (Expo) or index.js (bare):
MMKV setup
// App.tsx
import * as I18nKeyless from "i18n-keyless-react";
import { MMKV } from "react-native-mmkv";
const storage = new MMKV();
I18nKeyless.init({
API_KEY: "YOUR_API_KEY",
storage,
languages: {
primary: "en",
supported: ["en", "fr", "de", "es"],
},
});
export default function App() {
return <YourAppRoot />;
}
AsyncStorage setup
// App.tsx
import * as I18nKeyless from "i18n-keyless-react";
import AsyncStorage from "@react-native-async-storage/async-storage";
I18nKeyless.init({
API_KEY: "YOUR_API_KEY",
storage: AsyncStorage,
languages: {
primary: "en",
supported: ["en", "fr", "de", "es"],
},
});
export default function App() {
return <YourAppRoot />;
}
The SDK accepts any object with getItem, setItem, removeItem methods. MMKV and AsyncStorage both satisfy this.
Step 3: Translate strings in your components
import { Text, Pressable } from "react-native";
import { I18nKeyless, getTranslation } from "i18n-keyless-react";
export function HomeScreen() {
return (
<>
<Text style={styles.title}>
<I18nKeyless>Welcome to Acme</I18nKeyless>
</Text>
<Text>
<I18nKeyless>Ship multilingual apps in 5 minutes.</I18nKeyless>
</Text>
<Pressable
accessibilityLabel={getTranslation("Get started button")}
>
<Text><I18nKeyless>Get started</I18nKeyless></Text>
</Pressable>
</>
);
}
<I18nKeyless> works inside <Text>. For accessibility props, third-party components, or anywhere you need a string value (not a component), use getTranslation.
Important: nested text
React Native requires text to be inside a <Text> component. <I18nKeyless> outputs the translated string and must be wrapped:
// ✅ Correct
<Text><I18nKeyless>Hello</I18nKeyless></Text>
// ❌ Wrong — RN throws "text strings must be rendered within a <Text> component"
<View><I18nKeyless>Hello</I18nKeyless></View>
Step 4: Detect the device's preferred language
React Native exposes the device locale via the expo-localization package:
npx expo install expo-localization
import * as Localization from "expo-localization";
const SUPPORTED = ["en", "fr", "de", "es"] as const;
function detectInitialLanguage(): typeof SUPPORTED[number] {
const deviceLang = Localization.getLocales()[0]?.languageCode ?? "en";
return (SUPPORTED.includes(deviceLang as any) ? deviceLang : "en") as typeof SUPPORTED[number];
}
I18nKeyless.init({
API_KEY: "YOUR_API_KEY",
storage,
languages: {
primary: "en",
supported: SUPPORTED as unknown as string[],
initial: detectInitialLanguage(),
},
});
For bare React Native without Expo, use react-native-localize:
npm install react-native-localize
import * as RNLocalize from "react-native-localize";
const deviceLang = RNLocalize.getLocales()[0]?.languageCode ?? "en";
Step 5: Build a language switcher
import { useI18nKeyless } from "i18n-keyless-react";
import { Pressable, Text, View } from "react-native";
const SUPPORTED = ["en", "fr", "de", "es"];
const LABELS: Record<string, string> = {
en: "English",
fr: "Français",
de: "Deutsch",
es: "Español",
};
export function LanguageSwitcher() {
const { currentLanguage, setLanguage } = useI18nKeyless();
return (
<View style={{ flexDirection: "row", gap: 8 }}>
{SUPPORTED.map((lang) => (
<Pressable
key={lang}
onPress={() => setLanguage(lang)}
style={{
padding: 8,
backgroundColor: lang === currentLanguage ? "#3b82f6" : "#e5e7eb",
borderRadius: 4,
}}
>
<Text style={{ color: lang === currentLanguage ? "white" : "black" }}>
{LABELS[lang]}
</Text>
</Pressable>
))}
</View>
);
}
setLanguage updates the global locale; all <I18nKeyless> components re-render with the new language.
Step 6: Persist user's language choice
Without persistence, your app starts in the device locale every time the user reopens. Persist their explicit choice:
import { MMKV } from "react-native-mmkv";
const userPrefs = new MMKV({ id: "user-prefs" });
function detectInitialLanguage() {
const stored = userPrefs.getString("preferred-lang");
if (stored) return stored;
const deviceLang = Localization.getLocales()[0]?.languageCode ?? "en";
return SUPPORTED.includes(deviceLang as any) ? deviceLang : "en";
}
// In your language switcher:
function setLanguageWithPersist(newLang: string) {
setLanguage(newLang);
userPrefs.set("preferred-lang", newLang);
}
Step 7: Handle Expo's hot reload and dev experience
Hot reload preserves the SDK initialization. Live-update when you change a string:
// Before
<Text><I18nKeyless>Welcome</I18nKeyless></Text>
// Edit to:
<Text><I18nKeyless>Welcome to Acme</I18nKeyless></Text>
The new string ("Welcome to Acme") triggers a fresh translation request on first render in non-source locales. The old "Welcome" stays cached but is no longer referenced.
Step 8: Native edge cases
iOS dynamic type / Android font scaling
<Text> automatically respects platform font scaling. <I18nKeyless> doesn't need to do anything special — it outputs a string that <Text> renders.
Right-to-left (RTL) layouts
For Arabic, Hebrew — RTL layout is a separate concern from translation. React Native handles RTL via I18nManager.forceRTL(true) and I18nManager.allowRTL(true). Detect from the locale and toggle accordingly:
import { I18nManager } from "react-native";
const RTL_LANGS = ["ar", "he", "fa", "ur"];
function setLanguageWithRTL(lang: string) {
setLanguage(lang);
const isRTL = RTL_LANGS.includes(lang);
if (I18nManager.isRTL !== isRTL) {
I18nManager.allowRTL(isRTL);
I18nManager.forceRTL(isRTL);
// App restart required for RTL changes to take effect on iOS
// Use expo-updates or manual reload
}
}
Push notifications
Server-side push notifications need server-side translation. Use i18n-keyless-node on your backend:
import { awaitForTranslation } from "i18n-keyless-node";
const title = await awaitForTranslation("New message", user.lang);
const body = await awaitForTranslation("You have a new message from {sender}", user.lang)
.then((t) => t.replace("{sender}", senderName));
await sendPushNotification(user.deviceToken, { title, body });
See the Node.js guide.
Splash screen / first launch
The SDK fetches translations on first cache miss. On a brand-new install with no cached translations, the user briefly sees source-language text before translations load. Mitigate by:
- Pre-warming the cache (call
<I18nKeyless>invisibly during splash) — overkill for most apps. - Bundling a "starter" translation set as JSON and seeding the cache on first init — overkill, accept the brief flash.
- Just letting it render — the source language is always a sensible fallback, especially for the first 500ms of an install.
Common patterns
Pluralization
function ItemCount({ count }: { count: number }) {
if (count === 0) return <Text><I18nKeyless>No items</I18nKeyless></Text>;
if (count === 1) return <Text><I18nKeyless>1 item</I18nKeyless></Text>;
return (
<Text>
<I18nKeylessText replace={{ '{count}': count }}>{`{count} items`}</I18nKeylessText>
</Text>
);
}
For locales with multiple plural forms (Polish, Arabic, Russian), use Intl.PluralRules:
const rules = new Intl.PluralRules(currentLanguage);
const form = rules.select(count); // 'one' | 'few' | 'many' | 'other'
Tab bar labels
import { getTranslation } from "i18n-keyless-react";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
const Tab = createBottomTabNavigator();
<Tab.Navigator>
<Tab.Screen
name="Home"
component={HomeScreen}
options={{ tabBarLabel: getTranslation("Home") }}
/>
<Tab.Screen
name="Settings"
component={SettingsScreen}
options={{ tabBarLabel: getTranslation("Settings") }}
/>
</Tab.Navigator>
Error messages and alerts
import { Alert } from "react-native";
import { getTranslation } from "i18n-keyless-react";
Alert.alert(
getTranslation("Network error"),
getTranslation("Please check your connection and try again."),
);
When to use a different approach
- ❌ Need fully offline-first with no backend dependency → use
i18n-jsorreact-native-i18nwith bundled JSON. - ❌ Have ICU plural rules across many locales → use
react-i18nextwith FormatJS Intl polyfills. - ❌ Have human translators in workflow → use
react-i18next+ Lokalise / Crowdin.
For everyone else — indie apps, MVPs, fast-moving products — keyless is the better choice on React Native.
FAQ
Does it work on Expo Go?
If you use AsyncStorage as the storage adapter, yes. If you use MMKV, you need a custom development build (npx expo prebuild) — Expo Go doesn't include native MMKV. Most teams move off Expo Go anyway around the same time they need MMKV.
Does it work in React Native Web?
Yes — the same i18n-keyless-react package works on web. Use window.localStorage as the storage adapter on the web target.
How big is the SDK bundle?
Small — under 10 KB minified + gzipped. Most of the bundle weight is React itself, which you already have.
What about Hermes engine compatibility?
Fully compatible. Hermes runs the same JavaScript; nothing in the SDK uses APIs Hermes doesn't support.
Can I export translations to bundle them with the app?
There's an export endpoint — call it during your build to get current translations as JSON. Bundling them avoids the first-launch fetch but is rarely worth the build complexity.
Next steps
- Get an API key: i18n-keyless.com.
- Quick setup: Setup reference.
- Compare options: Best i18next alternatives in 2026.
- Other frameworks: React · Next.js · Node.js.
- The complete guide: The complete guide to keyless i18n.