View .md

Translations and Multi-Language Sites

How multi-language content works in the CMS: how locales are configured, how translations are created, what the Delivery API returns for each language, and what your tenant app has to do to route, render and index them.

This guide covers your site's content. The language of the CMS dashboard itself is a separate setting and has no effect on anything described here.


1. The Model

A translation is a separate page. It has its own title, slug, block tree, SEO fields, draft and publish state. Pages that are translations of each other share a translation group, and the group is the only thing that connects them.

Three consequences follow:

  • There is no source page. Once created, all pages in a group are equal. Any of them can be the starting point for the next translation.
  • A translation is a copy, not a link. Editing the English page later does not change the German one, and nothing marks the German page as out of date. Keeping translations in step is an editorial task.
  • Slugs can differ per locale. /about and /ueber-uns can be translations of each other. A slug is unique per tenant and locale, so two locales can also use the same slug.

Pages are stored with a bare slug (/about). The locale prefix you see in URLs (/de/about) is added when URLs are generated. It is not part of the slug.

2. Configuring Locales

Locales are configured per tenant in Settings → Locales. Viewing requires the locales.view permission and changing requires locales.manage.

SettingMeaning
supportedThe locales you can translate into. At least 1, at most 50.
defaultThe locale served when a request names none. Must be in supported. It gets no URL prefix.
urlStrategysubdirectory, subdomain or query. Controls the URLs the CMS generates. See section 6.
fallbackChainOptional per-locale list of locales to try when a page is missing. See section 5.

A tenant with no locale settings behaves as supported: ["en"], default: "en", urlStrategy: "subdirectory".

Locale codes are stored in lowercase. pt-BR is accepted as input and saved as pt-br, so that is the form you will see on pages, in alternates and in generated URLs. Codes must start with a 2–3 letter lowercase language tag.

Changing these settings does not touch existing pages. A page keeps its locale, so removing a locale from supported leaves its pages in place and still served. Changing default changes which locale has no URL prefix, which changes generated URLs across the site.

3. Creating Translations

Open a page in the dashboard and go to its Translations tab. It lists every supported locale and whether the page exists in it. Creating a translation needs the pages.create permission.

MethodWhat it does
ManualCopies the page into one locale. You can set a different title and slug.
BulkCopies the page into several locales at once. Locales that can't be created are reported as skipped and the others still go through.
AILike manual, and sends the text through your configured AI model. See section 4.

Every method copies the block tree, SEO fields, code injection, layout and collection link, and always creates a draft. Nothing goes live until the translation is published, and each translation is published on its own. Tags are not copied.

A translation can't be created when:

ErrorStatusCause
LOCALE_NOT_SUPPORTED422The locale isn't in supported. Add it in Settings → Locales first.
SAME_LOCALE422The target is the page's own locale.
TRANSLATION_EXISTS409The translation group already has a page in that locale.
SLUG_TAKEN409Another page already uses this slug in that locale. Pick another one.

Bulk creation doesn't fail on these. It lists unsupported locales, existing translations and slug conflicts in its skipped response, and ignores the page's own locale.

For a collection entry, a custom slug always keeps the collection's URL prefix: /wpis in a collection with the prefix /blog becomes /blog/wpis.

Unlinking removes a page from its group and leaves it as a standalone page, published or not. If that leaves only one page in the group, that page becomes standalone too. Unlinking needs pages.edit.

4. What AI Translation Translates

AI translation is part of the ai-translation feature, available on the Pro and Enterprise plans. It uses the provider and model from Settings → AI, where you can also set a tone, a glossary and a list of do-not-translate terms that apply to every translation. The translation dialog also takes an extra glossary for that one translation.

ContentTranslated?
Page titleYes, unless you enter your own
Meta title and descriptionYes
Open Graph and Twitter title, descriptionYes
Block propsText only, decided by the rules below
Collection fieldstext, textarea and richtext fields only
SlugNo. It stays the same unless you enter your own, so set one if URLs should be localized
Layout header and footerNo. See section 8
Data model recordsNo. They are shared across locales. See section 10
Code injectionNo

