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

# Contentful

> The Contentful lens pack: every locale in one document, images and references that publish as real Links, and a publish that refuses to take somebody's draft live.

`@avocadostudio-ai/site-sdk/lens/contentful` is the Contentful counterpart of
the Storyblok and Sanity packs described in [the field table](/integration/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

```mermaid theme={null}
flowchart LR
  subgraph Read
    CDA["Delivery / Preview API<br/>locale=*"] --> RE["readEntry<br/>unwrap + annotate Links"]
    RE --> P["lens.project<br/>one page per locale"]
  end
  P --> ED["Avocado edit<br/>(chat or panel)"]
  subgraph Publish
    ED --> M["lens.merge<br/>codecs emit sentinels"]
    M --> EP["entryPatch<br/>changed slots only"]
    EP --> PE["publishEntries<br/>Management API"]
  end
  PE --> CF[("Contentful entry")]
```

## Setup

```ts avocado/contentful.ts theme={null}
import { createLens, registerLens } from "@avocadostudio-ai/site-sdk/lens"
import {
  contentfulLocale,
  contentfulPrimitives,
  createContentfulDelivery,
  localizedFields,
  type LocalizedFields,
} from "@avocadostudio-ai/site-sdk/lens/contentful"
import { TABLE } from "./table"

export const DEFAULT_LOCALE = "en-US"
export const LOCALES = ["en-US", "de-DE"] as const
export type Locale = (typeof LOCALES)[number]

export const cda = createContentfulDelivery({
  spaceId: process.env.CONTENTFUL_SPACE_ID!,
  accessToken: process.env.CONTENTFUL_PREVIEW_TOKEN!,
  preview: true,
})

// Which fields are localised is a fact about the space, not the table.
let schema: LocalizedFields | undefined
export async function loadSchema() {
  schema = localizedFields(await cda.contentTypes())
  return schema
}

export const lens = createLens<Locale>({
  table: TABLE,
  locale: contentfulLocale<Locale>(DEFAULT_LOCALE, LOCALES, { localized: () => schema }),
  primitives: contentfulPrimitives(),
})

registerLens(lens)
```

`localesFrom(await cda.locales())` returns `{ defaultLocale, locales }` if you would
rather read the locales from the space than list them yourself.

<Warning>
  **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.
</Warning>

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

```ts theme={null}
readEntry(entry, { defaultLocale: "en-US", localized, includes })
// title:         { "en-US": "Hello", "de-DE": "Hallo" }   ← localised, kept
// publishedDate: "2026-09-01"                             ← not localised, unwrapped
```

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](/integration/multilingual) before writing the adapter:
its four rules apply here unchanged.

## What each field kind does

| Contentful field               | Table kind                  | Projects to             | A change is written as                   |
| ------------------------------ | --------------------------- | ----------------------- | ---------------------------------------- |
| Symbol, Text                   | `text`                      | the string              | the string                               |
| Symbol holding a URL           | `link`                      | the URL                 | the URL                                  |
| Rich text                      | `richtext`                  | a document              | the whole document, only when it changed |
| Media (one file)               | `image`                     | `<key>Url` + `<key>Alt` | an upload sentinel, then a new asset     |
| Media (one file, not an image) | `file`                      | the URL                 | an upload sentinel                       |
| Media (many files)             | `imageList`                 | `{ image, alt, id }[]`  | kept Links plus upload sentinels         |
| Reference (one entry)          | `reference`                 | the target's slug       | a lookup sentinel, then a Link           |
| Boolean, Number, Date          | `boolean`, `number`, `text` | themselves              | themselves                               |

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

```ts theme={null}
featuredImage: {
  kind: "image",
  alt: { readOnly: true, readOnlyReason: "Alt text is the Contentful asset's title, shared by every entry that uses it." },
},
```

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

<Warning>
  **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.
</Warning>

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

```ts theme={null}
import { applyDraftBlocks, toContentful, type DraftMapping } from "@avocadostudio-ai/site-sdk/lens"

// Block prop → where it lives in the object your own query returns.
export const OVERLAY: Record<string, DraftMapping> = {
  articleHero: {
    title: "title",
    featuredImageUrl: "featuredImage.url",
    featuredImageAlt: "featuredImage.title",
  },
  articleBody: { body: { path: "content.json", to: toContentful } },
}

// In the page, next to the query it already runs:
const post = await getBlogPost(slug, locale)              // the site's GraphQL client, unchanged
const draft = await Astro.locals.avocado.getDraftPage()   // null outside the editor
const view = applyDraftBlocks(post, draft?.blocks, OVERLAY)
```

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:

```ts theme={null}
featuredImageUrl: {
  path: "featuredImage",
  // An editor-chosen image is not on Contentful's CDN: drop the size hints.
  to: (url: string, current) => ({ ...(current as object), url, width: undefined, height: undefined }),
}
```

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:

