Internationalization (i18n) is critical for applications targeting a global audience. This guide explores a modular, scalable approach to i18n in React that automatically processes translation files and persists language preferences through cookies.

By the end, you'll have a robust i18n system that automatically detects and loads translation namespaces, persists language preferences, separates translations by feature, and scales effortlessly as your application grows.

You can find the source code for this tutorial on GitHub.

Demo

Table of Contents

  1. Core Concepts and Setup: Understanding i18n and project initialization
  2. Automatic Configuration: The magic of auto-detecting translations
  3. Translation Organization and Usage: Structuring and implementing translations
  4. Extending Your i18n System: Adding languages and scaling

Core Concepts and Setup

Internationalization involves adapting your application to different languages and regional preferences, including:

  • Translation of text content
  • Formatting of dates, numbers, and currencies
  • Handling of pluralization

Our implementation focuses on modularity, automation, and persistence. Let's start by setting up our project:

pnpm add i18next react-i18next i18next-browser-languagedetector js-cookie @types/js-cookie

First, define a Language enum for type safety:

// src/domain/enums/language.ts
export enum Language {
  EN = "en",
  PT_BR = "pt-br",
}

Automatic Configuration

The cornerstone of our scalable i18n system is the config.ts file that automatically discovers and loads translation files:

// src/i18n/config.ts
import i18n from "i18next";
import Cookies from "js-cookie";
import { initReactI18next } from "react-i18next";
import { Language } from "@/domain/enums/language";
import LanguageDetector from "i18next-browser-languagedetector";

type Context = Record<string, { default: Record<string, string> }>;
type Resources = Record<string, Record<string, Record<string, string>>>;

function loadTranslationFiles() {
  const resources: Resources = {};

  Object.values(Language).forEach((lang) => {
    resources[lang] = {};
  });

  const enContext: Context = import.meta.glob<{
    default: Record<string, string>;
  }>("./locales/en/**/*.json", { eager: true });

  const ptBrContext: Context = import.meta.glob<{
    default: Record<string, string>;
  }>("./locales/pt-br/**/*.json", { eager: true });

  const processContext = (context: Context, lang: Language) => {
    Object.keys(context).forEach((path) => {
      const filename = path.match(/\/([^/]+)\.json$/)?.[1];
      const module = context[path];

      resources[lang][filename] = "default" in module ? module.default : module;
    });
  };

  processContext(enContext, Language.EN);
  processContext(ptBrContext, Language.PT_BR);

  return resources;
}

const resources = loadTranslationFiles();
const cookieOptions = { expires: 365 };

i18n
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    resources,
    fallbackLng: Language.EN,
    detection: {
      order: ["cookie", "navigator"],
      lookupCookie: "i18next",
    },
    interpolation: { escapeValue: false },
    ns: Object.keys(resources.en),
  });

export const changeLanguage = (lang: Language) => {
  i18n.changeLanguage(lang);
  Cookies.set("i18next", lang, cookieOptions);
};

export default i18n;

Breaking Down the Configuration

Let's understand how this configuration works:

a) Language Enum:
First, we define a Language enum that contains all supported languages. This provides type safety throughout the application:

// src/domain/enums/language.ts
   export enum Language {
     EN = "en",
     PT_BR = "pt-br",
   }

b) Resource Structure:
We create TypeScript types to ensure proper typing for our translation resources:

type Context = Record<string, { default: Record<string, string> }>;
   type Resources = Record<string, Record<string, Record<string, string>>>;

c) Automatic Resource Discovery:
The loadTranslationFiles function is where the magic happens:

const enContext: Context = import.meta.glob<{
     default: Record<string, string>;
   }>("./locales/en/**/*.json", { eager: true });

This uses Vite's glob import to find all JSON files within the locales directory for each language. The eager: true option ensures these are loaded synchronously.

d) Resource Processing:
The processContext function extracts the namespace from each file path and organizes translations:

const processContext = (context: Context, lang: Language) => {
     Object.keys(context).forEach((path) => {
       const filename = path.match(/\/([^/]+)\.json$/)?.[1];
       const module = context[path];

       resources[lang][filename] =
         "default" in module ? module.default : module;
     });
   };

This extracts the filename (which becomes the namespace) using regex and adds the translations to the appropriate language and namespace.

e) Language Detection and Persistence:

detection: {
     order: ['cookie', 'navigator'],
     lookupCookie: 'i18next',
   }

This configuration:

  • Checks cookies first, then browser settings for language preference
  • Uses a cookie named 'i18next' to store the preference

f) Language Change Helper:

export const changeLanguage = (lang: Language) => {
     i18n.changeLanguage(lang);
     Cookies.set("i18next", lang, cookieOptions);
   };

This function provides a convenient way to change languages and persist the preference in a cookie that can be used across the application

Translation Organization and Usage

With automatic configuration in place, organize translations by feature or context:

src/
└── i18n/
    ├── config.ts
    └── locales/
        ├── en/
        │   ├── common.json
        │   ├── index.json
        ├── pt-br/
        │   ├── common.json
        │   ├── index.json

Example translation files:

// src/i18n/locales/en/index.json
{
  "greeting": "Hello, World!",
  "languageSelector": "Select a language",
  "languages": {
    "en": "English",
    "pt-BR": "Portuguese"
  }
}
// src/i18n/locales/pt-BR/index.json
{
  "greeting": "Olá, Mundo!",
  "languageSelector": "Selecione um idioma",
  "languages": {
    "en": "Inglês",
    "pt-BR": "Português"
  }
}

The key advantage: any new JSON file added to these folders is automatically detected and processed without code changes.

Using translations in components is straightforward:

const App = () => {
  const { t } = useTranslation();

  return (
    <div className="min-h-screen flex flex-col bg-[#101010]">
      <Header />
      <main className="flex-1 flex items-center justify-center p-6">
        <p className="text-white text-2xl">{t("index:greeting")}p>
      main>
    div>
  );
};

Extending Your i18n System

Adding a new language is simple:

a) Update the Language enum:

export enum Language {
  EN = "en",
  PT_BR = "pt-br",
  ES = "es", // New language
}

b) Modify the config.ts file:

const esContext: Context = import.meta.glob<{
  default: Record<string, string>;
}>("./locales/es/**/*.json", { eager: true });

processContext(esContext, Language.ES);

c) Create corresponding translation files in a new folder structure:

src/i18n/locales/es/
├── common.json
├── index.json

Final Thoughts

Our i18n implementation offers several key advantages:

  • Modularity: Translations organized by feature/context for easy maintenance
  • Automation: New files automatically detected and processed
  • Type Safety: Using TypeScript enums prevents errors
  • Persistence: User language preferences are remembered
  • Scalability: Adding new languages or namespaces is simple

This approach works particularly well for growing applications, allowing team members to work on different features without conflicts. For more information, check out the documentation for react-i18next, i18next, and js-cookie.