A collection field with a validation pattern is skipped even if its type is text, because it holds a constrained format such as a SKU. A translated value isn't checked against the field's max length, so review long fields before publishing.

How block text is detected

This matters if you write blocks. Block schemas are not consulted. Every string in a block's props, at any depth, is tested on its own:

The string…ExampleResult
Starts with a protocol, / or #https://…, /pricing, #top, #1 choiceLeft alone
Is a color, a CSS function or a value with a unit#ff0000, rgb(0,0,0), 1.5remLeft alone
Is an ISO date or a UUID2026-01-15, an asset or record idLeft alone
Contains an HTML tag<p>Hello</p>Translated
Contains whitespaceRead more, left alignedTranslated
Is a single word starting with an uppercase letterWelcome, LeftTranslated
Is a single word starting with a lowercase lettercenter, primary, helloLeft alone

So when you define a block:

  • Use lowercase, single-token enum values. center and align-left survive translation. Left and left aligned get translated and will no longer match your schema.
  • Don't expect a lone lowercase word to be translated. A heading of hello stays as it is. Capitalize user-facing labels, as you would anyway.
  • Expect uppercase identifiers to reach the model. A code like ABC123 is sent for translation. The model usually returns it unchanged, but check it in review, or add it to the do-not-translate list.

The cost of a missed string is an untranslated word, while the cost of a wrong one is a broken block. That is why the rules lean toward leaving things alone, and why the result is a draft for someone to review.

5. Fetching Content by Locale

A single page

const page = await cms.pages.getBySlug("/about", { locale: "de" })

Without locale, the tenant's default locale is used. The response's locale field is the locale of the page you actually received, which may differ from the one you asked for.

Locale fallback

If the page has no published version at that slug in the requested locale, the Delivery API tries these in order and returns the first hit:

  1. The locales listed for it in fallbackChain
  2. Its base language, for a regional locale (pt-br → pt)
  3. The default locale

Locales that aren't in supported are dropped from the chain. With supported: ["en", "fr", "pt", "pt-br"], default: "en" and fallbackChain: { "pt-br": ["fr"] }, a request for pt-br tries pt-br → fr → pt → en.

Keep in mind:

  • Fallback matches by slug. It looks for the same slug in the next locale, not for the page's translation. A request for /ueber-uns in fr finds nothing if the English page is at /about, and the result is a 404.
  • A fallback is a normal 200 response. To treat it differently, compare page.locale with the requested locale, for example to show a "not available in your language" notice or to return a 404.
  • The rest of the response follows the page you received. Its canonical URL and layout belong to the fallback locale, so a fallback doesn't create a duplicate-content signal.
  • Publishing clears it. A cached fallback response is evicted from the CDN by any publish in your tenant, so a new translation replaces it right away. The webhook carries the same delivery-fallback:{tenantId} tag. If you cache Delivery responses yourself and don't purge on every publish, a fallback answer can outlive the new translation.

Lists

pages.list(), navigation.get() and collections.listEntries() take a locale filter, and without it they return every locale. There is no fallback for lists: a filtered list contains exactly the pages published in that locale.

// Always pass the locale when building navigation for a language
const nav = await cms.navigation.get({ locale })

6. Routing in Your App

The CMS doesn't route your site. Your app decides which locale a URL means and asks the Delivery API for it. urlStrategy only tells the CMS how to write URLs into canonical tags, hreflang alternates and the sitemap, so your routing has to match it. Setting subdomain in the dashboard doesn't make subdomains work on your site.

urlStrategyGenerated URL for deYour app must…
subdirectory (default)https://acme.com/de/aboutRead the first path segment
subdomainhttps://de.acme.com/aboutRead the host, and serve each host
queryhttps://acme.com/about?locale=deRead the query parameter

In every strategy the default locale has no marker: https://acme.com/about.

The starter template implements subdirectory only. If you choose another strategy, replace its route resolver with your own.

Resolving a subdirectory route

In a catch-all route, split the URL into a locale and a slug:

// lib/i18n.ts
const supportedLocales = (process.env.CMS_LOCALES ?? "en")
  .split(",")
  .map((locale) => locale.trim().toLowerCase())
const defaultLocale = (process.env.CMS_DEFAULT_LOCALE ?? "en").toLowerCase()

