Skip to content

Frontend internationalization (React)

The React frontend uses an in-house i18n system, with no external dependencies (no react-intl, no i18next). The implementation fits in a single file (I18nContext.jsx) about sixty lines long and covers every need of the shop and the pedagogical journey.

Sources

frontend/src/i18n/I18nContext.jsx, frontend/src/i18n/fr.json, frontend/src/i18n/en.json

Why not react-intl or i18next?

PerfShop doesn't need the advanced features of those libraries:

  • No complex pluralization (the rare cases are handled manually with a ternary)
  • No localized date / number formatting (dates already follow the browser locale via toLocaleDateString())
  • No dynamic loading on the fly (both languages are imported statically at build time)
  • No automatic detection (the language is fixed at deployment via VITE_LANG)

A minimal useT() hook and two functions t() / tErr() cover 100% of the needs. The gain: zero additional weight in the bundle, full readability, and complete control over the behavior.

Files

frontend/src/i18n/
├── I18nContext.jsx   ← Provider + useT() hook
├── fr.json           ← French dictionary (~23 KB, ~400 keys)
└── en.json           ← English dictionary (~21 KB, ~400 keys)

Each JSON is flat — no nested structure:

{
  "nav.logo": "PerfShop",
  "nav.catalog": "Catalog",
  "nav.orders": "My orders",
  "nav.account": "My account",
  "nav.logout": "Log out",
  "nav.login": "Log in",
  "nav.cartCount": "Cart ({count})",

  "products.title": "Our products",
  "products.empty": "No products in this category",
  "products.addToCart": "Add to cart",

  "api.error.loadProducts": "Unable to load products",
  "api.error.cartEmpty": "Your cart is empty"
}

The flat structure is a deliberate choice: it makes lookups trivial (dict[key]), simplifies FR/EN comparison, and avoids the complexity of nested paths.

I18nContext.jsx

The provider is short and readable:

import React, { createContext, useContext, useMemo } from 'react';
import fr from './fr.json';
import en from './en.json';

const dictionaries = { fr, en };

const I18nContext = createContext({
  t: (k) => k, tErr: (k) => k, lang: 'fr', DATE_LOCALE: 'fr-FR'
});

export function I18nProvider({ children }) {
  const lang = (import.meta.env.VITE_LANG || 'fr').toLowerCase();
  const safeLocale = lang === 'en' ? 'en-GB' : `${lang}-${lang.toUpperCase()}`;

  const value = useMemo(() => {
    const dict = dictionaries[lang] || dictionaries.fr;

    const t = (key, replacements) => {
      let text = dict[key] ?? dictionaries.fr[key] ?? key;
      if (replacements) {
        Object.entries(replacements).forEach(([k, v]) => {
          text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v));
        });
      }
      return text;
    };

    const tErr = (msg) => {
      if (!msg) return '';
      const resolved = dict[msg] ?? dictionaries.fr[msg];
      return resolved !== undefined ? resolved : msg;
    };

    return { t, tErr, lang, DATE_LOCALE: safeLocale };
  }, [lang, safeLocale]);

  React.useEffect(() => {
    document.documentElement.lang = lang;
  }, [lang]);

  return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}

export function useT() {
  return useContext(I18nContext);
}

How it works in detail

Language determination

import.meta.env.VITE_LANG is injected by Vite at build time, and possibly rewritten at runtime by env-inject.sh in the Nginx container. The default value is fr. It is lowercased to avoid any typing errors.

const lang = (import.meta.env.VITE_LANG || 'fr').toLowerCase();

Dictionary selection

const dict = dictionaries[lang] || dictionaries.fr;

If the requested language does not exist (e.g. VITE_LANG=xx), the fallback is automatically the French dictionary.

t() function

Three fallback levels:

let text = dict[key] ?? dictionaries.fr[key] ?? key;
  1. The active language dictionary
  2. The French dictionary as fallback
  3. The key itself as a last resort (never breaks, eases debugging)

Placeholder substitution uses the {name} format:

t('nav.cartCount', { count: 3 })
// → "Cart (3)"

