Internationalization (i18n)
The template ships a real, working multi-language layer built on next-intl, rendered on the server.
Overview
The app is fully translatable. Every user-facing string is a key looked up at render time, so switching language
navigates to that locale's URL and the new language arrives already rendered.
Key design decisions:
- Flat keys, namespaced with
:. 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 every locale is complete. next-intl has no cross-locale fallback, so the message build backfills English into any key a translation is missing. A partially translated language therefore renders English for the gaps rather than the raw key.
- The locale lives in the URL. Every route sits under a
[locale]segment, so/ja/apps/chatis a real, shareable, indexable address that renders Japanese server-side. Requests without a locale are redirected to the visitor's best match. - Messages are compiled, not loaded at runtime.
npm run codegenturns the flat locale files into one ICU bundle per locale, so nothing is fetched on demand and a malformed message fails the build instead of the page. <html lang>comes from the route, not a script — the server already knows the locale, which also drives the CJK font swap for Japanese and Chinese.
Architecture & files
| File | Responsibility |
|---|---|
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/platform/i18n.ts | The translation adapter every component imports — useTranslation, Trans, TFunction. |
src/hooks/useLanguage.ts | Thin wrapper 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). |
src/styles/index.css | html[lang='ja'] / html[lang='zh'] font-variable overrides. |
src/i18n/routing.ts | The locale list and URL-prefix strategy — the single place routing knows about languages. |
src/i18n/request.ts | next-intl request configuration — resolves the locale and loads that locale's compiled bundle. |
src/i18n/navigation.ts | Locale-aware Link / useRouter / usePathname, consumed through @/platform/nav. |
scripts/build-messages.mjs | Compiles the flat locale files into ICU bundles (messages/<lng>.json), with English backfill. |
messages/<lng>.json | Generated output — one ICU bundle per locale. Never edit by hand. |
src/proxy.ts | Locale detection and redirects (/ → /en), composed with the optional auth guard. |
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).
defaultNSis'common', sot('save')resolves tocommon:save. Prefer the explicitt('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(thesrc/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 — the source of truth every other language is generated from. |
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. |
Either way a gap degrades gracefully to English and the UI never shows raw keys —
at build time, via the English backfill in the message build.
To fill the gaps, run npm run i18n:translate (see below), which generates fr/nl/de/es/pt/zh/ja from en/.
Info
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(), imported from the platform adapter — never from the i18n library
directly. That indirection is what lets the same component render in both editions:
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
(navigates to the same route under the new locale prefix):
import {useLanguage} from '@/hooks/useLanguage'
function Example() {
const {code, setLanguage} = useLanguage()
return <button onClick={() => setLanguage('ja')}>{code}</button>
}API / Props
Routing configuration (src/i18n/routing.ts)
| Option | Value | Notes |
|---|---|---|
locales | LANGUAGE_CODES | The 8 declared codes — the same list the switcher and translator read. |
defaultLocale | 'en' | Used when the visitor's preference matches nothing. |
localePrefix | 'always' | Every URL carries its locale, so no route is ambiguous and all are cacheable. |
Message compilation (npm run codegen)
The flat locale files under src/locales/ remain the source of truth; the build converts them to ICU:
| Step | What it does |
|---|---|
| Compose namespaces | Merges the per-namespace files into one object per locale, keyed by namespace. |
| Interpolation | Rewrites {{name}} to ICU's {name}. |
| Plurals | Folds key_one / key_other pairs into one {count, plural, …} message. |
| English backfill | Fills any key a translation is missing, because next-intl has no cross-locale fallback. |
| Validation | Parses every message as ICU — a malformed translation fails the build, not the page. |
Because the source files are untouched, npm run i18n:translate and the DeepL workflow are unchanged.
Info
A missing key renders the key itself rather than throwing. next-intl's default is to throw, which would blank an entire page over one typo; the adapter catches it to match the React edition's behaviour.
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) => void | Switch language by navigating to the same route under that locale. |
Internally it resolves the current language from i18n.resolvedLanguage ?? i18n.language ?? 'en' and matches it against
LANGUAGES (falling back to the first entry).
Configuration & customization
Adding a UI string
- Add the key to the correct English namespace file —
src/locales/en/<ns>.json— as a flat"key": "value"pair. - Render it via
t('<ns>:<key>'). - Run
npm 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 (menu items, chat seeds, roles) stores keys (item.key,
roleKey, message key) that are resolved at render; proper nouns, numbers, and the brand name stay literal.
Adding a namespace
- Create
src/locales/en/<ns>.json(flat keys). - Add
<ns>toNAMESPACESinsrc/i18n/languages.ts. - Add
<ns>tosrc/locales/en/manifest.json(keep the manifest andNAMESPACESaligned). - Use it via
t('<ns>:<key>')oruseTranslation('<ns>'). - Run the translate tool to generate the file for the other languages.
Adding a language
- Add a
Languageentry toLANGUAGESinsrc/i18n/languages.ts(code/label/native/flag). - Add the flag SVG at
public/flags/<code>.svg. - Nothing to register for routing —
src/i18n/routing.tsreadsLANGUAGE_CODES, so the new locale's URLs (/<code>/…) start working from step 1. - If it's a target the DeepL tool should fill, add it to
TARGET_LANGUAGESandTARGET_DIRSintools/translate/translate.mjs(key = folder name, value = DeepL API language code). - For a CJK or otherwise special-font language, add an
html[lang='<code>']font override insrc/styles/index.cssand register the webfont withnext/fontin the root layout. - Run
npm run i18n:translate.
Generating translations
# needs one or more DeepL keys in .env (see .env.example)
npm run i18n:translateThis runs tools/translate/translate.mjs, which:
- Reads every
src/locales/en/*.json(exceptmanifest.json) as the source. - Translates into
fr / nl / de / es / pt (→ PT-BR) / zh / ja. - Rotates across multiple DeepL keys to spread load (avoiding 429s) and retires a key that hits its monthly quota.
- Hash-caches each English value per language (
tools/translate/.translation-cache.json) so unchanged strings are skipped; only new/changed keys are re-translated. - Saves incrementally and resumes — an interrupted run (Ctrl-C/crash) keeps finished work, and re-running continues from where it left off.
- Protects
{{placeholders}}— interpolation variables are masked before translation and restored after, so DeepL never translates a variable name. - Prunes keys that no longer exist in the English source from each target file.
- On a failed key, leaves it missing (so runtime falls back to English) and does not cache it, so the next run retries.
- Copies
manifest.jsonverbatim into each target folder, and streams live per-string progress.
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 namespaceCheck each key's remaining monthly quota anytime (free — costs no characters):
npm run i18n:usageFull details
— multi-key rotation, resume, placeholder protection, CLI flags, and the usage checker — are documented in Reference → Translation Tool.
Info
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'})Filling all languages (bash)
export DEEPL_API_KEY=xxxx # or add to .env
npm run i18n:translateBest 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 files stay small and focused. - 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.
- Switch the language by navigating, not by writing state. The URL is the source of truth, so
setLanguage()changes the route; never try to set a locale variable directly. - Never edit
messages/. It is generated. Editsrc/locales/<lng>/<ns>.jsonand re-runnpm run codegen. - 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 key shows as the raw key after you add it | The ICU bundles are stale | Re-run npm run codegen (it also runs automatically on dev and build). |
| The build fails on an ICU parse error | A translation contains a stray { or } | Fix that message in src/locales/<lng>/<ns>.json — the failing locale, namespace and key are printed. |
| A namespace is missing from every locale | Not listed in manifest.json | The message build validates against it; add the namespace there and to NAMESPACES. |
| 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 next/font registration. |
| A URL without a locale 404s | — | It should redirect to the visitor's best match; check that src/proxy.ts is present and matching. |
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.
How is a missing translation handled? At build time. next-intl has no cross-locale fallback, so the message build backfills English into every gap — an untranslated string renders in English, never as a raw key.
Do all languages download up front? No. Each locale is its own compiled bundle, and a request only loads the bundle for the locale it is serving.
Where is the active language stored? Nowhere — it is in the URL. /ja/apps/chat is the Japanese page, which is
what makes a language choice shareable, bookmarkable and indexable by search engines.
Can I add a language the DeepL tool doesn't support? Yes — add it to LANGUAGES and hand-author its locale files;
just don't add it to the tool's targets. Routing picks it up automatically.
What happens at / with no locale? src/proxy.ts resolves the visitor's preferred language from the request and
redirects to that locale's URL.
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, placeholder protection, CLI, usage checker)
Getting started
Architecture & routing
Header
the language switcher lives in the header cluster
Customizer & settings
the Language section mirrors the header switcher
Utilities & API reference
Was this page helpful?
