> ## Documentation Index
> Fetch the complete documentation index at: https://docs.avocadostudio.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Multilingual content

> Avocado has no locale dimension. A CMS with per-language fields maps onto it as one editable page per (document x language) — how to project it, and the four rules that stop the round trip corrupting content.

<Note>
  Looking for **the editor's own language** — translating "Publish" into German,
  or getting AI responses back in the user's language? That is
  [Internationalization](/i18n). This page is about content that exists in more
  than one language.
</Note>

This page is for the developer projecting a multilingual CMS into Avocado
Studio. If you use one of the lens packs, the
[field table](/integration/field-table) and its locale lens implement the rules
below for you; read this page to know what they protect against.

## The mismatch

Avocado's content model has no locale axis:

```ts theme={null}
type PageDoc      = { id, slug, title, updatedAt, blocks, meta? }
type BlockInstance = { id, type, props: Record<string, unknown> }
```

One `title`, one `slug`, and `props` are flat values. A CMS with **field-level
i18n** stores the opposite shape — one document per page, and every translatable
field is an object:

```ts theme={null}
type LocaleString = Partial<Record<"de" | "fr" | "en", string>>
```

Handing that object to Avocado as a prop does not work. The planner writes
strings, so it will either overwrite the whole object with one or refuse the
field; and the property panel has no editor for it.

## The shape that works: one page per language

**An Avocado page is one language of one page.** Fifteen tri-lingual documents
become forty-five `PageDoc`s. `/de/prices` and `/fr/prix` are two Avocado pages
backed by the same CMS document.

* **Read** projects each localised field down to the plain string that language
  renders. The editor then sees ordinary monolingual pages and the planner
  never meets a locale object.
