---
title: "Translations and Multi-Language Sites"
url: "https://glokki.com/docs/translations"
locale: "en"
updated: "2026-10-31T00:28:35.677Z"
---

# 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.

---

## Table of Contents

1. [The Model](#1-the-model)
2. [Configuring Locales](#2-configuring-locales)
3. [Creating Translations](#3-creating-translations)
4. [What AI Translation Translates](#4-what-ai-translation-translates)
5. [Fetching Content by Locale](#5-fetching-content-by-locale)
6. [Routing in Your App](#6-routing-in-your-app)
7. [Rendering: `lang`, `dir` and the Language Switcher](#7-rendering-lang-dir-and-the-language-switcher)
8. [Layouts](#8-layouts)
9. [SEO](#9-seo)
10. [Other Systems and Locales](#10-other-systems-and-locales)
11. [APIs and MCP](#11-apis-and-mcp)
12. [Checklist](#12-checklist)
13. [Troubleshooting](#13-troubleshooting)

---

## 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`.

| Setting         | Meaning                                                                                                                   |
| --------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `supported`     | The locales you can translate into. At least 1, at most 50.                                                               |
| `default`       | The locale served when a request names none. Must be in `supported`. It gets no URL prefix.                               |
| `urlStrategy`   | `subdirectory`, `subdomain` or `query`. Controls the URLs the CMS **generates**. See [section 6](#6-routing-in-your-app). |
| `fallbackChain` | Optional per-locale list of locales to try when a page is missing. See [section 5](#locale-fallback).                     |

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.

| Method | What it does                                                                                                                         |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| Manual | Copies the page into one locale. You can set a different title and slug.                                                             |
| Bulk   | Copies the page into several locales at once. Locales that can't be created are reported as skipped and the others still go through. |
| AI     | Like manual, and sends the text through your configured AI model. See [section 4](#4-what-ai-translation-translates).                |

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:

| Error                  | Status | Cause                                                                 |
| ---------------------- | ------ | --------------------------------------------------------------------- |
| `LOCALE_NOT_SUPPORTED` | 422    | The locale isn't in `supported`. Add it in Settings → Locales first.  |
| `SAME_LOCALE`          | 422    | The target is the page's own locale.                                  |
| `TRANSLATION_EXISTS`   | 409    | The translation group already has a page in that locale.              |
| `SLUG_TAKEN`           | 409    | Another 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.

| Content                                   | Translated?                                                                                 |
| ----------------------------------------- | ------------------------------------------------------------------------------------------- |
| Page title                                | Yes, unless you enter your own                                                              |
| Meta title and description                | Yes                                                                                         |
| Open Graph and Twitter title, description | Yes                                                                                         |
| Block props                               | Text only, decided by the rules below                                                       |
| Collection fields                         | `text`, `textarea` and `richtext` fields only                                               |
| Slug                                      | **No.** It stays the same unless you enter your own, so set one if URLs should be localized |
| Layout header and footer                  | **No.** See [section 8](#8-layouts)                                                         |
| Data model records                        | **No.** They are shared across locales. See [section 10](#10-other-systems-and-locales)     |
| Code injection                            | **No**                                                                                      |

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…                                        | Example                                      | Result         |
| -------------------------------------------------- | -------------------------------------------- | -------------- |
| Starts with a protocol, `/` or `#`                 | `https://…`, `/pricing`, `#top`, `#1 choice` | Left alone     |
| Is a color, a CSS function or a value with a unit  | `#ff0000`, `rgb(0,0,0)`, `1.5rem`            | Left alone     |
| Is an ISO date or a UUID                           | `2026-01-15`, an asset or record id          | Left alone     |
| Contains an HTML tag                               | `<p>Hello</p>`                               | **Translated** |
| Contains whitespace                                | `Read more`, `left aligned`                  | **Translated** |
| Is a single word starting with an uppercase letter | `Welcome`, `Left`                            | **Translated** |
| Is a single word starting with a lowercase letter  | `center`, `primary`, `hello`                 | Left 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

```ts
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.

```ts
// 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.

| `urlStrategy`            | Generated URL for `de`             | Your app must…                     |
| ------------------------ | ---------------------------------- | ---------------------------------- |
| `subdirectory` (default) | `https://acme.com/de/about`        | Read the first path segment        |
| `subdomain`              | `https://de.acme.com/about`        | Read the host, and serve each host |
| `query`                  | `https://acme.com/about?locale=de` | Read 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:

```ts
// 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("/")}` }
}
```

```ts
// 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:

| Situation                                   | What 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 `default` | Your 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**:

```tsx
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:

```tsx
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](#9-seo)), 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](/docs/layout-development) 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.

```ts
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](/docs/sitemap-and-robots) 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](#6-routing-in-your-app).

## 10. Other Systems and Locales

| System            | Behavior |
| ----------------- | -------- |
| Redirects         | Each 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 models       | Shared across locales. A block referencing a data model record shows the same record in every language. |
| Collections       | Entries are pages, so they translate like pages. `listEntries()` needs the `locale` filter. |
| Internal links    | Suggestions and analysis stay within a page's own locale. |
| Revalidation      | Each translation is a separate page and sends its own publish webhook. See the [Integration Guide](/docs/integration#11-on-demand-revalidation). |
| Layout cache keys | Layout responses are tagged `layout:{layoutKey}:{locale}`, so publishing one locale's layout doesn't purge another's. |

## 11. APIs and MCP

| Task                       | How                                                                                           |
| -------------------------- | --------------------------------------------------------------------------------------------- |
| Read the locale settings   | `GET /api/v1/admin/locales`                                                                   |
| Change the locale settings | `PUT /api/v1/admin/locales`                                                                   |
| List a page's translations | `GET /api/v1/admin/pages/:pageId/translations`                                                |
| Create a translation       | `POST /api/v1/admin/pages/:pageId/translations` with `targetLocale`, optional `title`, `slug` |
| Create several             | `POST /api/v1/admin/pages/:pageId/translations/bulk` with `targetLocales`                     |
| AI translation             | Dashboard 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](/docs/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](#9-seo). 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](#6-routing-in-your-app).

### Navigation shows every language at once

Pass `locale` to `navigation.get()`. See [Lists](#lists).

### The header and footer are empty in one language

That locale has no layout data yet, or its draft hasn't been published. See [section 8](#8-layouts).

### 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](#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](#4-what-ai-translation-translates).

### `LOCALE_NOT_SUPPORTED`

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

---

## Related Guides

- [Integration Guide](/docs/integration) — client setup, `cmsMetadata()` and on-demand revalidation.
- [Sitemap and robots.txt](/docs/sitemap-and-robots) — serving the sitemap that carries your hreflang alternates.
- [Layout Development Guide](/docs/layout-development) — defining the layouts whose data is stored per locale.
- [Block Development Guide](/docs/block-development) — defining blocks, including the prop values AI translation handles.
- [MCP Server](/docs/mcp-server) — creating pages from an agent.
