Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Localization plugin

Translating content — and the one setting that decides what happens to a removed language.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

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'
    };
}
KeyRule
locales[].slugLower-case BCP-47 (en, de, pt-br). Unique. Used as the HTML lang
locales[].nameDisplay name, written in that language
locales[].isDefaultExactly one
locales[].dirltr 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

Example language
# 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 (404 on a single read). The public API has no fallback; ask for translations=preview and choose yourself.
  • translations holds 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.
  • localeGroupId is the stable id of a record across languages; each row’s id is per locale.

Writing a translation

Create a row with a locale and the localeGroupId it joins:

Example language
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.
  • locale and localeGroupId are 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: syncAcrossLocales defaults to true, 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

RouteNeeds
GET /api/i18n/localesa session — { items: [{ slug, name, isDefault, dir }] }
GET /api/i18n/content/:typeName/:id/localescontent:read
POST /api/i18n/content/:typeName/locale-summarycontent:read
GET /api/insights/i18n/coveragecontent:read

Public clients get locale slugs from i18n_locales_list over MCP, or from your own config.

Agent tools

ToolSurfaces
i18n_locales_listMCP and copilot — call it before passing locale
i18n_translations_getCopilot
i18n_propose_translation, i18n_propose_bulk_translationCopilot — create a missing translation; never write a shared field