⚠️ Work in progress — createCMS is pre-1.0 and not production-ready (not tested in production). Expect breaking changes.
createCMS

i18n

Per-language content scoping with fallback chains.

The i18n plugin scopes content by language. Each root belongs to one language, sibling-language versions of an entry are tied together by a translation group, and reads fall back along a chain you configure.

Installation

Add it to your config

Pass the static set of languages (as a const tuple, so they become a typed union) and a defaultLanguage:

lib/cms.ts
import { createCMS } from '@createcms/core';
import { i18n } from '@createcms/core/plugins/i18n';

export const cms = createCMS({
  db,
  collections,
  media,
  authMiddleware,
  plugins: [
    i18n({
      languages: ['en', 'de', 'fr'],
      defaultLanguage: 'en',
      fallback: { de: ['en'], fr: ['en'] },
    }),
  ],
});

Update the database

The plugin adds language columns. Regenerate the schema file, then apply it to your database with your Drizzle migration workflow:

npx createcms generate
npx drizzle-kit generate && npx drizzle-kit migrate

The drizzle-kit commands need a drizzle.config.ts at your project root — see the Quickstart for the one to create.

Usage

The active language is resolved per request from your middleware and applied to every query. These two methods are contributed to every collection, so they live at cms.api.<collection>.<method> (and on the client as client.<collection>.<method> with identical types). They only exist when the i18n plugin is installed.

Create a Translation

Create a sibling-language version of an existing entry, so a page can live in every language you support. The new entry joins the source's translation group (translationKey), takes the target language, and (by default) seeds its draft from a copy of the source's main tree. The source must exist in the active language.

root:create
POST/{collection}/createTranslation
const { data, error } = await client.pages.createTranslation({
  body: {
    sourceRootId: 'root_abc', // required
    targetLanguage: 'de', // required
    seed: 'copy',
  },
});
Parameters
sourceRootIdstringrequired

The entry to translate from. Must exist in the active language.

targetLanguagestringrequired

The language for the new root. Must be one of the configured `languages`.

targetSlugstring

Slug for the new translation. Defaults to the source slug.

seed'copy' | 'blank'= 'copy'

Seed the draft from a copy of the source's `main` tree, or start empty.

messagestring= 'Translation (<targetLanguage>)'

Commit message for the initial draft.

Returns
rootIdstring

The id of the new translated entry.

branchIdstring

The id of the new entry's initial draft branch.

commitIdstring

The id of the initial commit that seeds the draft.

languagestring

The target language stamped on the new entry.

translationKeystring

The group id inherited from the source, tying the sibling-language entries together.

List an Entry's Translations

List every language variant (sibling) of an entry, tied together by their translation group. Diff the returned languages against your configured languages to see which ones you still need to translate. It works cross-language by design: you pass a rootId from the active language, and the siblings you get back span the whole group.

root:read
GET/{collection}/listTranslations
const { data, error } = await client.pages.listTranslations({
  query: { rootId: 'root_abc' }, // required
});
Parameters
rootIdstringrequired

The entry whose translations to list. Must exist in the active language.

Returns
translationKeystring

The shared key that ties the language group together.

translations{ language, rootId, slug, path }[]

One sibling per language in the group, including the entry you passed in.

Variables and templates

Variables and templates scope by language too, with the same fallback chain: a value defined only in the default language is inherited by every language that falls back to it, and only the languages that need a different value override it.

// companyName: only defined in the default language 'en' = 'Acme'
// cta:         'en' = 'Buy now', 'de' = 'Jetzt kaufen'
// reading a German page:
//   {{companyName}} → 'Acme'          (fell back to en)
//   {{cta}}         → 'Jetzt kaufen'  (de override)

Managing variables and templates always targets the exact active language, not a fallback cell — so in German you manage German values. Uniqueness for both is scoped per language, so a variable's key (or a template's (collection, blockType, propertyKey)) is free to differ per language.

Schema

TableColumnPurpose
rootslanguageThe entry's language.
rootstranslationKeyStable group id tying sibling-language entries together.
redirectslanguagePer-language redirect routing.
templateslanguageThe default's language (a German and an English default coexist for one field).
variableslanguageThe value's language, resolved with fallback.

Options

OptionTypeDescription
languagesreadonly string[]The supported languages (a const tuple).
defaultLanguageone of languagesSeed language and default fallback target.
fallbackPartial<Record<language | 'default', language[]>>Per-language fallback chains. Absent means fall back to defaultLanguage.

Error codes

CodeStatusWhen
LANGUAGE_REQUIRED400authMiddleware did not return a language while the plugin is active.
LANGUAGE_NOT_ENABLED400The resolved request language is not one of the configured languages.
TRANSLATION_SOURCE_NOT_FOUND404The sourceRootId has no entry in this collection / active language.
TRANSLATION_EXISTS409A translation in the target language already exists for this entry.
TRANSLATION_PARENT_NOT_TRANSLATED409The parent has no translation in the target language — translate the parent first.
TRANSLATION_LANGUAGE_NOT_ENABLED400targetLanguage is not one of the configured languages.

There is intentionally no I18N_NOT_ENABLED code: createTranslation / listTranslations only exist when the plugin is installed, so "i18n not enabled" is the structural absence of the endpoint, not a runtime error.

On this page