export function resolveRoute(segments: string[] | undefined) {
  if (!segments || segments.length === 0) {
    return { locale: defaultLocale, slug: "/" }
  }
  const first = segments[0].toLowerCase()
  if (first !== defaultLocale && supportedLocales.includes(first)) {
    const rest = segments.slice(1)
    return {
      locale: first,
      slug: rest.length > 0 ? `/${rest.join("/")}` : "/",
    }
  }
  return { locale: defaultLocale, slug: `/${segments.join("/")}` }
}
// app/[[...slug]]/page.tsx
const { locale, slug: pageSlug } = resolveRoute(slug)
const page = await cms.pages.getBySlug(pageSlug, { locale })

Checking the segment against the list is what stops /plumbing or /de-icing from being read as a locale.

Keep the two lists in sync

CMS_LOCALES and CMS_DEFAULT_LOCALE repeat what's in Settings → Locales, because your app needs them to parse a URL before it can talk to the CMS. Update both when you add a locale. If they drift:

SituationWhat happens
Locale in the CMS but not in CMS_LOCALES/pl/o-nas is read as the slug /pl/o-nas and returns a 404, while hreflang and the sitemap advertise it
Locale in CMS_LOCALES but not in the CMS/xx/about responds with default-locale content through fallback
CMS_DEFAULT_LOCALE differs from defaultYour unprefixed URLs serve a different language than the canonical and hreflang tags claim

7. Rendering: lang, dir and the Language Switcher

lang and dir

In a Next.js root layout, <html lang> can't see the route's locale, so it stays on the default language. The starter handles this by declaring the language on a wrapper, using page.locale so that it stays correct when a fallback was served. It doesn't set dir, so right-to-left support is yours to add:

const RTL_LANGUAGES = new Set(["ar", "he", "fa", "ur"])

const dir = RTL_LANGUAGES.has(page.locale.split("-")[0]) ? "rtl" : "ltr"

return (
  <div lang={page.locale} dir={dir} className="contents">
    <CMSPage componentTree={page.componentTree} registry={registry} />
  </div>
)

Your blocks need to work in both directions too. Prefer logical CSS properties (margin-inline-start, text-align: start) over left and right.

Language switcher

page.alternates lists the published versions of the page, each with a full URL. Drop x-default, which isn't a language:

const languages = (page.alternates ?? []).filter(
  (alternate) => alternate.locale !== "x-default"
)

return (
  <nav aria-label="Language">
    {languages.map(({ locale, url }) => (
      <a key={locale} href={url} hrefLang={locale} lang={locale}>
        {locale}
      </a>
    ))}
  </nav>
)

alternates is absent when a page has no published translation or no Frontend URL is configured (see section 9), so a switcher must handle that case. It's also why a switcher can't offer unpublished translations.

8. Layouts

Layout data is stored per (layoutKey, locale), so each locale has its own header and footer:

  • A translation uses the same layout as the page it was created from.
  • A new locale starts with no layout data. Until someone fills it in, page.layout.header and page.layout.footer are null, so make sure your layout components render sensibly without data.
  • Layout content isn't copied or translated when a page is. Open a page in the new locale in the visual editor and fill in the header and footer there.
  • A layout draft is published when any page using it in the same locale is published. Publishing a German page doesn't publish the English header.

See the Layout Development Guide for how layouts are defined.

9. SEO

cmsMetadata() turns a page's SEO data into canonical and hreflang tags. Set Settings → Visual Editor → Frontend URL on any multi-language site, and Settings → Locales warns you when it's missing. Hreflang needs it, and without it the canonical tag falls back to baseUrl plus the bare slug, with no locale prefix, so /de/about declares /about as its canonical URL.

export async function generateMetadata({ params }: PageProps) {
  const { locale, slug } = resolveRoute((await params).slug)
  const page = await cms.pages.getBySlug(slug, { locale })
  return page ? cmsMetadata(page, { baseUrl: process.env.SITE_URL }) : {}
}

A page gets alternates only when all of these hold:

  1. A Frontend URL is configured.
  2. The page is in a translation group.
  3. At least two pages in the group are published.

