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.
This page is for the developer projecting a multilingual CMS into Avocado Studio. If you use one of the lens packs, the 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:
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. 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.
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.
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.

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