```ts theme={null}
contentfulLocale<Locale>(DEFAULT_LOCALE, LOCALES, {
  localized: () => schema,
  contentTypeOf: (block) => (block === "articleHero" || block === "articleBody" ? "pageBlogPost" : block),
})
```

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

```ts avocado/publish.ts theme={null}
import type { OnPublishFn } from "@avocadostudio-ai/site-sdk/routes"
import {
  createContentfulManagement,
  entryPatch,
  readEntry,
  type EntryWrite,
} from "@avocadostudio-ai/site-sdk/lens/contentful"
import { cda, lens, loadSchema, DEFAULT_LOCALE, type Locale } from "./contentful"

const cma = createContentfulManagement({
  spaceId: process.env.CONTENTFUL_SPACE_ID!,
  accessToken: process.env.CONTENTFUL_MANAGEMENT_TOKEN!,
})

export const onPublish: OnPublishFn = async (pages, _config, context) => {
  const localized = await loadSchema()
  const writes: EntryWrite[] = []
  // Edits the lens refused (shared alt text, a non-localized field edited on a
  // de-DE page). The editor lists each as "Not published: …".
  const skipped: string[] = []

  for (const page of pages) {
    // The adapter put the entry id and the locale in the page id.
    const [entryId, locale] = page.id.split("~") as [string, Locale]
    const { entry, includes } = await cda.entry(entryId)
    if (!entry?.sys.contentType) continue

    const source = readEntry(entry, { defaultLocale: DEFAULT_LOCALE, localized, includes })
    let doc = source
    for (const block of page.blocks) {
      const merged = lens.merge(doc, block.props, block.type, locale)
      for (const w of merged.warnings) skipped.push(`${page.slug} › ${w.where}: ${w.reason}`)
      doc = merged.doc
    }

    const contentType = entry.sys.contentType.sys.id
    writes.push({
      entryId,
      label: page.slug,
      patch: entryPatch(source, doc, { defaultLocale: DEFAULT_LOCALE, localized, contentType }),
    })
  }

  const result = await cma.publishEntries(writes, {
    defaultLocale: DEFAULT_LOCALE,
    inlineAssets: context?.assets,
  })
  if (!result.ok) return { ok: false, error: result.error }
  return skipped.length > 0 ? { ok: true, unsupported: skipped } : { ok: true }
}
```

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:

| Value                | What happens                                                                                                                                              |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `"refuse"` (default) | The **whole** publish is refused before anything is written, and the response names each entry with pending changes.                                      |
| `"draft"`            | Avocado's change is written into the existing draft, which stays unpublished for its owner to publish. Entries with no pending draft are still published. |
| `"publish"`          | Avocado writes and publishes anyway, and the pending changes go live too. This is only right when Avocado is the only one writing to the space.           |

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.

```mermaid theme={null}
flowchart TD
  A[publishEntries] --> B[Fetch every live entry]
  B --> C{Archived, or pending draft<br/>with policy 'refuse'?}
  C -- yes --> R[Refuse whole publish<br/>nothing written]
  C -- no --> D[Resolve every lookup sentinel]
  D --> E{Every slug found<br/>and published?}
  E -- no --> R
  E -- yes --> F[Create assets for upload sentinels]
  F --> G[Apply patch onto live entry]
  G --> H{Differs from live?}
  H -- no --> U[unchanged]
  H -- yes --> I[PUT entry]
  I --> J{Pending draft and<br/>policy 'draft'?}
  J -- yes --> K[updated]
  J -- no --> L[PUT published]
```

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

```ts theme={null}
import { readFile } from "node:fs/promises"
import { contentfulExportSource, createContentfulDelivery } from "@avocadostudio-ai/site-sdk/lens/contentful"

export const source = process.env.CONTENTFUL_EXPORT_FILE
  ? contentfulExportSource(async () => JSON.parse(await readFile(process.env.CONTENTFUL_EXPORT_FILE!, "utf8")))
  : createContentfulDelivery({ spaceId: …, accessToken: …, preview: true })
```

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

## What to read next

<CardGroup cols={2}>
  <Card title="The field table" icon="table" href="/integration/field-table">
    The table, the two write rules, and `roundTrip`.
  </Card>

  <Card title="Multilingual" icon="language" href="/integration/multilingual">
    One page per entry × locale, and the four rules that keep the round trip clean.
  </Card>

  <Card title="CMS adapters" icon="database" href="/integration/cms-adapters">
    Reading pages out of a CMS and the publish contract.
  </Card>

  <Card title="Coverage checks" icon="clipboard-check" href="/integration/coverage">
    `editableCoverage` and `panelCoverage`, for the renderers and the panel.
  </Card>
</CardGroup>
