Skip to main content
@avocadostudio-ai/site-sdk/lens/contentful is the Contentful counterpart of the Storyblok and Sanity packs described in the field table. Two integrations wrote it by hand, about 190 lines each, and arrived at the same design. That design now ships as the pack, so an integration only has to declare its table and wire up a read and a publish. It needs no Contentful SDK. Reads and writes are plain fetch calls against the REST APIs, and fetch is a parameter, so all of it can be unit-tested with no space behind it. The rich-text converters are re-exported too (fromContentful, toContentful), so @avocadostudio-ai/richtext does not have to be a direct dependency.

How a page gets to Avocado and back

Setup

avocado/contentful.ts
localesFrom(await cda.locales()) returns { defaultLocale, locales } if you would rather read the locales from the space than list them yourself.
Do not declare localized in a Contentful field table. On Contentful, the space’s content types say which fields are localised, and localizedFields(contentTypes) reads them at runtime. A localized: false in the table is a second copy of the content model. It stays correct until somebody toggles “Enable localization” in the web app, and after that the lens reads one place while writing another.

Locales: unwrap on read, re-wrap on write

A locale=* read stores every field in a locale container: { "en-US": …, "de-DE": … }. That includes fields that are not localised: Contentful stores those under the default locale and nowhere else. The lens reads a non-localised field at the bare key, so readEntry unwraps those fields on the way in and entryPatch wraps them again on the way out:
With the schema, an edit to a non-localised field on a German page is refused with a reason (“not translatable … edit it on the EN-US page”). Without the schema it would be written to a de-DE slot that Contentful rejects. The pack reads through the Delivery or Preview API with locale=*, not through the site’s own client (often GraphQL, one locale per query). A page is one locale, but a publish writes into one entry that holds all of them. createContentfulDelivery forces locale=* on every call. Each entry becomes one Avocado page per locale. Read Multilingual content before writing the adapter: its four rules apply here unchanged.

What each field kind does

Images, and why alt text is read-only

An image is a Link to an Asset. readEntry annotates the Link from the response’s includes with the asset’s URL and title, and the codec projects those. A changed URL cannot be written back as a Link, because no asset exists for it yet. The codec writes an upload sentinel instead, { __avocadoUpload: url, title }, and the publisher turns it into a new asset (processed and published) plus a Link to it. Localhost and AI-generated images reach the publisher as bytes in the publish context and go through the Upload API. Alt text is the asset’s title, and every entry that uses the asset shares it. An alt edit on its own is therefore refused with a warning. Writing it would change the alt text on every page that shows that picture. When the image is replaced as well, the new alt becomes the title of the new asset, since that asset belongs to no other entry yet. Say so in the field table too, so the panel shows the alt disabled with the reason instead of offering an edit the publish will refuse. The image stays swappable:

References

A reference is a Link to an Entry, projected as the target’s slug. Unlike the Storyblok and Sanity packs, this one writes references, because on Contentful a person can type something that identifies the target: its slug. A changed slug becomes a lookup sentinel, { __avocadoLookup: slug, contentType, locale }. At publish, the publisher looks up the entry that has that slug and writes a Link to it. If no entry has the slug, or the entry it finds has never been published, the whole publish is refused before anything is written. Pass lookup to publishEntries if your targets are identified some other way.

Rich text

The body is converted with fromContentful / toContentful. “Unchanged” is decided against the stored value’s own canonical round trip, not against its bytes. toContentful(fromContentful(x)) is not x: the converter drops empty text nodes and adds data: {} wherever an authoring tool left it out. If an edit were compared with the raw stored value, every body would read as changed, and a publish that fixed one typo would rewrite every rich-text field in the space. Embedded entries and assets survive as opaque nodes, so editing the prose around an embedded entry does not delete it.
Put a size limit on embedded entries. A rich-text field created without size: { max: N } on its embedded-entry-block validation (and on any other entry-link node type the field allows) gets priced at the maximum by Contentful’s GraphQL API. One integration saw a query cost of 101,700 against a limit of 11,000: every blog post page returned 500 TOO_COMPLEX_QUERY while the home page rendered fine. Set a realistic limit (10 is usually plenty) in the content model, and render every page after seeding, not just the home page.

Rendering the preview: overlay the draft on your own data