* **Write** merges the edited value back into that language's slot only.
* The language rides along in the slug, and in whatever context your publisher
  carries (`diffPage`'s `ctx` is generic — put a `lang` in it).

Two details make this land correctly:

**Slugs are identities, not URLs.** `/de/prices` is a page identity even on a
site that serves German at the root and answers 404 for that path. Put the URL a
visitor actually uses in [`meta.path`](/integration/nextjs-integration#typescript-types);
absent means "the slug is the path". Without it, an agent handed the slug and
told to open it lands on a 404 and cannot tell it was given an id.

**Keep the source block.** Carry the untouched CMS block along in the props (a
`__source` or `_src` key) so publish can diff against what it was projected
from, rather than against a projection of a projection. Edits keep props the
schema does not declare, and a leading underscore marks the key internal, so
the panel and the planner never show it. A duplicated block is the exception:
`duplicate_block` drops top-level `_` props from the copy, because the copy has
no source document of its own.

## Four rules, or the round trip corrupts content

All four were found by round-trip checks on real tri-lingual sites, not by
reasoning about the model. A projection without them looks correct and silently
rewrites the dataset. Three of the four were written *into* an integration
before being caught, by someone who had already read this page.

<Warning>
  **1. Unchanged means untouched.** Accessors usually fall back: a field with
  German and no French renders the German string on the French page, which is
  right for a visitor. Merge that back and you have materialised the fallback as a
  real French translation — identical on screen, dozens of fabricated translations
  per publish, no warning. Compare against the **projected** value and emit a
  patch only on a genuine change.

  **2. Empty means absent, not empty.** Writing `{fr: ""}` into a field that had
  no French is invisible on screen and is a change in the document. Skip the
  field instead.
</Warning>

<Warning>
  **3. A container is not localised — the fields inside it are.** A
  field-level-i18n CMS localises leaves. `cardGrid.cards` is **one array shared by
  every language**, and each card inside it carries its own `title__i18n__fr`.

  The natural implementation writes the edited array to `cards__i18n__fr`, because
  that is what every other field does. Storyblok accepts it, stores it, and never
  reads it: the French page keeps showing the German cards, the German cards are
  untouched, and nothing anywhere looks broken. Recurse into the container and
  write the leaves.

  Every field-level-i18n CMS has containers — Storyblok bloks, Contentful with
  locales, Strapi i18n — and this is the first thing an integrator gets wrong.

  **4. The component schema decides in both directions.** The Delivery API
  resolves `<key>__i18n__fr` only for a field the component schema *currently*
  marks translatable. Un-marking one does not delete the values already stored, so
  a document keeps a French value the site has not rendered since — and a reader
  that consults the content without consulting the schema believes it.

  This is not hypothetical: on one tri-lingual space, `card_item.card_type` holds
  `card_type__i18n__fr: "événement"` while every French page renders `"event"`,
  and `text_section.alignment` holds `"centre"` against a rendered `"center"`.
  Read those and you hand a planner text the site has not shown in years, then
  report the fields as changed on every publish.

  The same fact governs writes: a value written to a field the schema does not
  mark translatable will never be read back. So an adapter for a field-level-i18n
  CMS has to **fetch and snapshot the component schema**, and treat it as the
  authority on what to read and on whether a write can land. That is part of the
  job, not an optimisation.
</Warning>

## Build the round-trip check first

Before wiring the editor to anything, write a script that projects every
document in every language, rehydrates it with no edits, and asserts the result
is byte-identical to the source:

```
project(doc, lang) -> rehydrate() -> assert deepEqual(source)
```

Run it across every block type times every language. On the integration this
guidance comes from it found the first two rules above plus nine further bugs,
and nothing else would have. Rule 3 was caught the same way on a later
integration — the round trip is what makes a write the CMS accepts and never
reads visible, because the container comes back holding what it always held. The equivalent check at publish time is: **publish with
no edits and expect zero patches.** A dry run that plans forty-five patch groups
after changing nothing is a broken projection, not a busy one.

## Declare the site's languages

Return them from `getSiteConfig` in the editor API handler (or the Astro content
module), as the CMS reports them — **even when there is only one**:

```ts theme={null}
createEditorApiHandler({
  getPages,
  getSiteConfig: () => ({ locales: ["en-US", "de-DE"], defaultLocale: "en-US" }),
  // …
})
```

The editor forwards `locales` and `defaultLocale` to the orchestrator when it
loads the site, and they change what a translation request does. A page's
language is a property of which page is open, so "translate this to Russian" on
an `en-US` page cannot mean "add a Russian translation" — it can only mean
"replace the English text with Russian", which a publish then writes into the
`en-US` field. With the site's languages known, the chat answers that request
with a question instead:

```mermaid theme={null}
flowchart TD
    Ask["'Translate this page to X'"] --> Known{"Does the site<br/>have languages?"}
    Known -- "no" --> Apply["Translate in place<br/>(the demo-site behaviour)"]
    Known -- "yes" --> Same{"Is X this page's<br/>language?"}
    Same -- "yes" --> Apply
    Same -- "no" --> Has{"Does the site<br/>publish X?"}
    Has -- "yes" --> Sibling["Ask: edit the X page instead<br/>(names its slug)"]
    Has -- "no" --> Missing["Ask: X is not one of the<br/>site's languages"]
    Sibling --> Anyway["'…anyway' or 'yes'<br/>overwrites this page"]
    Missing --> Anyway
```

A site that declares nothing still gets the check when its slugs carry language
prefixes (`/post` beside `/de-DE/post`); a site with neither translates in place,
as it always has. Declaring is still the reliable form, and the only one that
covers a single-language site — which is exactly where the overwrite happened:
a Contentful space with one locale, whose English description came back in
Russian.

## Tell the user what a publish skipped

Some edits cannot land. A field the CMS does not localize has one value, owned
by the default locale, so an edit to it on the `de-DE` page has nowhere to go.
Skip it — and say so. Return it from `onPublish` in `unsupported`:

```ts theme={null}
onPublish: async (pages) => {
  const skipped: string[] = []
  // … merge, and for each change the CMS cannot store:
  skipped.push("/de-DE/post › Article hero › slug: not localized — edit it on the en-US page")
  return { ok: true, unsupported: skipped }
}
```

The editor lists each one as "Not published: …" under the publish result. A
skip that is only logged on the site's server is a publish the user was told
succeeded. `describeUnsupported()` from `@avocadostudio-ai/site-sdk/publish`
formats a field diff's `unsupported` entries this way.

## What is not solved

* The editor pairs sibling pages by their slugs: `/post` and `/de-DE/post` are
  one entry in the page picker, with a language switch beside it that moves
  between them. A page whose translation has a translated slug
  (`/de/veranstaltungen` for `/events`) has nothing to pair it by, so switching
  language on it lands on that language's home page.
* `duplicate_page` across languages does what it says — it copies a page, not a
  translation.

## Rich text

Rich text can round-trip **if** the CMS content was authored through a parser
you can invert. Write the inverse serialiser and keep the original tree whenever
re-serialising returns it unchanged; that makes rich text lossless unless it was
actually edited. Where no such parser exists, the usual lossy flatten applies —
see [rich text](/integration/cms-adapters#rich-text) for the converters in
`@avocadostudio-ai/richtext` that pivot through a ProseMirror document, and
prefer a document-valued prop over a markdown string when the CMS has real rich
text.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.