← Back to all posts

A Clean Way to Handle Localization in React Native with i18next

EhsanBy Ehsan
9 min read
React Nativei18nextLocalizationInternationalizationi18nOTA UpdatesTranslationTypeScriptMobile DevelopmentMultilingual Apps

Introduction

Adding multiple languages to your React Native app doesn't have to be complicated. I've seen developers overcomplicate localization with complex namespace hierarchies, manual type definitions, and brittle update systems.

Here's the approach I use: simple, type-safe, and maintainable. It supports over-the-air translation updates, full TypeScript support, and works seamlessly with 16+ languages. This setup integrates perfectly with modern React Native tooling like NativeWind for styling and Jotai for state management.

Let me show you how to set it up.

Why i18next?

i18next is the most mature localization library for JavaScript. It's framework-agnostic, battle-tested, and has everything you need:

  • Pluralization support
  • Variable interpolation
  • Fallback languages
  • Dynamic loading
  • React Native compatible

Most importantly, it gets out of your way. Set it up once, and it just works.

Basic Setup

Install the dependencies:

npm install i18next react-i18next react-native-localize

Create your initialization file:

// src/localization/index.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import * as RNLocalize from 'react-native-localize';
import resources from './i18n-resources';

const deviceLanguage = RNLocalize.getLocales()?.[0]?.languageCode || 'en';

i18n.use(initReactI18next);

if (!i18n.isInitialized) {
  i18n.init({
    resources,
    ns: ['translation'],
    defaultNS: 'translation',
    lng: deviceLanguage,
    fallbackLng: 'en',
    debug: __DEV__,
    interpolation: {
      escapeValue: false, // React already escapes
    },
    react: {
      useSuspense: false, // Important for React Native
    },
  });
}

export default i18n;

Import it in your app entry:

// App.tsx
import './src/localization';

// Rest of your app

That's it. Your app now supports multiple languages.

File Organization

Keep it simple. One folder, flat structure:

src/localization/
├── json/
│   ├── en.json       # English (base)
│   ├── de.json       # German
│   ├── es.json       # Spanish
│   ├── fr.json       # French
│   └── ...
├── index.ts          # Initialization
└── i18n-resources.ts # Auto-generated imports

Your translation files are simple JSON:

{
  "welcome": "Welcome",
  "greeting": "Hi {{name}}",
  "songCount_one": "{{count}} song",
  "songCount_other": "{{count}} songs"
}

Using Translations in Components

The useTranslation hook gives you everything you need:

import { useTranslation } from 'react-i18next';

function MusicPlayer() {
  const { t } = useTranslation();

  return (
    <View>
      <Text>{t('welcome')}</Text>
      <Text>{t('greeting', { name: 'John' })}</Text>
      <Text>{t('songCount', { count: 5 })}</Text>
    </View>
  );
}

Clean, readable, no complexity.

Pluralization

i18next handles pluralization automatically. Just use the suffix convention:

{
  "day_one": "{{count}} day",
  "day_other": "{{count}} days",
  "playlist_one": "{{count}} playlist in your library",
  "playlist_other": "{{count}} playlists in your library"
}

Use it in code:

<Text>{t('day', { count: 1 })}</Text>
// Output: "1 day"

<Text>{t('day', { count: 5 })}</Text>
// Output: "5 days"

i18next automatically picks the right suffix based on the count and language rules.

Variable Interpolation

Pass variables directly to your translations:

{
  "playlistCreated": "Playlist <bold>{{name}}</bold> created successfully",
  "trackAddedAt": "Added {{date}} at {{time}}",
  "errorCode": "Error: {{code}}"
}
t('playlistCreated', { name: 'Summer Hits' })
t('trackAddedAt', { date: '2024-11-14', time: '10:30 AM' })
t('errorCode', { code: 404 })

You can even use HTML-like tags if you disable escapeValue. Perfect for styled text.

Language Switching

Create a custom hook to handle language changes:

// hooks/useChangeLanguage.ts
import { useTranslation } from 'react-i18next';

export function useChangeLanguage() {
  const { i18n } = useTranslation();

  const changeLanguage = async (language: string) => {
    await i18n.changeLanguage(language);
  };

  const getCurrentLanguage = () => i18n.language;

  return { changeLanguage, getCurrentLanguage };
}

