i18n
Locales are declared in config/i18n.ts (supportedLocales, defaultLocale, loaders). Translation JSON lives per module at app/<mod>/resources/lang/<locale>/<mod>.json. Keys are namespaced by module (common.layout.navMain.dashboard), so the file at app/common/resources/lang/en/common.json doesn't repeat common. inside — the loader adds it. Backend uses ctx.i18n.t('key'); frontend uses useTranslation(). Locale detection runs as an HTTP middleware that prefers an X-User-Language header → a user-locale cookie → the browser's Accept-Language. Authenticated users have User.locale as the authoritative source: the switch endpoint writes both cookie and DB; every login flow syncs the cookie from user.locale if set.
Rules
- Adding a key requires an entry in every supported locale. There is no auto-generate. Missing keys fall back to the default locale at runtime — the mixed-language UI is the symptom.
- Key naming:
<mod>.<section>.<key>. The<mod>prefix comes from the file location; inside<mod>.jsonyou don't repeat it. E.g. insidecommon.json:
and consumed as{ "layout": { "navMain": { "dashboard": "Dashboard" } } }t('common.layout.navMain.dashboard'). - Frontend:
useTranslation()returns{ t, changeLanguage, language }.t(key, params?)supports ICU interpolation. - Backend:
ctx.i18n.t('key', params?). The i18n instance is bound toHttpContextby the locale-detection middleware. Use in controllers, actions (ifi18nwas in the input), and mail classes. - ICU interpolation:
{name}in the string,t('key', { name: 'Alice' })at the call site. Also supports plurals and selectors. - Validation messages: VineJS uses the request's i18n instance through the messages provider bound in the locale-detection middleware. Localize by adding keys under
<field>.<rule>in<mod>/resources/lang/<locale>/validator.json. - User preference:
User.localeis a nullable column. When authenticated, the switch endpoint updates both the cookie anduser.locale. Login flows syncuser-localecookie fromuser.localeif set. Pre-login (marketing/auth pages) uses cookie /Accept-Language, never DB. - Locale switcher UI:
<LanguageSwitcher />lives in every logged-in shell — see [[layout-shells]]. - Adding a locale means updating
supportedLocalesinconfig/i18n.ts, adding a folder under every module'sresources/lang/, and confirming every key exists in the new locale.
Repo refs
- Locale config + per-module loaders:
config/i18n.ts. Keys:app/users/resources/lang/{en,fr,pt}/users.json. - Frontend hook:
app/common/ui/hooks/use_translation.ts. Detection middleware:app/core/middleware/detect_user_locale_middleware.ts.
Doc refs
- AdonisJS i18n — https://docs.adonisjs.com/guides/digging-deeper/i18n
- ICU message format — https://docs.adonisjs.com/guides/digging-deeper/i18n#icu-message-format
- Translating validator errors — https://docs.adonisjs.com/guides/digging-deeper/i18n#translating-validation-messages
Workflow
Add a translation key
- Pick the module. If the string is shared UI (nav, buttons, layout), put it in
common. - Open the JSON file for every supported locale:
app/<mod>/resources/lang/<locale>/<mod>.json. - Add the same key path in every file with the localized value.
- Frontend:
t('<mod>.<path>')viauseTranslation(). - Backend:
ctx.i18n.t('<mod>.<path>'). - Verify parity: grep the key across
app/<mod>/resources/lang/*/— it must appear in all three locales. Present in only one → it falls back silently and ships a mixed-language UI.
Add a new module's translations
- Create
app/<mod>/resources/lang/<locale>/<mod>.jsonfor every supported locale. - Add an
fsloader entry inconfig/i18n.ts:loaders.fs({ location: app.makePath('app/<mod>/resources/lang') }), - The frontend glob picks the files up automatically.
Interpolation
{ "welcome": "Welcome, {name}!" }
t('common.welcome', { name: user.fullName })
Plural / selector:
{ "items": "{count, plural, =0 {No items} one {# item} other {# items}}" }
Localize a validator message
Add to app/<mod>/resources/lang/<locale>/validator.json:
{ "required": "This field is required" }
VineJS looks these up automatically because the messages provider is bound in the locale-detection middleware.
Set a user's default locale programmatically
Use during invite / sign-up when the target's language is known:
await User.create({ email, locale: 'pt' })
The next login flow writes the cookie for them.
Anti-patterns
- ❌ Adding a key in one locale but not the others — runtime falls back silently to the default and users see mixed languages.
- ❌ Hardcoding strings in components (
<span>Save</span>instead oft('common.save')). - ❌ Reading
req.cookies['user-locale']directly instead ofctx.i18n.locale— bypasses the detection priority (header → cookie → Accept-Language). - ❌ Persisting locale to the cookie without also updating
user.localewhen the user is authenticated — the switch endpoint handles both; don't reimplement. - ❌ Deep key paths (
common.a.b.c.d.e.f) — collapse the JSON tree, keep depth ≤ 3. - ❌ Repeating the module prefix inside the JSON file (
common.jsonwith acommonroot) — the loader adds it.
Related skills
[[inertia]] · [[layout-shells]] · [[mail]] · [[crud]] · [[module-scaffolding]]