Skip to main content

How to add i18n to a React Native app (Expo and bare workflow)

· 9 min read
Founder of i18n-keyless

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:

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-js or react-native-i18n with bundled JSON.
  • ❌ Have ICU plural rules across many locales → use react-i18next with 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