Setting i18n: true on a content type makes it multilingual by storing one
row per locale. The rows that are translations of each other share a
locale_group_id.
export const article = collection('article', {
i18n: true,
publishable: true,
fields: {
title: field.text({ required: true, localized: true }),
slug: field.text({ required: true, localized: true }),
body: field.richtext({ localized: true }),
author: field.relation({ to: () => author, required: true })
}
});Configuring locales
The available locales are declared once, on the plugin:
I18nServerPlugin({
locales: [
{ slug: 'en', name: 'English', isDefault: true },
{ slug: 'de', name: 'Deutsch' },
{ slug: 'ar', name: 'العربية' }
]
});| Key | Effect |
|---|---|
slug | The machine slug stored on rows. A BCP-47 language tag. |
name | Display name in the admin. |
isDefault | The locale used when a request names none. Exactly one must set it. |
dir | Text direction. Optional — inferred from the slug when omitted. |
The slug being a BCP-47 tag is a contract, not a coincidence. Every locale
value the API returns can be used verbatim as an HTML lang attribute, so a
consumer sets lang={entry.locale} on the rendered region with no mapping table
and a screen reader announces German content with German pronunciation. That is
also why nothing uppercases a region subtag: pt-br, not pt-BR.
dir is optional in config but always present on the wire. Omit it and the
plugin infers it from the slug — ar, he, fa, ur and friends are rtl,
everything else ltr — so the admin never has to decide for itself whether
Arabic is right-to-left.
Removing a locale from config strands its rows
Removing a locale does not remove its rows, and those rows then become
invisible to every read path: ?locale= rejects the slug, the locale panel
iterates the configured set, coverage excludes it. The data is intact and
unreachable. Boot therefore fails by default when rows exist in an
undeclared locale. Set orphanedLocales: 'warn' for a deployment that is
knowingly mid-migration.
Shared and localized fields
Per field, localized: true means the value varies by language. A field
without the flag is shared across the translation group.
title: field.text({ localized: true }), // per language
authorNote: field.text() // one value for the whole recordShared means shared: editing authorNote in the English row writes it to every
sibling row in the group. That is the point of the flag, and it is worth
thinking about which of your fields are genuinely per-language.
localized: true on a non-i18n type is rejected at define time.
Translation groups
Every row carries a locale_group_id. A plain create starts a fresh group
— the column defaults to a new uuid. Passing an existing localeGroupId on a
create makes the new row a sibling in that group:
POST /api/content/article
{ "locale": "de", "localeGroupId": "…", "values": { "title": "Hallo" } }This is why there is no separate “create a translation” endpoint. The group id
is verified to name a real group in the workspace first, so a typo produces a
404 rather than a stray one-row group. A duplicate (group, locale) is a clean
409.
Reading a locale
Reads take ?locale=, and scope strictly by default: locale = X, so a
group with no row in the requested language is simply absent.
GET /api/v1/content/article?locale=deAdding ?localeFallback=default widens it to “the requested locale, or the
default-locale row of a group that has no requested-locale row” — which is what
a relation picker wants, and rarely what a published site wants.
An unknown locale is a 400 everywhere, never a silent read of the default.
What a save reaches
This is the part worth internalising, because one edit can touch several rows.
When a row in a group is saved, the localization plugin:
- Syncs every non-localized column-backed field to the group’s sibling rows.
- Syncs relations, according to the rules on relations — shared links verbatim, mirrored links resolved into each sibling’s own locale, join-backed link sets order-preserved.
- Moves any rewritten published sibling back to
draft, keeping itspublished_at— so it reads as Modified, not as a never-published draft. - Re-validates any sibling that was published. A failure is a 422 and the whole save rolls back.
Step 3 is what keeps publish state coherent. Leaving siblings published would make the same edit live in the untouched languages while still pending in the edited one. Step 4 is what stops a draft edit silently invalidating a live translation.
Each rewritten sibling also gets its own revision, so a sibling whose shared values moved has the history entry it earned rather than a timeline that skips the change.
Creating a sibling is not symmetric with updating one
On an update, the edited row is the authority and its state propagates outward. On a create, it is the opposite: a new translation arrives carrying the source’s shared values but none of its relations, so treating it as the authority would push those gaps onto rows that were already right. The system handles the two differently on purpose.
Cross-locale links are forbidden
A relation may not point from a row in one locale to a row in another. Where a link genuinely belongs to the record rather than the language, the mirrored storage rule resolves it into each sibling’s own locale — see relations.
Coverage
GET /api/insights/i18n/coverage reports translation coverage per locale and
per content type. It lives in the localization plugin because that plugin owns
the configured locale set, and therefore owns the question of what “complete”
means.
Endpoints
| Route | Returns |
|---|---|
GET /api/i18n/locales | The configured locales, with resolved dir. |
GET /api/i18n/content/:type/:id/locales | Which locales this entry’s group exists in. |
POST /api/i18n/content/:type/locale-summary | Locale availability for a set of entries. |
GET /api/insights/i18n/coverage | Translation coverage. |