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

## 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. Note that undeclared
props are stripped unless the block's schema is registered with a `catchall` —
see [Registering your own block schemas](/integration/custom-blocks).

## Two rules, or the round trip corrupts content

Both of these were found by a round-trip check on a real tri-lingual site, not
by reasoning about the model. A projection without them looks correct and
silently rewrites the dataset.

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

## 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 both rules above plus nine further bugs, and
nothing else would have. 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.

## What is not solved

* There is no UI affordance for "the same page in another language". The editor
  sees forty-five independent pages and its page list is forty-five entries
  long; grouping them is up to your slugs.
* Nothing prevents the agent from being asked to edit the German page and
  writing English into it. The language is a property of which page is open.
* `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 [`@avocadostudio-ai/richtext`](/integration/block-system) for the converters
that pivot through a ProseMirror document, and prefer a document-valued prop over
a markdown string when the CMS has real rich text.
