DEV Community

Cover image for Building a Scalable i18n System in React
renan leonel
renan leonel

Posted on

Building a Scalable i18n System in React

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
Enter fullscreen mode Exit fullscreen mode

First, define a Language enum for type safety:

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

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;
Enter fullscreen mode Exit fullscreen mode

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",
   }
Enter fullscreen mode Exit fullscreen mode

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>>>;
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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;
     });
   };
Enter fullscreen mode Exit fullscreen mode

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',
   }
Enter fullscreen mode Exit fullscreen mode

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);
   };
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Example translation files:

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

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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

src/i18n/locales/es/
├── common.json
├── index.json
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)