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

Localization

Localization plugin @orthacms/i18n-server@orthacms/i18n-admin

One row per language, translation groups, and the difference between a shared field and a localized one.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

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.

KeyEffect
slugStored on rows. A BCP-47 tag — usable verbatim as an HTML lang. Lowercase region: pt-br.
nameDisplay name in the admin.
isDefaultThe locale a request without ?locale= gets. Exactly one.
dirltr 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

Example language
# 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=preview attaches the group’s other published rows as translations; the front end decides.
  • group/:localeGroupId is the stable address across languages — each locale’s id differs. 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.

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": "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 localeGroupId a 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

FieldOn save in one locale
localized: trueOnly 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:

  1. Copies every shared column-backed field to the siblings.
  2. Syncs relations — shared links verbatim, mirrored links resolved per locale, order preserved.
  3. Moves any rewritten published sibling to draft, keeping published_at (it reads as Modified, and the public API keeps serving its published version).
  4. 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.

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:

FieldOperatorsMeaning
hasLocaleeq, inThe group has a row in that locale
missingLocaleeq, inin [de, fr] = missing all of them. “Any” is an or of eq rules
localeCountnumber operatorsHow many configured locales the group has

Endpoints

RoutePermissionReturns
GET /api/i18n/localessigned inThe configured locales, with resolved dir.
GET /api/i18n/content/:type/:id/localescontent:readWhich locales this entry’s group exists in.
POST /api/i18n/content/:type/locale-summarycontent:readLocale availability for a set of entries.
GET /api/insights/i18n/coveragecontent:readTranslation coverage per locale and type.