Features Apograph CMS on GitHub

Localization

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

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

Documents 0.5.2 Updated Edit this page Report a problem

On this page

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: 'العربية' }
    ]
});
KeyEffect
slugThe machine slug stored on rows. A BCP-47 language tag.
nameDisplay name in the admin.
isDefaultThe locale used when a request names none. Exactly one must set it.
dirText 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 record

Shared 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=de

Adding ?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:

  1. Syncs every non-localized column-backed field to the group’s sibling rows.
  2. 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.
  3. Moves any rewritten published sibling back to draft, keeping its published_at — so it reads as Modified, not as a never-published draft.
  4. 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.

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

RouteReturns
GET /api/i18n/localesThe configured locales, with resolved dir.
GET /api/i18n/content/:type/:id/localesWhich locales this entry’s group exists in.
POST /api/i18n/content/:type/locale-summaryLocale availability for a set of entries.
GET /api/insights/i18n/coverageTranslation coverage.