Expo i18n Setup: Complete Tutorial for 2026

I remember building my first Expo app and thinking, "I'll add localization later." Spoiler alert: "later" never came, and when we finally launched in Thailand, the entire app was still in English.
If you're building an Expo app with global ambitions, setting up i18n early saves you massive headaches down the road. The good news? Expo makes localization surprisingly straightforward.
Let me walk you through everything I've learned from internationalizing multiple Expo apps—from basic setup to advanced patterns.
What You Need to Know Before Starting
Expo provides two key tools for localization:
- expo-localization: Detects device locale and preferences
- i18n-js: Handles translations and formatting
Together, they form a complete i18n solution for Expo apps.
Quick note: This guide works for both Expo managed workflow and bare workflow. If you're using bare workflow, you can also use react-native-localize instead of expo-localization.
Step 1: Install Dependencies
First, install the necessary packages:
npx expo install expo-localization i18n-js
That's it! No native linking required—Expo handles everything.
Step 2: Create Translation Files
Organize your translations in a clean structure:
src/
├── locales/
│ ├── en.json
│ ├── es.json
│ ├── fr.json
│ └── ja.json
└── i18n.js
Your translation files should be JSON:
// src/locales/en.json
{
"app_name": "My Awesome App",
"welcome": "Welcome to our app",
"get_started": "Get Started",
"settings": "Settings",
"profile": "Profile",
"logout": "Logout",
"good_morning": "Good morning, %{name}!",
"items_count": "%{count} items"
}
// src/locales/ja.json
{
"app_name": "素晴らしいアプリ",
"welcome": "アプリへようこそ",
"get_started": "始める",
"settings": "設定",
"profile": "プロフィール",
"logout": "ログアウト",
"good_morning": "おはようございます、%{name}さん!",
"items_count": "%{count}個のアイテム"
}
Step 3: Set Up i18n Configuration
Create a central i18n configuration file:
// src/i18n.js
import * as Localization from "expo-localization";
import { I18n } from "i18n-js";
// Import all translation files
import en from "./locales/en.json";
import es from "./locales/es.json";
import fr from "./locales/fr.json";
import ja from "./locales/ja.json";
// Set up i18n instance
const i18n = new I18n({
en,
es,
fr,
ja,
});
// Set the locale based on device settings
i18n.locale = Localization.locale;
// Enable fallback to English if translation is missing
i18n.enableFallback = true;
i18n.defaultLocale = "en";
// Handle locale formats like "en-US" -> "en"
const getLanguageCode = (locale) => {
const languageCode = locale.split("-")[0];
return Object.keys(i18n.translations).includes(languageCode)
? languageCode
: i18n.defaultLocale;
};
i18n.locale = getLanguageCode(Localization.locale);
export default i18n;
This configuration:
- Detects the device locale automatically
- Handles locale variations (en-US, en-GB -> en)
- Falls back to English for missing translations
- Works with all Expo-supported platforms
Step 4: Create a Translation Hook
For a cleaner API, create a custom hook:
// src/hooks/useTranslation.js
import { useCallback } from "react";
import i18n from "../i18n";
export function useTranslation() {
const t = useCallback((key, options = {}) => {
return i18n.t(key, options);
}, []);
const changeLanguage = useCallback((language) => {
i18n.locale = language;
}, []);
return { t, changeLanguage, locale: i18n.locale };
}
Now you can use it like this:
import { useTranslation } from "../hooks/useTranslation";
function MyComponent() {
const { t, changeLanguage, locale } = useTranslation();
return (
<View>
<Text>{t("welcome")}</Text>
<Button title={t("get_started")} />
</View>
);
}
Step 5: Using Translations in Components
Let's build a practical example:
import React from "react";
import { View, Text, Button, StyleSheet } from "react-native";
import { useTranslation } from "../hooks/useTranslation";
function HomeScreen() {
const { t, changeLanguage, locale } = useTranslation();
return (
<View style={styles.container}>
<Text style={styles.title}>{t("welcome")}</Text>
<Button
title={t("get_started")}
onPress={() => console.log("Get started")}
/>
<View style={styles.languageSwitcher}>
<Button
title="English"
onPress={() => changeLanguage("en")}
color={locale === "en" ? "#007AFF" : "#999"}
/>
<Button
title="Español"
onPress={() => changeLanguage("es")}
color={locale === "es" ? "#007AFF" : "#999"}
/>
<Button
title="日本語"
onPress={() => changeLanguage("ja")}
color={locale === "ja" ? "#007AFF" : "#999"}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 20,
},
title: {
fontSize: 24,
marginBottom: 20,
fontWeight: "bold",
},
languageSwitcher: {
marginTop: 30,
flexDirection: "row",
gap: 10,
},
});
export default HomeScreen;
Step 6: Handling Interpolation
Dynamic content is common in apps. Here's how to handle it:
// In your translation file
{
"hello_user": "Hello, %{name}!",
"items_remaining": "%{count} items remaining"
}
// In your component
function UserProfile({ userName }) {
const { t } = useTranslation();
return (
<View>
<Text>{t('hello_user', { name: userName })}</Text>
</View>
);
}
function Cart({ itemCount }) {
const { t } = useTranslation();
return (
<Text>
{t('items_remaining', { count: itemCount })}
</Text>
);
}
Step 7: Number and Date Formatting
Different locales format numbers and dates differently. Use Intl APIs:
import * as Localization from "expo-localization";
function formatNumber(number) {
return new Intl.NumberFormat(Localization.locale).format(number);
}
function formatDate(date) {
return new Intl.DateTimeFormat(Localization.locale, {
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
}
function formatCurrency(amount, currency = "USD") {
return new Intl.NumberFormat(Localization.locale, {
style: "currency",
currency: currency,
}).format(amount);
}
// Usage examples
console.log(formatNumber(1234567)); // "1,234,567" (US), "1.234.567" (DE)
console.log(formatDate(new Date())); // "January 2, 2026" (US), "2 janvier 2026" (FR)
console.log(formatCurrency(99.99)); // "$99.99" (US), "99,99 €" (FR)
Create a utility file for these formatters:
// src/utils/formatters.js
import * as Localization from "expo-localization";
export const formatNumber = (number) =>
new Intl.NumberFormat(Localization.locale).format(number);
export const formatDate = (date, options = {}) =>
new Intl.DateTimeFormat(Localization.locale, options).format(date);
export const formatCurrency = (amount, currency = "USD") =>
new Intl.NumberFormat(Localization.locale, {
style: "currency",
currency,
}).format(amount);
Step 8: Persisting Language Preference
Let users save their language choice:
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Localization from "expo-localization";
const LANGUAGE_KEY = "@user_language";
export async function getSavedLanguage() {
try {
const savedLanguage = await AsyncStorage.getItem(LANGUAGE_KEY);
return savedLanguage || Localization.locale.split("-")[0];
} catch (error) {
console.error("Error loading language:", error);
return "en";
}
}
export async function saveLanguage(language) {
try {
await AsyncStorage.setItem(LANGUAGE_KEY, language);
} catch (error) {
console.error("Error saving language:", error);
}
}
// Update your i18n.js to use saved language
const initI18n = async () => {
const savedLanguage = await getSavedLanguage();
i18n.locale = getLanguageCode(savedLanguage);
};
initI18n();
Step 9: Handling RTL Languages
Right-to-left languages (Arabic, Hebrew) need special handling:
import { I18nManager } from "react-native";
import * as Localization from "expo-localization";
const RTL_LANGUAGES = ["ar", "he", "fa", "ur"];
function setupRTL() {
const locale = Localization.locale.split("-")[0];
const isRTL = RTL_LANGUAGES.includes(locale);
if (isRTL !== I18nManager.isRTL) {
I18nManager.allowRTL(isRTL);
I18nManager.forceRTL(isRTL);
// You may need to reload the app
// Updates.reloadAsync();
}
}
// Call this on app startup
setupRTL();
Make sure your layouts use flexbox properly:
// Good - uses flexbox
<View style={{ flexDirection: 'row' }}>
<Text>Item 1</Text>
<Text>Item 2</Text>
</View>
// Bad - hardcoded direction
<View style={{ flexDirection: 'row', alignItems: 'flex-start' }}>
<Text>Item 1</Text>
<Text>Item 2</Text>
</View>
Step 10: Testing Different Locales
Testing localization is crucial. Here are some strategies:
Method 1: Change Device Language
Change your device/emulator language in settings. This is the most realistic test.
Method 2: Force Locale in Development
// In development, force a specific locale
if (__DEV__) {
i18n.locale = "ja"; // Test Japanese
// i18n.locale = 'ar'; // Test Arabic (RTL)
}
Method 3: Create a Debug Language Switcher
function DebugLanguageSwitcher() {
const { changeLanguage, locale } = useTranslation();
if (!__DEV__) return null;
return (
<View style={styles.debugContainer}>
<Text>Debug: Switch Language</Text>
{["en", "es", "fr", "ja", "ar"].map((lang) => (
<Button
key={lang}
title={lang}
onPress={() => changeLanguage(lang)}
color={locale === lang ? "#007AFF" : "#999"}
/>
))}
</View>
);
}
Common Mistakes to Avoid
1. Hardcoding Strings
// Bad
<Text>Welcome</Text>
// Good
<Text>{t('welcome')}</Text>
2. Not Handling Missing Translations
// Bad - shows the key if translation is missing
<Text>{t("some_missing_key")}</Text>;
// Good - provides fallback
function safeTranslate(key) {
const translation = i18n.t(key);
return translation === key ? "..." : translation;
}
3. Forgetting to Persist Language Choice
Users hate having to reselect their language every time they open the app. Always save their preference.
4. Ignoring Text Direction
If you support RTL languages, test them thoroughly. Layouts can break unexpectedly.
A Modern Alternative: File-Free Localization
Managing JSON translation files gets tedious as your app grows. Modern tools like AutoLocalise eliminate translation files entirely:
import { useAutoTranslate } from "react-autolocalise";
function WelcomeScreen() {
const { t } = useAutoTranslate();
return (
<View>
<Text>{t("Welcome to our app")}</Text>
<Button title={t("Get Started")} onPress={() => {}} />
</View>
);
}
No JSON files, no manual updates, real-time translation. Perfect for rapid development and MVPs.
Best Practices Checklist
- Set up i18n early in development
- Use a consistent translation key naming convention
- Implement language persistence
- Handle RTL languages properly
- Use locale-specific number/date formatting
- Test with multiple languages
- Provide fallbacks for missing translations
- Document your translation workflow
- Consider automated translation tools for speed
- Keep translation files organized
FAQ
Q: Does this work with Expo Router?
A: Yes! The same setup works with Expo Router. Just initialize i18n in your root layout or app entry point.
Q: Can I use this with React Navigation?
A: Absolutely. You can translate navigation titles and tab labels:
<Stack.Screen name="Profile" options={{ title: t("profile") }} />
Q: How do I handle pluralization?
A: i18n-js supports pluralization:
{
"item": "Item",
"item_plural": "Items"
}
Or use ICU message format for more complex cases.
Q: What about TypeScript support?
A: Create type definitions for your translation keys:
type TranslationKey = keyof typeof en;
declare module "i18n-js" {
interface CustomTypeOptions {
defaultLocale: "en";
translation: typeof en;
}
}
Q: Can I translate images and other assets?
A: Not directly with i18n-js. You'll need to conditionally load assets based on locale:
const getImage = () => {
switch (locale) {
case "ja":
return require("./assets/banner-ja.png");
default:
return require("./assets/banner-en.png");
}
};
Continue Reading: React Native & Expo Localization Best Practices
