All documentation

Collections, content types and publishing state

Two systems here are called a collection. Saving a row does not make it visible: the write API cannot create a live row, and nothing renders until publish runs.

Collections, content types and publishing state

Two different systems in this codebase are called a collection, and the one people get wrong is the second. Saving content does not make it visible: the write path cannot create a live row, and nothing renders until publish runs. If you are about to touch the content API, this is the part that will cost you an hour otherwise.

Two systems, one word

Platform contentSite content
Unita ContentEntry — page, post, doc, changelogsite_collections and site_rows
Identitykey (the document) and slug (one locale's URL)a collection name and a row slug
Statusdraft, scheduled, published, archiveddraft, staged_draft, live
Written bycontent providers — seed, D1, ZESO-CMSthe tenant API, then publish
Scopeshared, per localeevery query filtered by site id

Platform content: key is identity, slug is a URL

Four collections: page, post, doc and changelog. Each declares its route, how a URL finds it, whether it is localized, and what it owns — including what it must not absorb from code. Marketing pages are addressed by key, so renaming a slug cannot move a page that other people link to; docs, posts and changelog entries are addressed by slug.

key is locale-independent. The English and Chinese rows of one document share it, which is what makes them two variants of one document rather than two pages that happen to look alike. Linking translations by slug is the mistake the model was changed to prevent: rename one slug and the other language silently stops being the same page. data.tags drives grouping, which is how this article is filed.

Status here has no staging step. An entry is draft, scheduled, published or archived, and setting it is a direct write.

Site content: fields, rows and addresses

A collection is a name, a list of typed fields and a few flags. Field types are text, rich_text, number, boolean, date, image, option and reference. A row is one JSON document of field names to values, plus an optional slug. A row with a slug renders as a page at that address; a row without one is data-only and feeds lists and widgets instead.

Slugs are strict: lowercase, letters digits and dashes only, at most 64 characters, and unique within the collection. Data-only rows carry a null slug and never collide, because SQL treats nulls as distinct.

Writes are validated against the collection's fields — an unknown field or a type mismatch is rejected, and required fields are checked when a row is created. On update the merge is per field: a field you omit keeps its value, and a null clears it. External collections are read-only; their rows live at the data owner, and a write to one is refused.

The three states, and the one that surprises people

StatusVisible to visitorsSet byBecomes live
draftNo — and never willan API writenever
staged_draftNoan API write, and the default for new rowsat the next publish
liveYespublish only—

The read side queries status = 'live' and nothing else. Two consequences follow, and the second one is the bug people file:

  1. The write API cannot produce a live row. It accepts draft or

staged_draft, and it rejects live with an error that says so — live is publish's decision.

  1. Editing a live row takes it out of the live set. Because a status must be

named and live is refused, updating a row that is currently live demotes it to staged or draft until you publish. Calling update without naming a status fails rather than demoting silently. If you edited content through the API and the page began returning 404, this is why.

What publish actually does

One batch, four steps: copy a staged structure document onto the live one if a staged document exists; promote every staged row of the site to live; bump the site's content version; then purge the site's cache tag. The promotion and the version bump share one transaction, so the version can never run ahead of the content it names.

Two operational properties are worth knowing:

  • Publish never retires anything. Rows already live stay live. Removing

content is an explicit edit, not a side effect of somebody else's publish.

  • A publish with zero staged rows is legitimate. It still bumps the version

and purges, which is what makes re-publishing the recovery path after a partial failure rather than a gamble.

A checklist before you write

  1. Decide whether the row is addressable. If it needs a page, give it a slug; if

it feeds a list, leave the slug null.

  1. Name a status on every write. New rows default to staged, but be explicit.
  2. Expect the page to stop resolving until you publish. That is the design.
  3. Publish after a batch of edits rather than after each one — promotion is per

site, not per row.

  1. If a purge fails, publish again. The content is already live at that point;

the failed step was the cache, and re-publishing re-purges.