PVR Tech Studio

Internationalization (i18n)

The template ships a real, working multi-language layer built on next-intl, rendered on the server.

13 min read
Updated August 13, 2026

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 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 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/chat is 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 codegen turns 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

FileResponsibility
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/platform/i18n.tsThe translation adapter every component imports — useTranslation, Trans, TFunction.
src/hooks/useLanguage.tsThin wrapper 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).
src/styles/index.csshtml[lang='ja'] / html[lang='zh'] font-variable overrides.
src/i18n/routing.tsThe locale list and URL-prefix strategy — the single place routing knows about languages.
src/i18n/request.tsnext-intl request configuration — resolves the locale and loads that locale's compiled bundle.
src/i18n/navigation.tsLocale-aware Link / useRouter / usePathname, consumed through @/platform/nav.
scripts/build-messages.mjsCompiles the flat locale files into ICU bundles (messages/<lng>.json), with English backfill.
messages/<lng>.jsonGenerated output — one ICU bundle per locale. Never edit by hand.
src/proxy.tsLocale 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 const

24 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. 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 — the source of truth every other language is generated from.
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.

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/.

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)

OptionValueNotes
localesLANGUAGE_CODESThe 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:

StepWhat it does
Compose namespacesMerges the per-namespace files into one object per locale, keyed by namespace.
InterpolationRewrites {{name}} to ICU's {name}.
PluralsFolds key_one / key_other pairs into one {count, plural, …} message.
English backfillFills any key a translation is missing, because next-intl has no cross-locale fallback.
ValidationParses 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.

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) => voidSwitch 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

  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 (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

  1. Create src/locales/en/<ns>.json (flat keys).
  2. Add <ns> to NAMESPACES in src/i18n/languages.ts.
  3. Add <ns> to src/locales/en/manifest.json (keep the manifest and NAMESPACES aligned).
  4. Use it via t('<ns>:<key>') or useTranslation('<ns>').
  5. Run the translate tool to generate the file for the other languages.

Adding a language

  1. Add a Language entry to LANGUAGES in src/i18n/languages.ts (code/label/native/flag).
  2. Add the flag SVG at public/flags/<code>.svg.
  3. Nothing to register for routing — src/i18n/routing.ts reads LANGUAGE_CODES, so the new locale's URLs (/<code>/…) start working from step 1.
  4. If it's a target the DeepL tool should fill, add it to TARGET_LANGUAGES and TARGET_DIRS in tools/translate/translate.mjs (key = folder name, value = DeepL API language code).
  5. For a CJK or otherwise special-font language, add an html[lang='<code>'] font override in src/styles/index.css and register the webfont with next/font in the root layout.
  6. Run npm run i18n:translate.

Generating translations

# needs one or more DeepL keys in .env (see .env.example)
npm run i18n:translate

This runs tools/translate/translate.mjs, which:

  • Reads every src/locales/en/*.json (except manifest.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.json verbatim 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 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'})

Filling all languages (bash)

export DEEPL_API_KEY=xxxx        # or add to .env
npm run i18n:translate

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 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 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.
  • 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. Edit src/locales/<lng>/<ns>.json and re-run npm run codegen.
  • 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 key shows as the raw key after you add itThe ICU bundles are staleRe-run npm run codegen (it also runs automatically on dev and build).
The build fails on an ICU parse errorA 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 localeNot listed in manifest.jsonThe message build validates against it; add the namespace there and to NAMESPACES.
CJK text renders in a fallback fontFont override / webfont missingEnsure 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) 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?