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.
Every root-specific call, including writes, publishBranch and branch
operations, runs in the language context your authMiddleware resolves.
A call that targets a root of another language is rejected with a 404
ROOT_NOT_FOUND ("... lies outside the active scope ..."), because
the scope filter excludes it. Scripts that maintain
several languages must switch the language context per target root.
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.
Missing translations, routing and SEO
The plugin resolves PAGES strictly per language on purpose: a root without
a sibling in the active language does not exist in that scope, and reads
return a 404 instead of silently serving another language. The
fallback chain applies one level below, inside a page: references resolve
to the best sibling along [language, ...fallback], internal links point
at the active-language sibling when one exists, and variables inherit
along the chain. An explicit empty chain (fallback: { en: [] }) opts a
language out of fragment fallback too.
For your routing this means the 404 is a decision the app makes, and two patterns are sound:
- Do not link what does not exist: build the language switcher and the
sitemap from
listTranslations, emithreflangpairs only for existing siblings plus anx-defaultpointing at the default language, and let a direct request for a missing translation return 404. - Or redirect: answer a missing translation with a temporary redirect (302 or 307) to the default-language URL, so the content has exactly one URL per language it exists in.
Avoid serving default-language content under another language's URL: the
same content becomes indexable under several URLs, the page language
contradicts the URL and any hreflang annotation, and when the
translation later arrives its URL has to displace the indexed fallback.
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.