PVR Tech Studio

Internationalization

How Luminaux translates every string with react-i18next — flat namespaced keys, eager English, per-namespace lazy loading, and eight languages.

10 min read
Updated July 15, 2026

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 with keySeparator: false, so keys are literal strings (no nested objects) — you address them as t('<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. en ships in the main bundle via import.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-languagedetector reads localStorage('language') first, then the browser's navigator language, and caches the choice back to localStorage.
  • <html lang> stays in sync. Set pre-paint in index.html and updated on every languageChanged, which also drives the CJK font swap for Japanese and Chinese.

Architecture & files

FileResponsibility
src/i18n/index.tsi18next bootstrap — plugins, eager en load, lazy backend, detection, flat-key config, <html lang> sync.
src/i18n/languages.tsLANGUAGES descriptors, LANGUAGE_CODES, DEFAULT_LANGUAGE, NAMESPACES.
src/locales/<lng>/<ns>.jsonThe translations — one flat JSON file per namespace per language.
src/locales/en/manifest.jsonThe canonical namespace list (source-of-truth for the folder's contents).
src/hooks/useLanguage.tsThin wrapper over react-i18next for the switcher UI.
src/layout/LanguageMenu.tsxHeader globe dropdown (flag trigger → grid of languages).
tools/translate/translate.mjsDeepL batch translator (source EN → all targets, hash-cached).
index.htmlPre-paint <html lang> script + Google Fonts (incl. Noto Sans JP/SC).
src/styles/index.csshtml[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 const

24 namespacessrc/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).

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:

Languagesrc/locales/<lng>/ folderStatus
enpresent (24 ns files + manifest)Complete — source of truth, bundled eagerly.
japresent (24 ns files)Complete — hand-seeded at full key parity with en (verified per namespace).
depresent (8 files)Partial — a subset of namespaces.
frpresent (8 files)Partial — a subset of namespaces.
nl, es, pt, zhno folder yetDeclared + 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/.

Switcher UI

Users change language in two places, both driven by useLanguage():

  • Headersrc/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)

OptionValueNotes
keySeparatorfalseFlat keys — no nested lookups. Required by the DeepL tool.
fallbackLng'en' (DEFAULT_LANGUAGE)Missing keys/languages fall back to English.
supportedLngsLANGUAGE_CODESThe 8 declared codes.
nonExplicitSupportedLngstrueMaps region variants (e.g. en-USen).
nsNAMESPACESAll namespaces registered up front.
defaultNS'common'Unprefixed keys resolve here.
resources{ en: enResources }English bundled eagerly.
partialBundledLanguagestrueen 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.escapeValuefalseReact already escapes.
react.useSuspensefalseNo Suspense boundary needed for lazy namespaces.

useLanguage() (src/hooks/useLanguage.ts)

MemberTypeDescription
languageLanguageActive language descriptor (code/label/native/flag).
codestringActive language code, e.g. 'en'.
languagesLanguage[]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

  1. Add the key to the correct English namespace file — src/locales/en/<ns>.json — as a flat "key": "value" pair. 2. Render it via t('<ns>:<key>'). 3. Run npm run i18n:translate to 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, message key) 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:translate

You 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 namespace

Check each key's remaining monthly quota anytime (free — costs no characters):

npm run i18n:usage

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 Intl helpers in src/lib/dates.ts (monthsShort / monthDayShort / relTimeShort), or pass i18n.language into the data/* formatters. FullCalendar gets its own locale via CalendarView's locale prop.
  • 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 the data/* module maps known seed values → i18n keys at render (LABEL_KEYS in src/data/scrumboard.ts, TAG_KEYS / LOCATION_KEYS in src/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 NAMESPACES and manifest.json in sync when you add/remove a namespace.

Troubleshooting

SymptomLikely causeFix
A raw key (e.g. nav:foo) shows instead of textKey missing in en/<ns>.json, or namespace not registeredAdd the key to the English file; ensure the namespace is in NAMESPACES.
A string stays English in another languageThat language/namespace not generated yetRun npm run i18n:translate; confirm the language folder + file exist.
npm run i18n:translate exits with "Missing DEEPL_API_KEY"No API keyAdd DEEPL_API_KEY to .env (the script runs via node --env-file=.env).
A new namespace never loads for non-EN languagesFile not on disk / not in the lazy glob pathIt must live at src/locales/<lng>/<ns>.json; run the translate tool to create it.
Language doesn't persist across reloadslocalStorage blocked, or detection order changedDetection is localStoragenavigator, cached to localStorage('language'); check browser storage.
CJK text renders in a fallback fontFont override / webfont missingEnsure 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 matchedHandled 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) and public/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.

Was this page helpful?