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:
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 migrateThe 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/{collection}/createTranslationconst data = await cms.api.pages.createTranslation({
body: {
sourceRootId: 'root_abc', // required
targetLanguage: 'de', // required
seed: 'copy',
},
});const { data, error } = await client.pages.createTranslation({
body: {
sourceRootId: 'root_abc', // required
targetLanguage: 'de', // required
seed: 'copy',
},
});sourceRootIdstringrequiredThe entry to translate from. Must exist in the active language.
targetLanguagestringrequiredThe language for the new root. Must be one of the configured `languages`.
targetSlugstringSlug 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.
rootIdstringThe id of the new translated entry.
branchIdstringThe id of the new entry's initial draft branch.
commitIdstringThe id of the initial commit that seeds the draft.
languagestringThe target language stamped on the new entry.
translationKeystringThe 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/{collection}/listTranslationsconst data = await cms.api.pages.listTranslations({
query: { rootId: 'root_abc' }, // required
});const { data, error } = await client.pages.listTranslations({
query: { rootId: 'root_abc' }, // required
});rootIdstringrequiredThe entry whose translations to list. Must exist in the active language.
translationKeystringThe 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
| Table | Column | Purpose |
|---|---|---|
roots | language | The entry's language. |
roots | translationKey | Stable group id tying sibling-language entries together. |
redirects | language | Per-language redirect routing. |
templates | language | The default's language (a German and an English default coexist for one field). |
variables | language | The value's language, resolved with fallback. |
Options
| Option | Type | Description |
|---|---|---|
languages | readonly string[] | The supported languages (a const tuple). |
defaultLanguage | one of languages | Seed language and default fallback target. |
fallback | Partial<Record<language | 'default', language[]>> | Per-language fallback chains. Absent means fall back to defaultLanguage. |
Error codes
| Code | Status | When |
|---|---|---|
LANGUAGE_REQUIRED | 400 | authMiddleware did not return a language while the plugin is active. |
LANGUAGE_NOT_ENABLED | 400 | The resolved request language is not one of the configured languages. |
TRANSLATION_SOURCE_NOT_FOUND | 404 | The sourceRootId has no entry in this collection / active language. |
TRANSLATION_EXISTS | 409 | A translation in the target language already exists for this entry. |
TRANSLATION_PARENT_NOT_TRANSLATED | 409 | The parent has no translation in the target language — translate the parent first. |
TRANSLATION_LANGUAGE_NOT_ENABLED | 400 | targetLanguage 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.