Skip to main content
Looking for the editor’s own language — translating “Publish” into German, or getting AI responses back in the user’s language? That is Internationalization. This page is about content that exists in more than one language.

The mismatch

Avocado’s content model has no locale axis:
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:
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 PageDocs. /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; 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.

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

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