Use it in your settings screen:

function LanguageSelector() {
  const { changeLanguage, getCurrentLanguage } = useChangeLanguage();
  const currentLanguage = getCurrentLanguage();

  const languages = [
    { code: 'en', name: 'English' },
    { code: 'de', name: 'Deutsch' },
    { code: 'es', name: 'Español' },
    { code: 'fr', name: 'Français' },
  ];

  return (
    <View>
      {languages.map((lang) => (
        <Button
          key={lang.code}
          title={lang.name}
          onPress={() => changeLanguage(lang.code)}
          style={currentLanguage === lang.code ? styles.active : styles.inactive}
        />
      ))}
    </View>
  );
}

TypeScript Type Safety

This is where it gets good. Auto-generate types from your base translation file.

Create a simple script:

// scripts/generate-i18n-types.js
const fs = require('fs');
const path = require('path');

const enTranslations = require('../src/localization/json/en.json');

const schemaContent = `
export const translationSchema = ${JSON.stringify(enTranslations, null, 2)} as const;

export type TranslationSchema = typeof translationSchema;
`;

fs.writeFileSync(
  path.join(__dirname, '../src/localization/i18n-schema.ts'),
  schemaContent
);

console.log('✅ Translation schema generated');

Declare types for i18next:

// src/@types/i18next.d.ts
import type { TranslationSchema } from '@src/localization/i18n-schema';
import 'i18next';

declare module 'i18next' {
  interface CustomTypeOptions {
    defaultNS: 'translation';
    resources: {
      translation: TranslationSchema;
    };
  }
}

Now you get full autocomplete and type-checking:

t('welcome') // ✅ Works
t('welcom')  // ❌ TypeScript error: Key doesn't exist

Fallback Strategy

Set up a smart fallback hierarchy:

const getInitialLanguage = () => {
  // 1. User's saved preference (highest priority)
  const savedLanguage = getUserLanguagePreference();
  if (savedLanguage) return savedLanguage;

  // 2. Device language
  const deviceLanguage = RNLocalize.getLocales()?.[0]?.languageCode;
  if (deviceLanguage) return deviceLanguage;

  // 3. Default fallback
  return 'en';
};

i18n.init({
  lng: getInitialLanguage(),
  fallbackLng: 'en', // Ultimate fallback for missing keys
  // ... other config
});

This ensures your app always displays something, even if a translation is missing.

Over-The-Air Translation Updates

Here's a powerful pattern: update translations without releasing a new app version.

Important: Don't use OTA translations as your default/primary loading strategy. That could lead to slow loading or failure on app startup. Always bundle translations with your app and use OTA updates as an enhancement only.

Set up dynamic loading:

// localization/dynamicTranslations.ts
import { storage } from '@src/storage'; // MMKV or AsyncStorage
import i18n from './index';

const CACHE_KEY_PREFIX = 'translation_cache_';
const API_BASE_URL = 'https://your-cdn.com/translations';

export async function loadTranslationsForLanguage(language: string) {
  try {
    // Check cache first
    const cached = storage.getString(`${CACHE_KEY_PREFIX}${language}`);
    if (cached) {
      const data = JSON.parse(cached);
      i18n.addResourceBundle(language, 'translation', data, true, true);
      return;
    }

    // Fetch from API
    const response = await fetch(`${API_BASE_URL}/${language}.json`);
    const translations = await response.json();

    // Cache for offline use
    storage.set(`${CACHE_KEY_PREFIX}${language}`, JSON.stringify(translations));

    // Add to i18n
    i18n.addResourceBundle(language, 'translation', translations, true, true);
  } catch (error) {
    console.warn('Failed to load translations:', error);
    // Fallback to bundled translations
  }
}

For storage, I recommend using MMKV with Jotai for fast, synchronous storage that's perfect for caching translations.

Load translations on app startup:

// App.tsx
useEffect(() => {
  const currentLanguage = i18n.language;
  loadTranslationsForLanguage(currentLanguage);
}, []);

Now you can update translations on your CDN, and users get them automatically. No app store review needed.

