i18n: true stores one row per locale. Rows that translate each other share
a locale_group_id.
export const article = collection('article', {
i18n: true,
publishable: true,
fields: {
title: field.text({ required: true, localized: true }), // per language
slug: field.text({ required: true, localized: true }),
body: field.richtext({ localized: true }),
readingMinutes: field.number({ integer: true }), // shared by the group
author: field.relation({ to: () => author, required: true })
}
});Configuring locales
// apps/server/config/i18n.ts
import type { I18nPluginConfig } from '@orthacms/i18n-server';
export function i18nConfig(): I18nPluginConfig {
return {
locales: [
{ slug: 'en', name: 'English', isDefault: true },
{ slug: 'de', name: 'Deutsch' },
{ slug: 'ar', name: 'العربية' } // dir inferred: rtl
],
orphanedLocales: 'fail'
};
}
// apps/server/src/plugins.ts
I18nServerPlugin(config.plugins.i18n)Register I18nServerPlugin after ContentPlugin — it binds content’s
entry-extension port.
| Key | Effect |
|---|---|
slug | Stored on rows. A BCP-47 tag — usable verbatim as an HTML lang. Lowercase region: pt-br. |
name | Display name in the admin. |
isDefault | The locale a request without ?locale= gets. Exactly one. |
dir | ltr or rtl. Optional; inferred from the slug (ar, he, fa, ur… are rtl). |
orphanedLocales | 'fail' (default) or 'warn' — what boot does when rows exist in a locale no longer listed. |
curl https://cms.example.com/api/i18n/locales -b 'orthacms_session=…'{
"items": [
{ "slug": "en", "name": "English", "isDefault": true, "dir": "ltr" },
{ "slug": "de", "name": "Deutsch", "isDefault": false, "dir": "ltr" },
{ "slug": "ar", "name": "العربية", "isDefault": false, "dir": "rtl" }
]
}Removing a locale strands its rows
The rows stay, and become unreachable: ?locale= rejects the slug, the locale
menu and coverage skip it. So boot fails while such rows exist. Use
orphanedLocales: 'warn' only while knowingly migrating.
Reading a locale
# One locale of a list
curl 'https://cms.example.com/api/v1/content/article?locale=de' \
-H 'Authorization: Bearer orthacms_…'
# "This story, in German" — addressed by translation group
curl 'https://cms.example.com/api/v1/content/article/group/5c1d…?locale=de&translations=preview' \
-H 'Authorization: Bearer orthacms_…' query Story {
article(localeGroupId: "5c1d…", locale: "de") {
id
locale
title
translations { locale title }
}
} - Scoping is strict:
locale = de. A group with no German row is absent. - No locale → the default locale. An unknown locale → 400, never a silent default.
- No fallback chain on the public API.
?translations=previewattaches the group’s other published rows astranslations; the front end decides. group/:localeGroupIdis the stable address across languages — each locale’siddiffers. A group route on a non-localized type is a 400.- The admin list also takes
?localeFallback=default(the requested locale, or the default-locale row of a group without one). It is the relation picker’s mode.
Creating a translation
There is no “translate” endpoint: create a row in an existing group.
curl -X POST https://cms.example.com/api/v1/content/article \
-H 'Authorization: Bearer orthacms_…' \
-H 'Content-Type: application/json' \
-d '{
"locale": "de",
"localeGroupId": "5c1d…",
"values": { "title": "Hallo Welt", "slug": "hallo-welt" }
}' mutation {
createArticle(
locale: "de"
localeGroupId: "5c1d…"
input: { title: "Hallo Welt", slug: "hallo-welt" }
) {
id
localeGroupId
status
}
} - Without
localeGroupIda create starts a fresh group. - An unknown group → 404. A second row for the same
(group, locale)→ 409. - The new row takes the record’s links from an existing sibling (the default-locale row where there is one). Shared values sent with it still propagate outward.
Shared and localized fields
| Field | On save in one locale |
|---|---|
localized: true | Only this row changes. |
| unmarked (shared) | Written to every sibling row. |
localized: true on a non-i18n type fails at boot. Relations follow their own
rule (shared / mirrored / none) — see
relations across locales.
What a save reaches
When a row in a group is saved, the localization plugin, in the same transaction:
- Copies every shared column-backed field to the siblings.
- Syncs relations — shared links verbatim, mirrored links resolved per locale, order preserved.
- Moves any rewritten published sibling to
draft, keepingpublished_at(it reads as Modified, and the public API keeps serving its published version). - Re-validates any sibling that was published. A failure is a 422 and the whole save rolls back.
- Each rewritten sibling gets its own revision. A sibling that already matches is left alone.
- A mirrored link whose target has no translation in a sibling’s locale is left unset there, rather than failing the save. Since 0.11.0 the gap closes from the other side: creating that translation of the target fills it into the waiting siblings, in the same transaction (each gets a revision).
- A link edited from the inverse side of a synced many-relation — adding an article from its author — carries the owner’s link set to the owner’s other languages too, as if the owner had been saved (since 0.11.0).
The admin asks before any of these writes reaches another row, with a preview of
each; an API client can do the same with askBeforeSiblingSync — see
errors.
Each language publishes on its own
Publish, unpublish and delete act on one row. The German row can go live without the English one existing. Delete the German row and the English one stays.
Cross-locale links are forbidden
A relation may not point from a German row to an English row. Use the default
syncAcrossLocales: true and let the mirrored rule resolve each locale.
Filtering by translation
The admin filter grammar gains three virtual fields on localized types:
| Field | Operators | Meaning |
|---|---|---|
hasLocale | eq, in | The group has a row in that locale |
missingLocale | eq, in | in [de, fr] = missing all of them. “Any” is an or of eq rules |
localeCount | number operators | How many configured locales the group has |
Endpoints
| Route | Permission | Returns |
|---|---|---|
GET /api/i18n/locales | signed in | The configured locales, with resolved dir. |
GET /api/i18n/content/:type/:id/locales | content:read | Which locales this entry’s group exists in. |
POST /api/i18n/content/:type/locale-summary | content:read | Locale availability for a set of entries. |
GET /api/insights/i18n/coverage | content:read | Translation coverage per locale and type. |