Dynamic languages
Allow admins to define new languages and upload translation files from the UI entirely after deployment — no rebuild, no new Docker image, no env-var change, no restart.
Goals
Admin uploads a
.jsonforhiin production → visible immediately.Admin clicks "Add language: Hindi" →
/hi/...URLs start working immediately.The container image never needs to change to add or translate a language.
Where the hardcoding lives today
src/i18n/routing.ts— literal['en', 'es', 'fr']used by the middleware (src/proxy.ts).src/i18n/request.ts— dynamicimport('../../locales/${locale}.json')from the bundledlocales/folder.src/components/layout/LanguageSwitcher.tsx— hardcodedLANGUAGE_CONFIGwith label + flag per locale./locales/en.json|es.json|fr.json— filesystem-only translation files.
The project already has a near-identical pattern we can reuse: registry-config / registry-theme — fetched from the backend on each request, cached in ClientSafeConfig (src/app/api/_lib/client-safe-config.ts), and consumed in app/[locale]/layout.tsx + RuntimeConfigContext. Languages sit in the same system.
Core principle: Backend is the source of truth, /locales is a safety net
/locales is a safety netThere is a strict primary + fallback relationship — never a choice made per user or per request:
/locales/*.json exists only to keep the app rendering when:
Local dev runs without the backend.
A pod has just started and the cache is cold and the backend is momentarily unreachable.
An admin has not uploaded a translation for that locale yet.
Admins never touch /locales/. One-time migration: POST the existing en.json, es.json, fr.json to the new backend endpoint so they appear as editable records in the admin UI.
Design
1. Backend endpoints (must be built)
Mirror how configuration/registers, configuration/registry, and registry-theme work today. Two resources:
Language metadata —
GET/POST/PUT/DELETE /configuration/languagesTranslation bundle —
GET/PUT /configuration/languages/{code}/messagesstoring the JSON blob (same shape as today'sen.json).
Without these endpoints there is no "upload after deployment" — the server has nowhere to put the uploaded data. This is the one piece that cannot be avoided.
2. Server-side language registry + cache
Create src/i18n/language-registry.ts (server-only):
Cache invalidation is what makes "upload → works immediately" work. Every POST/PUT/DELETE to /api/configuration/languages/... calls revalidateTag('languages') on success.
3. Dynamic-locale middleware (replaces src/proxy.ts)
src/proxy.ts)next-intl's middleware needs the locale list synchronously at request time. Today it comes from a literal array. After the change, it comes from getLanguages() (cached), with a bootstrap fallback read from the bundled /locales/*.json filenames so the pod can still render on a cold cache + unreachable backend.
Notes:
The cache TTL + tag invalidation are what keep latency acceptable and still make admin changes take effect immediately.
After the first successful backend fetch,
BOOTSTRAP_LOCALESis never used again until the next cold boot.
4. Translation loading — src/i18n/request.ts
src/i18n/request.tsSame primary-plus-fallback pattern:
5. Data-driven LanguageSwitcher
LanguageSwitcherLanguageSwitcher.tsx stops hardcoding LANGUAGE_CONFIG and reads the list (code, label, flag URL) from RuntimeConfigContext. That context is already populated server-side in app/[locale]/layout.tsx via clientSafeConfig.fetchRegistryConfig(origin). Extend that payload with languages: [...] — no new client-side fetch needed.
6. Admin UI (new page)
src/app/[locale]/configuration/languages/ + a feature folder src/features/configuration/languages/ (same layout as registers/ or data-models/). Three flows:
Add language — form: code (
hi), label (हिन्दी), flag upload, default/enabled toggles. POSTs metadata.Upload translations —
.jsonfile picker that:rejects malformed JSON,
validates the key tree against
en's keyset (warn on missing/extra keys, show a diff summary before save),PUTs to
/configuration/languages/{code}/messages.
Edit / set default / enable / disable / delete.
Each mutation hits a Next API route under src/app/api/configuration/languages/… that proxies via proxyToBackend and calls revalidateTag('languages') on success.
What actually changes in the repo
src/proxy.ts
Rewritten as custom middleware that builds routing from cached backend data
src/i18n/routing.ts
Kept for type exports only (or removed if no longer needed)
src/i18n/request.ts
Replace static import(...) with getMessages(locale) + bundled fallback
src/i18n/language-registry.ts
New — server cache + backend fetchers
src/app/api/configuration/languages/**
New — thin proxies via proxyToBackend, each calls revalidateTag('languages')
src/features/configuration/languages/**
New — admin UI feature
src/app/[locale]/configuration/languages/page.tsx
New — admin route
src/components/layout/LanguageSwitcher.tsx
Read languages from RuntimeConfigContext instead of hardcoded map
src/app/api/_lib/client-safe-config.ts
Include languages in the runtime config payload (one extra fetch)
locales/*.json
Stay in the repo as bootstrap/fallback only; seed backend once from these
No changes needed to next.config.ts, package.json, the next-intl version, or tailwind.config.ts.
Rollout plan
Phase 1 — Foundations
Backend: implement language-metadata + translation-bundle endpoints.
Seed backend from current
locales/*.json.Add Next API proxy routes under
src/app/api/configuration/languages/, each callingrevalidateTag('languages')on mutation.Add
src/i18n/language-registry.tswith backend fetch + bundled fallback.
Phase 2 — Wire into rendering
Switch
src/i18n/request.tstogetMessages(locale).Rewrite
src/proxy.tsto build routing fromgetLanguages()with bootstrap fallback.Extend
clientSafeConfigpayload withlanguages; makeLanguageSwitcherconsume it.
Phase 3 — Admin UI
Build the
configuration/languagesadmin feature: list, add, edit, upload, disable, delete. Enforce translation-file validation against theenkeyset.
Gotchas
Translation validation — enforce the schema against
en's key tree on upload so a partial file can't break production pages. Missing keys → log and fall back toenfor that key.Caching —
getLanguages()andgetMessages()must be cached (unstable_cachewith alanguagestag). Every mutation API route must callrevalidateTag('languages')— this is the mechanism that turns "uploaded now" into "live now".Cold-boot window — after a pod restart, before the first backend fetch resolves, only bootstrap locales (
en/es/frbundled) will URL-match. This window is milliseconds in practice.Flag assets — store uploaded flags wherever branding assets already go; keep
/public/images/common/flags/as defaults.RTL languages — pair each language record with
direction: 'ltr' | 'rtl'and set<html dir>inlayout.tsxfrom it. Cheap to add up front.Permissions — gate the new
configuration/languagesroutes behind the same RBAC checks used for other configuration domains.Fallback hygiene — do not let the admin UI show the bundled
/locales/*.jsonas "records". It always shows the backend list; the fallback is invisible to the admin.
Was this helpful?