Handling Other Locales

Don't forget about date pickers and calendars. They need separate locale configuration:

// localization/dayjsLocale.ts
import dayjs from 'dayjs';

export const configureDayjsLocale = (language: string) => {
  switch (language) {
    case 'de':
      require('dayjs/locale/de');
      dayjs.locale('de');
      break;
    case 'es':
      require('dayjs/locale/es');
      dayjs.locale('es');
      break;
    // ... other languages
    default:
      dayjs.locale('en');
  }
};
// localization/calendarLocale.ts
import { LocaleConfig } from 'react-native-calendars';

export const configureCalendarLocale = (language: string) => {
  switch (language) {
    case 'de':
      LocaleConfig.locales['de'] = {
        monthNames: ['Januar', 'Februar', 'März', ...],
        dayNames: ['Sonntag', 'Montag', 'Dienstag', ...],
        dayNamesShort: ['So', 'Mo', 'Di', ...],
      };
      LocaleConfig.defaultLocale = 'de';
      break;
    // ... other languages
  }
};

Update everything when language changes:

export const updateAllLocales = (language: string) => {
  configureDayjsLocale(language);
  configureCalendarLocale(language);
  i18n.changeLanguage(language);
};

Here's how it all comes together:

function NowPlaying({ song }) {
  const { t } = useTranslation();

  return (
    <View>
      <Text>{t('nowPlaying')}</Text>
      <Text>{song.title}</Text>
      <Text>{song.artist}</Text>
      <Text>{t('duration', { minutes: song.duration })}</Text>
      <Text>{t('playCount', { count: song.plays })}</Text>
    </View>
  );
}

Translation file:

{
  "nowPlaying": "Now Playing",
  "duration": "Duration: {{minutes}} min",
  "playCount_one": "{{count}} play",
  "playCount_other": "{{count}} plays"
}

Clean, simple, works in any language.

Tips for Success

Keep keys flat - Avoid deep nesting like userProfile.settings.privacy.title. Use userSettingsPrivacyTitle instead. Nested structures lead to duplication - you'll end up with userProfile.okay and artistPage.okay when you just need one okay key.

Use English as base - English has the widest translator availability. Build all other languages from English.

Test with long text - German translations are often 30% longer than English. Your UI needs to handle it.

Don't translate everything - Brand names, proper nouns, and technical terms often stay in English.

Version your translations - Track which version of translations each app build uses. Makes debugging easier.

Cache aggressively - Always cache translations locally. Never block app startup on network requests.

Why This Approach Works

Type-safe - Full TypeScript support with autocomplete

Maintainable - Simple file structure, easy to understand

Performant - Bundled translations with optional OTA updates

Flexible - Supports 100+ languages if needed

Future-proof - Update translations without app releases

Final Thoughts

Localization doesn't need to be complicated. Set up i18next properly, add TypeScript types, and you're done.

The pattern I've shown here handles everything: pluralization, interpolation, OTA updates, type safety, and fallbacks. It scales from 2 languages to 20+ without changing architecture.

Most importantly, it gets out of your way. Write your UI once, add translations as needed, and let i18next handle the rest.

Your app can now reach users worldwide. No overengineering required.

Frequently Asked Questions

How do I handle OTA translation updates?

Fetch updated translation files from your server, cache them locally using AsyncStorage or MMKV, then reload i18next with the new translations. Always bundle default translations so the app works offline.

Can I use i18next with TypeScript?

Yes! Generate TypeScript types from your translation files for full autocomplete and type safety. The post above shows how to set up typed translations with react-i18next.

What's the best way to organize translation files?

It depends on your use case, but based on my experience, I recommend keeping keys flat rather than deeply nested. Use descriptive names like settingsPrivacyTitle instead of settings.privacy.title. This prevents key duplication and makes translations easier to manage.

How do I handle pluralization in different languages?

i18next handles pluralization automatically. Use _one, _other, _zero suffixes in your translation keys. The library knows each language's plural rules and applies them correctly.

Should I translate everything in my app?

No. Keep brand names, proper nouns, and technical terms in their original language. Also, test with longer languages like German—translations can be 30% longer than English, so your UI needs flexibility.

Resources