Translations (i18n)
Translations (i18n)
Crouton supports server-side translations for column labels, resource titles, sidebar entries, enum option labels, UI chrome, and validation messages. The frontend never owns a translation catalogue β it sends Accept-Language, and the API responds in that language.
Setup
Add an i18n block to crouton.json:
{
"title": "My app",
"resourcesDir": "resources",
"enumsFile": "crouton.enums.json",
"i18n": {
"defaultLanguage": "en",
"languages": ["en", "nl", "fr"],
"translationsDir": "translations"
}
}| Field | Description |
|---|---|
defaultLanguage | Fallback language when a key is missing. |
languages | All supported languages. Controls cache size. |
translationsDir | Directory containing <lang>.json files, relative to project root. |
Generating translation files
# Create initial files for every configured language
npx crouton translations init
# Regenerate & merge (safe β hand edits are preserved)
npx crouton translations update
# Same as above, limited to specific languages
npx crouton translations update --lang nl,fr
# Remove keys that no longer match any column/enum
npx crouton translations update --prune
# Preview without writing
npx crouton translations update --dry-runcrouton update resources also refreshes translation files in the same pass, so the normal database-sync flow keeps translations in step with newly-introspected columns.
File format
Each language has one JSON file in translationsDir:
// translations/nl.json
{
"app": {
"title": "Mijn applicatie"
},
"sidebarGroups": {
"metadata": "Metadata"
},
"resources": {
"book": {
"title": "Boeken",
"sidebar": "Boeken",
"columns": {
"name": "Naam",
"description": "Beschrijving"
},
"actions": {
"publish": "Publiceren"
},
"subResources": {
"chapters": {
"title": "Hoofdstukken",
"columns": { "title": "Titel" }
}
}
}
},
"enums": {
"Status": {
"ACTIVE": "Actief",
"ARCHIVED": "Gearchiveerd"
}
},
"ui": {
"actions": { "save": "Opslaan", "cancel": "Annuleren", "delete": "Verwijderen" },
"table": { "empty": "Geen resultaten", "of": "van" }
},
"validation": {
"required": "{field} is verplicht",
"invalid_type": "{field} heeft een ongeldig type"
}
}Key conventions
resources.<name>is keyed on the resource'sname(fromresource.json), not on the route.- Column keys are column ids, including virtual ids generated by
extend. - Every section is optional. An empty
{}file is valid and means "fall back everywhere". - The
en.jsonfile is a real file generated withlabelFromId/resource.jsonlabels. It is the last resort before the raw column id. - Empty strings (
"") are treated as untranslated β they fall through the fallback chain.
Fallback chain
For any lookup (e.g. resources.book.columns.name):
- Requested language bundle (
nl.json) - Default language bundle (
en.json) - The
labelfromresource.json labelFromId(columnId)β the raw id turned into a readable string
How it works
Accept-Language header
The frontend sets Accept-Language on every API call via an axios interceptor installed by useCrouton().init(). The backend's LanguageInterceptor parses the header (standard q-value negotiation), stores the resolved language in an AsyncLocalStorage context, and sets Vary: Accept-Language + Content-Language on the response.
Caching
Any reverse proxy in front of crouton must honour Vary: Accept-Language, or it will serve stale translations.
What gets translated
| Endpoint | Translated |
|---|---|
GET /<route>/schemas | Column labels, resource title, view/form/table/filter JSON Schema titles |
GET /<route>/definition | Same |
GET /_app/layout | Sidebar labels, group labels, app title. Adds i18n metadata and ui dictionary |
GET /_app/translations | ui + validation dictionaries for the current language |
Read endpoints (findAll / findOne) | { value, label } envelope uses localized enum labels |
| Validation errors (400) | validation.<code> keys with {field} interpolation |
Dev-only editor endpoints (resource-columns, resource-json-raw, PATCH /resource.json) serve untranslated data β they edit resource.json, so they show what is on disk.
Enum labels
Enum option labels in read responses (the { value, label } envelope) are translated using enums.<EnumName>.<value> keys. Sorting and filtering on an enum column still operate on the stored scalar value, not the translated label.
Frontend
Language select
When the backend reports more than one language, a LanguageSelect dropdown appears in the admin sidebar. Consumers can also build their own UI β useLanguage() exposes the full reactive state:
import { useLanguage } from '@ghentcdh/crouton-vue';
const { language, languages, setLanguage, t } = useLanguage();
// Change language programmatically
await setLanguage('nl');
// Look up a ui dictionary key
const label = t('actions.save'); // "Opslaan"setLanguage updates the reactive ref, persists to localStorage, and triggers a layout + FormDef refresh automatically. Switching back to a previously used language is instant (FormDefs are cached per language).
UI dictionary
The ui section from the translation bundle is delivered via /_app/layout and available through t():
const { t } = useLanguage();
t('actions.save'); // "Opslaan"
t('table.empty'); // "Geen resultaten"
t('unknown.key'); // "unknown.key" (returns the key itself as fallback)No i18n library is needed. The dictionary is a plain nested object; t() does a dotted-path lookup.
Status page
When translations are active, the status page (/crouton/status.json) includes an i18n section showing:
- Configured languages and default language
- Key count per language bundle
- Number of untranslated (empty) keys per language
This helps identify incomplete translations at a glance.