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 content | Site content | |
|---|---|---|
| Unit | a ContentEntry — page, post, doc, changelog | site_collections and site_rows |
| Identity | key (the document) and slug (one locale's URL) | a collection name and a row slug |
| Status | draft, scheduled, published, archived | draft, staged_draft, live |
| Written by | content providers — seed, D1, ZESO-CMS | the tenant API, then publish |
| Scope | shared, per locale | every 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
| Status | Visible to visitors | Set by | Becomes live |
|---|---|---|---|
| draft | No — and never will | an API write | never |
| staged_draft | No | an API write, and the default for new rows | at the next publish |
| live | Yes | publish only | — |
The read side queries status = 'live' and nothing else. Two consequences follow, and the second one is the bug people file:
- The write API cannot produce a live row. It accepts
draftor
staged_draft, and it rejects live with an error that says so — live is publish's decision.
- 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
- Decide whether the row is addressable. If it needs a page, give it a slug; if
it feeds a list, leave the slug null.
- Name a status on every write. New rows default to staged, but be explicit.
- Expect the page to stop resolving until you publish. That is the design.
- Publish after a batch of edits rather than after each one — promotion is per
site, not per row.
- 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.