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.
Dictionary selection¶
If the requested language does not exist (e.g. VITE_LANG=xx), the fallback is automatically the French dictionary.
t() function¶
Three fallback levels:
- The active language dictionary
- The French dictionary as fallback
- The key itself as a last resort (never breaks, eases debugging)
Placeholder substitution uses the {name} format:
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:
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¶
- Add the key in French in
frontend/src/i18n/fr.json - Add the same key in English in
en.json - Rebuild (Vite HMR handles it automatically)
- Use from a component:
const { t } = useT(); ... {t('my.new.key')}
Adding a new language¶
Three steps:
- Create
frontend/src/i18n/<lang>.jsonby copyingfr.jsonand translating the values - Import it at the top of
I18nContext.jsx: - 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 logs —
console.logandconsole.errorcalls 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¶
- i18n overview
- Backend internationalization
- E-commerce frontend — where i18n is consumed