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.
Table of Contents
- Core Concepts and Setup: Understanding i18n and project initialization
- Automatic Configuration: The magic of auto-detecting translations
- Translation Organization and Usage: Structuring and implementing translations
- 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.