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

  1. Reads device preferences in BCP-47 format
  2. Works across iOS, Android, and Web
  3. 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-localization

Create 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

Click here to share this article with your friends on X if you liked it.