Placeholders are named (and not numbered like in the backend MessageFormat). It is a different convention but equally readable: {count}, {email}, {status}.

tErr() function

This helper function resolves error messages that can be either an i18n key (coming from the API client) or a raw server string:

const tErr = (msg) => {
  if (!msg) return '';
  const resolved = dict[msg] ?? dictionaries.fr[msg];
  return resolved !== undefined ? resolved : msg;
};

The services/api.js client throws Error objects whose message is often a key like 'api.error.loadProducts'. tErr() tries to resolve this key; if it doesn't exist in the dictionary, it considers it's already a raw message returned by the server and displays it as-is. This lets the frontend handle both cases uniformly.

Typical usage:

try {
  const data = await getProducts();
  setProducts(data);
} catch (err) {
  setError(tErr(err.message));
}

Updating the <html lang> attribute

A useEffect synchronizes the lang attribute of the <html> tag with the active language. This is important for:

  • Accessibility — screen readers use this attribute to determine the language to pronounce
  • SEO — search engines use it to correctly index linguistic versions
  • Font rendering — some fonts have different glyphs depending on the language (notably for Asian languages)

Date format

The context also exposes a DATE_LOCALE constant derived from the language:

const safeLocale = lang === 'en' ? 'en-GB' : `${lang}-${lang.toUpperCase()}`;
lang DATE_LOCALE Date example
fr fr-FR 15 janvier 2026
en en-GB 15 January 2026
es es-ES 15 de enero de 2026
de de-DE 15. Januar 2026
it it-IT 15 gennaio 2026

Choosing en-GB (and not en-US) for English is deliberate: it produces a day/month/year format consistent with the other Latin locales, rather than the American month/day/year format which could confuse European users.

Pages consume this constant to format dates:

const { t, DATE_LOCALE } = useT();
const dateStr = new Date(order.createdAt).toLocaleDateString(DATE_LOCALE);

The useT() hook

The useT() hook is imported from '../i18n/I18nContext' in each component that needs translations:

import { useT } from '../i18n/I18nContext';

export default function Cart({ cart }) {
  const { t, tErr, DATE_LOCALE } = useT();
  return (
    <div>
      <h1>{t('cart.title')}</h1>
      {cart.length === 0 ? (
        <p>{t('cart.empty')}</p>
      ) : (
        <p>{t('cart.itemCount', { count: cart.length })}</p>
      )}
    </div>
  );
}

That's all. No <Trans> component, no HOC, no translation bag to pass as a prop.

FR/EN symmetry

Both JSON files must contain exactly the same keys. A quick diff:

jq 'keys | sort' frontend/src/i18n/fr.json > /tmp/fr.keys
jq 'keys | sort' frontend/src/i18n/en.json > /tmp/en.keys
diff /tmp/fr.keys /tmp/en.keys

This diff must be empty. Any divergence is a drift to correct.

At the time of writing, symmetry is at ~400 keys in FR / ~400 keys in EN.

Adding a key

  1. Add the key in French in frontend/src/i18n/fr.json
  2. Add the same key in English in en.json
  3. Rebuild (Vite HMR handles it automatically)
  4. Use from a component: const { t } = useT(); ... {t('my.new.key')}

Adding a new language

Three steps:

  1. Create frontend/src/i18n/<lang>.json by copying fr.json and translating the values
  2. Import it at the top of I18nContext.jsx:
    import fr from './fr.json';
    import en from './en.json';
    import es from './es.json';  // new
    
    const dictionaries = { fr, en, es };
    
  3. Deploy with VITE_LANG=es

No other file to touch. DATE_LOCALE is computed automatically following the {lang}-{LANG.upper} convention.

What stays untranslated

A few elements are intentionally left in English or hard-coded:

  • CSS IDs and classes — obviously
  • Console logsconsole.log and console.error calls are in English to be understandable by the international development team
  • Code comments — mostly in French because the author is French-speaking, but some are in English for parts intended to be shared

This rule is pragmatic: translate what is visible to the user, leave code-level content as-is.

See also