Internationalization
How Luminaux translates every string with react-i18next — flat namespaced keys, eager English, per-namespace lazy loading, and eight languages.
Luminaux ships a real, working multi-language layer built on react-i18next + i18next. UI text
is stored as flat, namespaced keys (e.g. t('nav:dashboard')), English is bundled eagerly for an
instant first paint, every other language lazy-loads per namespace, and a DeepL batch translator
(npm run i18n:translate) fills the target languages from English.
Overview
The app is fully translatable. Every user-facing string is a key looked up at render time through
react-i18next, so switching language re-renders the whole UI without a reload.
Key design decisions:
- Flat keys, namespaced with
:. i18next is configured withkeySeparator: false, so keys are literal strings (no nested objects) — you address them ast('<namespace>:<key>'), e.g.t('nav:dashboard'),t('common:export'). Why flat? The DeepL translate tool only round-trips flat JSON objects (one level of"key": "value"pairs); nested objects would break the hash-caching and key-diffing logic. Keep every locale file flat. - English is the source of truth and is bundled eagerly.
enships in the main bundle viaimport.meta.glob('/src/locales/en/*.json', { eager: true }), so the first paint is never a flash of raw keys.fallbackLng: 'en'— any missing key in another language falls back to English at runtime. - Other languages lazy-load per file. Non-English namespaces are fetched on demand through
i18next-resources-to-backend; Vite emits one chunk per namespace file, so only the active language's namespaces download. - Language is detected + persisted.
i18next-browser-languagedetectorreadslocalStorage('language')first, then the browser'snavigatorlanguage, and caches the choice back tolocalStorage. <html lang>stays in sync. Set pre-paint inindex.htmland updated on everylanguageChanged, which also drives the CJK font swap for Japanese and Chinese.
Architecture & files
| File | Responsibility |
|---|---|
src/i18n/index.ts | i18next bootstrap — plugins, eager en load, lazy backend, detection, flat-key config, <html lang> sync. |
src/i18n/languages.ts | LANGUAGES descriptors, LANGUAGE_CODES, DEFAULT_LANGUAGE, NAMESPACES. |
src/locales/<lng>/<ns>.json | The translations — one flat JSON file per namespace per language. |
src/locales/en/manifest.json | The canonical namespace list (source-of-truth for the folder's contents). |
src/hooks/useLanguage.ts | Thin wrapper over react-i18next for the switcher UI. |
src/layout/LanguageMenu.tsx | Header globe dropdown (flag trigger → grid of languages). |
tools/translate/translate.mjs | DeepL batch translator (source EN → all targets, hash-cached). |
index.html | Pre-paint <html lang> script + Google Fonts (incl. Noto Sans JP/SC). |
src/styles/index.css | html[lang='ja'] / html[lang='zh'] font-variable overrides. |
Namespaces
Namespaces split translations by feature so files stay small and lazy chunks stay focused. The code
declares them in src/i18n/languages.ts:
export const NAMESPACES = [
'common', 'nav', 'header', 'footer', 'customizer', 'search',
'dashboard', 'pages', 'chat', 'notifications', 'scrumboard',
'calendar', 'pricing', 'contacts', 'email', 'forms', 'demo',
'faq', 'invoice', 'cookies', 'settings', 'errors', 'auth', 'widgets',
] as const24 namespaces — src/locales/en/manifest.json lists the same set (keep the two aligned when adding
one). defaultNS is 'common', so t('save') resolves to common:save — but prefer the explicit
t('common:save') form for clarity. The other namespaces map to feature areas: nav (sidebar +
mega-menu, mm-prefixed), header, footer, customizer (Layout Customizer + Layout Settings),
search, dashboard, pages (generic page copy), chat, notifications, scrumboard, calendar,
pricing, contacts, email (email-template page chrome), forms (the src/pages/forms/* pages),
demo (the /ui/* + /charts/* showcase text — section labels and sample content), faq,
invoice, cookies (cookie consent), settings (the settings hub), errors (error pages), auth
(the auth screens), widgets (the widgets gallery).
The email namespace is chrome-only by design
The email HTML artifacts themselves (subjects, body copy, CTAs) stay literal English — buyers copy them
out and edit them, and markup-laden strings don't survive the flat-JSON translate pipeline. Only the
page chrome, including the subject line displayed in the template list (subjectKey), is translated.
See Email Templates.
Languages
Eight languages are declared in src/i18n/languages.ts (matching the translate tool's targets plus the
en source):
export const LANGUAGES: Language[] = [
{code: 'en', label: 'Global', native: 'English', flag: '/flags/en.svg'},
{code: 'fr', label: 'French', native: 'Français', flag: '/flags/fr.svg'},
{code: 'nl', label: 'Dutch', native: 'Nederlands', flag: '/flags/nl.svg'},
{code: 'de', label: 'German', native: 'Deutsch', flag: '/flags/de.svg'},
{code: 'es', label: 'Spanish', native: 'Español', flag: '/flags/es.svg'},
{code: 'pt', label: 'Portuguese', native: 'Português', flag: '/flags/pt.svg'},
{code: 'zh', label: 'Chinese', native: '中文', flag: '/flags/zh.svg'},
{code: 'ja', label: 'Japanese', native: '日本語', flag: '/flags/ja.svg'},
]
export const DEFAULT_LANGUAGE = 'en'Each Language has a code, an English label, a native endonym (shown in the picker), and a flag
(an SVG served from public/flags/<code>.svg — all eight flag SVGs are present). Flags are
referenced as absolute public paths, not remote URLs.
Translation status (as shipped). The declared language set (8) is ahead of the seeded content. Honest current state on disk:
| Language | src/locales/<lng>/ folder | Status |
|---|---|---|
en | present (24 ns files + manifest) | Complete — source of truth, bundled eagerly. |
ja | present (24 ns files) | Complete — hand-seeded at full key parity with en (verified per namespace). |
de | present (8 files) | Partial — a subset of namespaces. |
fr | present (8 files) | Partial — a subset of namespaces. |
nl, es, pt, zh | no folder yet | Declared + flag present, but not generated. Run the translate tool to create them. |
Because fallbackLng is en, any missing language or missing key degrades gracefully to English — the
UI never shows raw keys. To fill the gaps, run npm run i18n:translate (see
Translation Tool), which generates fr/nl/de/es/pt/zh/ja from
en/.
Plural note
Japanese needs no _one plural keys — its CLDR plural rules only use the _other category, so a ja
file having fewer keys than en for pluralized entries is correct, not a gap.
Switcher UI
Users change language in two places, both driven by useLanguage():
- Header —
src/layout/LanguageMenu.tsx, a globe/flag trigger that opens a two-column grid of flag- native name (with English label as a subline) and a check on the active one.
- Customizer — a "Language" section in the Layout Customizer / Layout Settings.
Usage
Render any string through useTranslation() from react-i18next:
import {useTranslation} from '@/platform/i18n'
export function ExportButton() {
const {t} = useTranslation()
return <button>{t('common:export')}</button>
}You can scope a component to a namespace so its keys don't need the prefix:
const {t} = useTranslation('header')
// t('language') === t('header:language')Change the active language (persists automatically via the detector):
import {useLanguage} from '@/hooks/useLanguage'
function Example() {
const {code, setLanguage} = useLanguage()
return <button onClick={() => setLanguage('ja')}>{code}</button>
}Configuration reference
i18next configuration (src/i18n/index.ts)
| Option | Value | Notes |
|---|---|---|
keySeparator | false | Flat keys — no nested lookups. Required by the DeepL tool. |
fallbackLng | 'en' (DEFAULT_LANGUAGE) | Missing keys/languages fall back to English. |
supportedLngs | LANGUAGE_CODES | The 8 declared codes. |
nonExplicitSupportedLngs | true | Maps region variants (e.g. en-US → en). |
ns | NAMESPACES | All namespaces registered up front. |
defaultNS | 'common' | Unprefixed keys resolve here. |
resources | { en: enResources } | English bundled eagerly. |
partialBundledLanguages | true | en bundled; others come from the backend. |
detection.order | ['localStorage', 'navigator'] | localStorage first, then browser. |
detection.lookupLocalStorage | 'language' | The persisted key. |
detection.caches | ['localStorage'] | Choice cached back to localStorage. |
interpolation.escapeValue | false | React already escapes. |
react.useSuspense | false | No Suspense boundary needed for lazy namespaces. |
useLanguage() (src/hooks/useLanguage.ts)
| Member | Type | Description |
|---|---|---|
language | Language | Active language descriptor (code/label/native/flag). |
code | string | Active language code, e.g. 'en'. |
languages | Language[] | All selectable languages (LANGUAGES). |
setLanguage | (code: string) => Promise<TFunction> | Switch language; persists via the detector. |
Internally it resolves the current language from i18n.resolvedLanguage ?? i18n.language ?? 'en' and
matches it against LANGUAGES (falling back to the first entry).
Adding UI text, a namespace, or a language
- Add the key to the correct English namespace file —
src/locales/en/<ns>.json— as a flat"key": "value"pair. 2. Render it viat('<ns>:<key>'). 3. Runnpm run i18n:translateto fill the other languages (or leave it — English is the fallback until you do). Never hardcode a user-facing string; data-driven text stores keys (item.key,roleKey, messagekey) resolved at render, while proper nouns, numbers, and the brand name stay literal.
Generating translations
# needs one or more DeepL keys in .env (see .env.example)
npm run i18n:translateYou can scope a run to make it smaller and more manageable:
npm run i18n:translate -- --lang fr # one language, all namespaces
npm run i18n:translate -- --ns dashboard,demo # all languages, specific namespaces
npm run i18n:translate -- --lang ja --ns forms # one language, one namespaceCheck each key's remaining monthly quota anytime (free — costs no characters):
npm run i18n:usageFull translator details
Multi-key rotation, resume, placeholder protection, the CLI flags, and the usage checker are all
documented in Translation Tool. During development only ja is
maintained by hand (now at full parity with en); de / fr hold partial tool-generated subsets from
an earlier run. Once the UI copy is stable, run the tool once to fill everything.
Examples
Component with a namespaced key (tsx)
import {useTranslation} from '@/platform/i18n'
export function PageHeaderTitle() {
const {t} = useTranslation('nav')
return <h1>{t('dashboard')}</h1> // resolves nav:dashboard
}A flat locale file (json)
src/locales/en/nav.json:
{
"main": "Main",
"uiKit": "UI Kit",
"pages": "Pages",
"dashboard": "Dashboard",
"ecommerce": "eCommerce"
}Interpolation (json + tsx)
{ "rights": "© {{year}} {{brand}}. All rights reserved." }const {t} = useTranslation('footer')
t('footer:rights', {year: 2026, brand: 'Luminaux'})Best practices
- Always add new copy to
en/first. English is the source; everything else is generated or falls back to it. - Keep locale files flat. No nested objects — the translate tool and hash-cache depend on one level of key/value pairs.
- Use
:-namespaced keys (t('chat:send')), and pick the namespace by feature so lazy chunks stay small. - Store keys, not strings, in data. Menu, chat, roles, and other data-driven text carry keys resolved at render.
- Never give dates/months i18n keys. Generate them from the active language with the pure
Intlhelpers insrc/lib/dates.ts(monthsShort/monthDayShort/relTimeShort), or passi18n.languageinto thedata/*formatters. FullCalendar gets its own locale viaCalendarView'slocaleprop. - Persisted seed labels use render-time mapping. Seeded values that end up in
localStorage(scrumboard task labels, contacts tags/interview locations) can't be translated in place — the store mixes seeds with user-typed values. Instead thedata/*module maps known seed values → i18n keys at render (LABEL_KEYSinsrc/data/scrumboard.ts,TAG_KEYS/LOCATION_KEYSinsrc/data/contacts.ts); unknown user-created values render as typed. - Don't translate proper nouns, brand, or numbers — leave them literal or interpolate them.
- Register the switch, not the value. Language is persisted through the detector to
localStorage('language'); don't write that key by hand. - Keep
NAMESPACESandmanifest.jsonin sync when you add/remove a namespace.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
A raw key (e.g. nav:foo) shows instead of text | Key missing in en/<ns>.json, or namespace not registered | Add the key to the English file; ensure the namespace is in NAMESPACES. |
| A string stays English in another language | That language/namespace not generated yet | Run npm run i18n:translate; confirm the language folder + file exist. |
npm run i18n:translate exits with "Missing DEEPL_API_KEY" | No API key | Add DEEPL_API_KEY to .env (the script runs via node --env-file=.env). |
| A new namespace never loads for non-EN languages | File not on disk / not in the lazy glob path | It must live at src/locales/<lng>/<ns>.json; run the translate tool to create it. |
| Language doesn't persist across reloads | localStorage blocked, or detection order changed | Detection is localStorage → navigator, cached to localStorage('language'); check browser storage. |
| CJK text renders in a fallback font | Font override / webfont missing | Ensure the html[lang='ja'] / html[lang='zh'] override in index.css and the Noto webfont link in index.html. |
Region locale (e.g. en-GB) not matched | — | Handled by nonExplicitSupportedLngs: true (maps to the base code); no action needed. |
FAQ
Why flat keys instead of nested JSON? The DeepL batch translator only handles flat JSON — it hashes
and diffs top-level values. Nesting would break caching and key pruning. Hence keySeparator: false.
How is a missing translation handled? fallbackLng: 'en' — the English value is used at runtime, so
users never see a raw key.
Do all languages download up front? No. Only en is bundled eagerly. Other languages lazy-load per
namespace (one Vite chunk each) when selected.
Where is the active language stored? In localStorage('language'), written by the language detector
and also read pre-paint in index.html to set <html lang> before React mounts.
Can I add a language the DeepL tool doesn't support? Yes — add it to LANGUAGES + the index.html
allowlist and hand-author its locale files; just don't add it to the tool's targets.
Notes for designers & content editors
- All copy lives in JSON. To change wording, edit
src/locales/en/<namespace>.json— find the key, edit the value. No code changes needed for English copy. - Keep placeholders intact. Tokens like
{{year}}or{{brand}}are interpolated at runtime — keep them exactly (same braces, same name) when editing or translating. - English is the master. Edit English, then regenerate other languages with
npm run i18n:translate. Editing a non-English file directly works, but a later run of the tool may overwrite it if the English source changed. - Native names + flags in the switcher come from
src/i18n/languages.ts(native) andpublic/flags/<code>.svg. Swap the SVG to restyle a flag. - CJK typography. Japanese and Chinese switch to Noto Sans JP / Noto Sans SC automatically; other languages use the default Plus Jakarta Sans.
- Don't translate the brand. The product name and proper nouns stay literal.
Related
Translation Tool
The DeepL batch translator in depth — rotation, resume, CLI, usage checker.
Getting Started
Install, scripts, and the DEEPL_API_KEY environment variable.
Header
The language switcher lives in the header cluster.
Customizer & Settings
The Language section mirrors the header switcher.
Utilities & API Client
Other build-time and runtime helpers.
Was this page helpful?
