Implementing Internationalization in Your Expo App with React-i18next
Delivering your app in your users’ native language isn’t just a nice-to-have—it’s essential for usability, engagement, and global reach. In this article, we’ll walk through a battle-tested, scalable approach to internationalizing a React Native app built with Expo. We’ll leverage:
- expo-localization for detecting device locale
- i18next + react-i18next for managing translations
- A per-locale, per-namespace file structure that keeps things organized
By the end, you’ll have a clean folder layout, a reusable setup for any future screens, and best practices you can share with your team.
Why Internationalize Early?
Waiting until late in the development cycle to add multilingual support often leads to:
- Tons of manual churn replacing <Text> with translation calls
- Inconsistent terminology, as strings get added ad hoc
- Hard-to-catch bugs where some screens remain untranslated
By integrating your i18n infrastructure from day one, you ensure that every new screen, button label, and error message is ready for translation. Plus, you’ll avoid a time-consuming refactor later.
The Stack: expo-localization + react-i18next
expo-localization
- Reads device preferences in BCP-47 format
- Works across iOS, Android, and Web
- Always returns current settings—even if the user changes language in the background
i18next
- A mature, full-featured i18n engine
- Supports interpolation, plurals, nested keys, and fallbacks
react-i18next
- React bindings for i18next
- Provides useTranslation hooks, context providers, and HOCs
- Enables namespace-based code splitting and lazy loading
1. Set Up Your Folder Structure
A well-organized locale directory lets translators focus on one feature at a time. Here’s our recommended layout
app/
└─ locales/
├─ en/
│ ├─ common.json
│ ├─ groups.json
│ └─ profile.json
└─ es/
├─ common.json
├─ groups.json
└─ profile.json
- Per-locale folders (en, es, etc.)
- Per-namespace JSON (common, groups, profile)
This mirrors your app’s domain: global labels in common.json, screen-specific strings in groups.json, and so on.
2. Install & Configure Dependencies
npm install react-i18next i18next expo-localizationCreate app/i18n.ts to bootstrap your translations:
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import * as Localization from 'expo-localization'
// Import JSON resources
import commonEN from './locales/en/common.json'
import commonES from './locales/es/common.json'
import groupsEN from './locales/en/groups.json'
import groupsES from './locales/es/groups.json'
i18n
.use(initReactI18next)
.init({
compatibilityJSON: 'v3',
// Detect device language at runtime
lng: Localization.getLocales()[0].languageTag.split('-')[0],
fallbackLng: 'en',
// Map locales → namespaces → resources
resources: {
en: { common: commonEN, groups: groupsEN },
es: { common: commonES, groups: groupsES },
},
ns: ['common','groups'],
defaultNS: 'common',
interpolation: { escapeValue: false },
})
export default i18n
Note: We use Localization.getLocales() instead of the deprecated Localization.locale. This ensures we always pick up changes on Android without restarting the app.
3. Wrap Your App with Providers
Edit app/_layout.tsx to include both the gesture handler root and the i18n provider:
import 'react-native-gesture-handler'
import React from 'react'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { I18nextProvider } from 'react-i18next'
import i18n from './i18n'
import { Stack } from 'expo-router'
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<I18nextProvider i18n={i18n}>
<SafeAreaProvider>
<Stack>
{/* your routes */}
</Stack>
</SafeAreaProvider>
</I18nextProvider>
</GestureHandlerRootView>
)
}
Now any screen can tap into translations with useTranslation().
4. Using Translations in Screens
In your component, import the hook:
import React from 'react'
import { View, Text } from 'react-native'
import { useTranslation } from 'react-i18next'
export default function CreateGroupScreen() {
const { t } = useTranslation('common')
return (
<View>
<Text>{t('createGroup.title')}</Text>
<Text>{t('createGroup.nameLabel')}</Text>
{/* ... */}
</View>
)
}
Behind the scenes, react-i18next will look up common.json for the current locale, falling back to English if a key is missing.
5. Best Practices & Tips
- Consistent key naming: use a dot-notation that matches your JSON structure (createGroup.title).
- Namespaces for code splitting: load only the JSON you need for each tab/screen.
- Pluralization & interpolation:
"members": "{{count}} member", "members_plural": "{{count}} members"t('members', { count: group.memberCount }) - Dynamic language switching (optional):
import i18n from '@/i18n' await i18n.changeLanguage('es') - Testing: mock useTranslation in Jest to return t: (k) => k and verify UI strings map correctly.
Conclusion
By combining expo-localization with react-i18next, and organizing your files in per-locale, per-namespace folders, you get:
- Automatic locale detection on every app launch and foreground event
- Clean separation of translations by feature
- Easy runtime switching, interpolation, and plural support