An x-default entry is added when the group has a published page in the default locale. Hreflang entries are built from the translation group, not by matching slugs, so pages that were created separately aren't linked even if they share a slug.

The sitemap uses the same translation groups, default locale and URL strategy, so it agrees with the page tags on which pages are translations and what their URLs are. One difference is intentional: the sitemap leaves out noindex pages, including from its alternates, while a page's own alternates lists every published translation. If you noindex one language, expect it to still appear in its siblings' hreflang tags.

Your site's URLs must match the generated ones, or these tags point at the wrong place. See section 6.

10. Other Systems and Locales

SystemBehavior
RedirectsEach source path has one rule per tenant, and the source is matched as written, so to redirect /de/old create a rule for that exact path. Renaming a page creates one for you, using the page's real URLs: /de/old → /de/new for a non-default locale with subdirectory URLs. With subdomain or query the path holds no locale, so the rule uses the bare slug (/old → /new) and applies to every locale. It's skipped when another locale still uses the old slug. The response's redirectCreated tells you whether a rule was made.
Data modelsShared across locales. A block referencing a data model record shows the same record in every language.
CollectionsEntries are pages, so they translate like pages. listEntries() needs the locale filter.
Internal linksSuggestions and analysis stay within a page's own locale.
RevalidationEach translation is a separate page and sends its own publish webhook. See the Integration Guide.
Layout cache keysLayout responses are tagged layout:{layoutKey}:{locale}, so publishing one locale's layout doesn't purge another's.

11. APIs and MCP

TaskHow
Read the locale settingsGET /api/v1/admin/locales
Change the locale settingsPUT /api/v1/admin/locales
List a page's translationsGET /api/v1/admin/pages/:pageId/translations
Create a translationPOST /api/v1/admin/pages/:pageId/translations with targetLocale, optional title, slug
Create severalPOST /api/v1/admin/pages/:pageId/translations/bulk with targetLocales
AI translationDashboard only. There is no REST route or MCP tool

These admin routes use a signed-in dashboard session and don't accept a delivery API key.

The MCP server has no translation tools. create_page takes a locale, but the page it creates doesn't join a translation group. So an agent can write content in another language, but hreflang won't link that page to its counterpart. To link pages, create the translation from the page's Translations tab or the REST route above. A page's locale can't be changed after creation.

Creating pages in a locale

create_page and POST /api/v1/admin/pages treat locale the same way. Omit it to use your default locale. A locale you pass is lowercased and must be in supported, or the request fails with LOCALE_NOT_SUPPORTED (422).

12. Checklist

Adding a language to a site:

  • Add the locale in Settings → Locales
  • Add it to CMS_LOCALES and redeploy
  • Set Settings → Visual Editor → Frontend URL, if it isn't set
  • Translate the pages from each page's Translations tab
  • Fill in the layout's header and footer in the new locale
  • Review the drafts, then publish them
  • Set dir if the language is right-to-left
  • Filter navigation and lists by locale
  • Check a page's <link rel="alternate" hreflang> tags and /sitemap.xml

13. Troubleshooting

The translated page shows default-language content

Fallback served another locale. Compare page.locale with the requested locale. If they differ, the translation is unpublished, has a different slug than the one requested, or doesn't exist.

/de/about returns a 404

  • The translation's slug may differ. Check it on the page's Translations tab.
  • de may be missing from CMS_LOCALES, so /de/about is being read as a slug.
  • The page may be published in no locale in the fallback chain.

No hreflang tags on the page

See the conditions in section 9. The most common causes are a missing Frontend URL, or a translation that is still a draft.

Hreflang URLs don't match my routes

urlStrategy, default or the Frontend URL doesn't match your app. See section 6.

Pass locale to navigation.get(). See Lists.

That locale has no layout data yet, or its draft hasn't been published. See section 8.

A block looks broken after AI translation

An enum value was probably translated. Use lowercase, single-token values, and fix the affected prop in the draft. See How block text is detected.

Some text stayed in the source language

It's probably a single lowercase word, starts with / or #, or lives in a data model record or layout. See section 4.

LOCALE_NOT_SUPPORTED

Add the locale in Settings → Locales, then try again.