Abstract

Internationalization A Native Approach

i18n/l10n architecture in React using native web standards.


Elysee
4 min
December 7, 2025

programming

I18n and L10n are the respective abbreviations for internationalization and localization. This is a conventional workflow that consists of preparing and adapting a web application for multilingualism and different cultures.

In this article, I propose an architectural approach in React based on Contexts, Hooks, and centered on the browser’s native Intl API. The goal? Master the data flow and understand the approach of frameworks that offer ready-made solutions, by building our own from scratch.

API Structure

The approach relies on three fundamental pillars:

1- Global State Management: A TranslatorProvider that wraps the application and distributes via the Context API the active locale, the dynamically loaded translation dictionary, and the translation function.

The useTranslator hook then exposes a unified API to access translations (__), date formats (dateFormat), and numeric formats (numberFormat).

2- Native Standardization: Delegate the complexity of formatting (dates, numbers, plurals) to the browser’s Intl API.

3- Static Tooling: A static analysis script that automatically extracts translation keys from the source code via regex, then synchronizes the JSON files for each locale by adding new keys and removing obsolete ones. This ensures that dictionaries always remain consistent with the code.

Data Architecture

My approach to data structuring naturally turned to JSON. Given the number of native browser methods that allow parsing of this format (JSON.parse, fetch, etc.), I chose to rely on this simplicity.

{
  "Hello": "Bonjour",
  "Goodbye": "Au revoir"
}

State Management (State Sharing)

The architecture relies on React’s Provider/Consumer pattern. The TranslatorProvider makes the dictionary available to the Context, and components consume it via the useTranslator hook.

// TranslatorProvider.tsx
export function TranslatorProvider({ children, lang, loader }: Props) {
  const [langV, setLangV] = useState<Langs>(lang);
  const [translations, setTranslations] = useState<Record<string, string>>({});

  useEffect(() => {
    loader(langV).then(setTranslations);
  }, [langV, loader]);

  const translate = useCallback(
    (s: string) => translations[s] || s,
    [translations]
  );

  return (
    <TranslateContext.Provider value={{ translate, langV, setLangV }}>
      {children}
    </TranslateContext.Provider>
  );
}
export function useTranslator() {
  const { translate, langV, setLangV } = useContext(TranslateContext);

  const dateFormat = (date: Date | string | number | null,
    options: Intl.DateTimeFormatOptions = {
      year: "numeric",
      month: "long",
      day: "numeric",
      weekday: "long",
    }
  ) => {...};

  const styleForNumber = (options: Options): Intl.NumberFormatOptions ...

  const numberFormat = ( value: number | string | null, options: Options = {
      style: "decimal",
    }
  ) => {...};

  return {
    langV,
    setLangV,
    dateFormat: dateFormat,
    numberFormat,
    __: translate,
  };
}

Why this approach? It separates the loader loading from the Provider distribution. This makes the system agnostic: translations can come from a local JSON file, a REST API, or a headless CMS.

Abstraction around Intl

The browser’s Intl API covers most internationalization needs for a web application. It contains all the world’s linguistic and typographic rules: date formats by country, currency symbols, pluralization rules, etc.

A lightweight facade is sufficient to adapt this API to our needs. The idea is to encapsulate the verbosity of Intl (constructors, multiple options) behind simple functions (dateFormat, numberFormat) that automatically respect the active locale.

// useTranslator.ts
const styleForNumber = (options: Options): Intl.NumberFormatOptions => {
  switch (options.style) {
    case "currency":
      return {
        style: "currency",
        // Currency is dynamically deduced from the locale (e.g., FR -> EUR)
        currency: CurrencyCode[langV.toUpperCase()],
      };
    case "unit":
      return {
        style: "unit",
        unit: options.unit, // e.g., "kilometer"
      };
    // ...
  }
};

This function acts as a normalizer that ensures compliance with typographic conventions according to the active locale: non-breaking space, currency symbol position, decimal separator (comma or period).

Maintenance Automation

For maintenance and developer experience reasons, I implemented a script that analyzes the source code and automatically synchronizes translation files.

The tool scans all TypeScript/React files to detect calls to __() via regex, then updates the dictionaries for each locale.

// scripts/collect.ts
function collectStrings() {
  const files = globSync(sourcePath);
  const strings = new Set<string>();

  for (const file of files) {
    const content = readFileSync(file, "utf8");
    const found = extractStringsFromContent(content);
    found.forEach((s) => strings.add(s));
  }
  return strings;
}

// Synchronization: adding new keys, removing obsolete ones
for (const newKey of keys.difference(existingKeys)) {
  dict.set(newKey, "");
}

A simple command (node scripts/collect.ts or via your task runner) ensures that JSON files remain perfectly aligned with the code.

Concrete Application

The interface below reacts to locale changes:

  • Adapted date formats
  • Localized currencies (€ vs $)
  • Regional units (km, °C)

Conclusion

This architecture illustrates that modern browsers already provide many powerful abstractions. These standards are optimized and maintained by browser vendors.

The complete implementation (Provider, Hook, extraction script) is available on GitHub for experimentation and adaptation to your projects.