The lens above is what the orchestrator reads pages from (getPages()) and what a publish writes through. It does not have to be what your templates render from. The default pattern leaves the site’s own data layer in place. The public route fetches the page exactly as it always has, and on an editor render it overlays the draft onto that object, only at the paths Avocado edits:
On Next.js, the draft comes from fetchEditorPage(slug, session, siteId) once resolveEditorContext() says the request is an editor render. Outside the editor there is no draft, so applyDraftBlocks returns the object it was given, the same reference. The overlay never mutates the site’s object. It copies only the containers on the path it writes, so content.links, which the rich-text renderer resolves embedded entries against, is the same object your query returned. A rule with to shapes the value on the way in, and it receives what the site held at that path, so you can replace one key of an image and keep its other fields:
This is what keeps the site-specific part of an integration small: the field table, the section map and this overlay. The two alternatives both cost more:
  • A separate preview route that re-implements each page’s composition from Avocado props. It drifts from the public route the first time someone changes one and not the other, and nothing checks that the two stay in step.
  • Rewriting the templates to read Avocado’s projected props (book.coverImageUrl for book.coverImage.url). This keeps one render path, but every production read then goes through the lens, and the site’s own data layer becomes dead code. On Astro it remains an option, reading pages from getPages(). Choose it only when the site has no data layer worth keeping.
Image components often assume a CDN URL (new URL(url), a blur placeholder from the Contentful Images API). Render one image the editor produced (an Unsplash URL, a generated localhost URL) before calling the preview done.

Pages built from sections of one entry

A blog post is one entry, but the template renders it as several sections: a hero, the body, a grid of related posts. Map one block per rendered section, even when the sections share an entry. Mapping the whole entry to one block means a click anywhere on the post selects all of it, and the panel shows every field. The table is keyed by section, and contentTypeOf tells the locale lens which content type’s schema each section should read:
At publish, merge every section of the page into the same document one after another, then take one patch for the entry. The example below does exactly that.

Publishing

avocado/publish.ts
Return the lens’s warnings as unsupported rather than logging them. A skip that only reaches the site’s server log is a publish the user was told succeeded. entryPatch contains only the locale slots that changed. publishEntries then fetches the live entry through the Management API, applies the patch on top of it, and sends an update only if something really differs. That way, an edit made in Contentful after Avocado read the page survives the publish. The English and German pages of one entry are combined into one write, so they cannot overwrite each other.

An entry that already has unpublished changes

A Contentful publish applies to a whole entry, not to one field. PUT /entries/:id/published makes the entry’s entire current draft live. If someone has been editing that entry in the web app, their unfinished work goes to production along with Avocado’s edit. onUnpublishedChanges sets what happens: An entry that has never been published counts as having unpublished changes, since all of its content is somebody’s draft. Archived entries are always refused.

Developing without credentials: a read-only fixture

contentfulExportSource reads the JSON written by contentful space export (or the export.json a Contentful starter ships). It has the same entries / contentTypes / locales interface as the Delivery API, and no write path. The entries in an export use the locale=* shape, so the adapter code does not change:
On one integration, this fixture got every page to 100% editableCoverage and a clean roundTrip before any space existed. Because the fixture is read-only, a publish against it fails, so it can never look like it worked.

Seeding a space

  • Publish linked entries first, and add the links in a second pass. An entry cannot be published while it links to entries that are unpublished. Posts that link to each other as related posts have to be created and published first, and linked afterwards.
  • Check that the space has the content model before you seed it. Some Contentful templates ship without one, because the starter’s sign-up flow creates it. The survey step should check that every content type the site queries exists.
  • Set embedded-entry size limits on every rich-text field (see above).

Before the first render in the editor

Three Contentful-specific issues only show up in the browser, inside the editor frame:
  • Contentful Live Preview SDK. ContentfulLivePreviewProvider throws “The current origin is not supported” when anything other than app.contentful.com frames it. Setting enableInspectorMode={false} does not skip that check. Pass targetOrigin={[editorOrigin]} on editor renders.
  • Frame headers. Templates often send X-Frame-Options: SAMEORIGIN and frame-ancestors 'self' https://app.contentful.com. Add the editor origin.
  • Draft mode. Contentful Live Preview and Avocado both use Next’s __prerender_bypass cookie. Key editor rewrites on editor_draft_session.

What is not covered

  • Lists of references (“related articles”). A Links-many field is not written by this pack; leave it out of the table.
  • New entries from the editor. Adding a page in Avocado does not create an entry.
  • Alt text on its own. It is the shared asset title, and edits to it are refused (see above).

The field table

The table, the two write rules, and roundTrip.

Multilingual

One page per entry × locale, and the four rules that keep the round trip clean.

CMS adapters

Reading pages out of a CMS and the publish contract.

Coverage checks

editableCoverage and panelCoverage, for the renderers and the panel.