Content locales. A localized type stores one row per language, tied
together by a localeGroupId. The admin edits those rows as one record with a
locale menu.
Install and register
npm install @orthacms/i18n-server @orthacms/i18n-admin// apps/server/src/plugins.ts
import { I18nServerPlugin } from '@orthacms/i18n-server';
I18nServerPlugin(config.plugins.i18n);// apps/admin/src/plugins.ts — after ContentPlugin(), whose locale slots it fills
import { I18nPlugin } from '@orthacms/i18n-admin';
I18nPlugin();Configure locales
Locales are code, not environment variables — 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'
};
}| Key | Rule |
|---|---|
locales[].slug | Lower-case BCP-47 (en, de, pt-br). Unique. Used as the HTML lang |
locales[].name | Display name, written in that language |
locales[].isDefault | Exactly one |
locales[].dir | ltr or rtl. Omitted: inferred — ar, he, fa, ur are rtl |
orphanedLocales | 'fail' (default) or 'warn' — see below |
The list is validated when the plugin is constructed, so a bad one fails before boot.
Opt a type in and mark the fields that vary per language:
export const article = collection('article', {
i18n: true,
fields: {
title: field.text({ required: true, localized: true }), // one per locale
slug: field.text({ localized: true }),
author: field.relation({ to: () => author }) // shared by all locales
}
});Removing a locale hides rows, it does not delete them
Drop de from the list and every German row is intact and unreachable.
orphanedLocales: 'fail' refuses to boot and reports each type’s stray slugs
and row counts. 'warn' logs the report and boots — only for a deployment that
is knowingly mid-migration.
Reading a locale
# a list in German (no locale = the default locale)
curl 'https://cms.example.com/api/v1/content/article?locale=de' \
-H 'Authorization: Bearer orthacms_…'
# one record by translation group: vary only the locale
curl 'https://cms.example.com/api/v1/content/article/group/4a7d9e21-3b6c-4f58-a1e0-9c2b8d7f6e53?locale=de' \
-H 'Authorization: Bearer orthacms_…'
# the row plus its other published languages, for a language switcher
curl 'https://cms.example.com/api/v1/content/article/0c9e4d52-7f1a-4e3b-8d26-5b8f1a3c7e90?translations=preview&fields=title,slug' \
-H 'Authorization: Bearer orthacms_…' query Story($group: ID!, $locale: String) {
article(localeGroupId: $group, locale: $locale) {
locale
title
translations {
locale
slug
}
}
articles(locale: "de", pageSize: 5) {
items { title }
}
} { "jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "i18n_locales_list", "arguments": {} } }
{ "jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": { "name": "content_get", "arguments": {
"typeName": "article",
"localeGroupId": "4a7d9e21-3b6c-4f58-a1e0-9c2b8d7f6e53",
"locale": "de"
} } } {
"id": "0c9e4d52-7f1a-4e3b-8d26-5b8f1a3c7e90",
"createdAt": "2026-09-01T08:00:00.000Z",
"updatedAt": "2026-09-06T10:12:04.881Z",
"locale": "en",
"localeGroupId": "4a7d9e21-3b6c-4f58-a1e0-9c2b8d7f6e53",
"values": { "title": "Autumn catalogue", "slug": "autumn-catalogue" },
"translations": [
{
"id": "7b3e5a90-2d4c-4e1f-8b67-3c9a1f0d5e28",
"createdAt": "2026-09-02T09:30:00.000Z",
"updatedAt": "2026-09-06T10:15:40.112Z",
"locale": "de",
"localeGroupId": "4a7d9e21-3b6c-4f58-a1e0-9c2b8d7f6e53",
"values": { "title": "Herbstkatalog", "slug": "herbstkatalog" }
}
]
}- No
locale→ the default locale. Unknown locale →400. - Strict: a record with no row in the requested locale is absent (
404on a single read). The public API has no fallback; ask fortranslations=previewand choose yourself. translationsholds only published siblings, never the row itself.- The admin list route also takes
localeFallback=default— requested locale, else the default. That is the whole chain. localeGroupIdis the stable id of a record across languages; each row’sidis per locale.
Writing a translation
Create a row with a locale and the localeGroupId it joins:
curl -X POST https://cms.example.com/api/v1/content/article \
-H 'Authorization: Bearer orthacms_…' \
-H 'Content-Type: application/json' \
-d '{"locale":"de","localeGroupId":"4a7d9e21-3b6c-4f58-a1e0-9c2b8d7f6e53","values":{"title":"Herbstkatalog","slug":"herbstkatalog"}}' mutation {
createArticle(
locale: "de"
localeGroupId: "4a7d9e21-3b6c-4f58-a1e0-9c2b8d7f6e53"
input: { title: "Herbstkatalog", slug: "herbstkatalog" }
) {
id
locale
status
}
} { "jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": { "name": "content_create", "arguments": {
"typeName": "article",
"locale": "de",
"localeGroupId": "4a7d9e21-3b6c-4f58-a1e0-9c2b8d7f6e53",
"values": { "title": "Herbstkatalog", "slug": "herbstkatalog" }
} } } - A second row in the same locale of a group →
409. Unknown group →404. localeandlocaleGroupIdare read on create only; an update never moves a row to another locale.- Each locale is published on its own.
Shared fields
On an i18n: true type a field is shared unless it says localized: true.
- Editing a shared field in the German view changes it in every locale.
- A published sibling that edit reaches goes back to draft (Modified) and is re-validated.
- Relations follow the record:
syncAcrossLocalesdefaults totrue, linking the same record — or its translation, when the target type is localized too.
Read localization before modelling a type around it.
In the editor
The locale switcher, an outline button leading the entry editor’s top-bar
actions, shows the current locale (EN · English) and, since 0.12.0, one
dot per configured locale coloured by state — published, modified, draft, or
hollow where the record has no translation yet. It used to be a count
(2/3), which said how many but not which. Since 0.7.0 its menu is built
for many locales:
- A sticky summary: every locale code, coloured by state (published, draft, missing), with “N of M published” and a progress bar.
- One row per locale: switch to an existing translation (with its Draft / Published / Modified badge) or Add a missing one.
- A long list scrolls inside the menu under the summary. Right-to-left names render in their own direction.
Since 0.12.0 the form keeps the schema’s order rather than splitting into translated and shared runs: each field of a localized type says its scope beside its label — a globe for a localized field, All locales for a shared one.
Translation coverage fills the Localisation & media section of Insights: “of N localized records” in its header, and bars that switch between By language and By type, each with its share and what is missing (“86% · 118 missing”).
Routes
| Route | Needs |
|---|---|
GET /api/i18n/locales | a session — { items: [{ slug, name, isDefault, dir }] } |
GET /api/i18n/content/:typeName/:id/locales | content:read |
POST /api/i18n/content/:typeName/locale-summary | content:read |
GET /api/insights/i18n/coverage | content:read |
Public clients get locale slugs from i18n_locales_list over MCP, or from
your own config.
Agent tools
| Tool | Surfaces |
|---|---|
i18n_locales_list | MCP and copilot — call it before passing locale |
i18n_translations_get | Copilot |
i18n_propose_translation, i18n_propose_bulk_translation | Copilot — create a missing translation; never write a shared field |