Content model
Spillway stores structured content as entities of a content type. A content type is a list of typed fields. An entity is one record of that type: a page, a post, a case study. Every save of an entity creates a revision. The types in this document are defined in packages/shared/src/index.ts; the storage is defined in packages/core/src/db/migrations/0001_init.ts.
Content types
A content type (ContentType) has:
| Property | Type | Notes |
|---|---|---|
key | string | Machine name, ^[a-z][a-z0-9_]{1,62}$ (page, post, case_study). Cannot be changed after creation. |
label, labelPlural | string | "Case study", "Case studies". Shown in the admin navigation. |
description | string | Optional. |
icon | string | Optional icon name for the admin. |
hasPath | boolean | When true, entities get a public URL. |
pathPattern | string | Used when hasPath is true, for example /blog/{slug}. |
titleField | string | Which field is the display title. Defaults to title. |
fields | FieldDef[] | The fields, in display order. |
createdAt, updatedAt | ISO 8601 | Set by the server. |
Content types are managed in the admin under Content types (requires the types.manage permission) or through POST /api/types, PUT /api/types/:key and DELETE /api/types/:key.
Fields
A field definition (FieldDef):
| Property | Type | Notes |
|---|---|---|
key | string | ^[a-z][a-z0-9_]{0,62}$. The key in entity.data. |
label | string | Shown in the admin. |
type | FieldType | See the table below. |
required | boolean | |
multiple | boolean | Store an array of the type instead of one value. |
help | string | Shown under the input in the admin. |
aiHint | string | What belongs in this field. Read by the AI tasks (page from brief, field copy, translation, type drafting) and shown to agents over MCP. |
options | string[] | For select. |
refType | string | For reference: the content type key the field points to. |
vocabulary | string | For taxonomy: the key of the vocabulary the field picks terms from. |
default | unknown | Initial value for new entities. |
translatable | boolean | Whether each language keeps its own value. Default true for text, textarea, richtext, select, link, json and layout; false (shared with every translation, kept in sync from the source) for number, boolean, date, daterange, reference, media and taxonomy. See localization.md. |
searchable | boolean | Default true for text. false keeps a text, textarea or richtext field out of the site search index. |
Field types and their stored shape
Field values live in entity.data, a JSON object keyed by field key. The stored shape per type:
type | Stored as | Example |
|---|---|---|
text | string | "Acme launches v2" |
textarea | string, may contain newlines | "Line one\nLine two" |
richtext | sanitized HTML string from the Tiptap editor (see Rich text) | "<p>Hello <strong>world</strong></p>" |
number | number | 42 |
boolean | boolean | true |
date | ISO 8601 date or datetime string | "2026-03-01" or "2026-03-01T09:00:00Z" |
select | one of options | "enterprise" |
reference | entity id (string) of type refType | "01J..." |
media | media item id (string) | "01J..." |
link | object { href: string; label?: string; newTab?: boolean } | { "href": "/pricing", "label": "See pricing" } |
json | any JSON value | { "anything": true } |
layout | Puck page data (see below) | { "content": [...], "root": {...}, "zones": {} } |
taxonomy | term id (string) from the vocabulary named by vocabulary; an array of ids with multiple | "01J..." |
daterange | { start, end?, allDay?, timezone?, recurrence? }: a date range in its own IANA zone, with an optional repeat rule (freq daily/weekly/monthly/yearly, interval, byWeekday, until or count, exceptions); occurrences are expanded into entity_occurrence on save | { "start": "2027-01-05T16:00", "end": "2027-01-05T17:00", "timezone": "America/New_York", "recurrence": { "freq": "weekly", "byWeekday": ["TU"], "until": "2027-05-11" } } |
With multiple: true the stored value is an array of the shape above.
Values of reference fields are also copied into the reference table (from_entity_id, to_entity_id, field_key) when an entity is saved, so the server can find what links to what without parsing JSON. Values of taxonomy fields are mirrored the same way into entity_term (entity_id, term_id, field_key).
Taxonomy
A vocabulary (Vocabulary) is a named set of terms: topics, departments, audiences, degree types. A term (Term) belongs to one vocabulary and, when the vocabulary is hierarchical, may have a parentId. Both are managed in the admin under Vocabularies (types.manage) or through /api/vocabularies (see the contract in packages/shared/src/index.ts); storage is in packages/core/src/db/migrations/0012_taxonomy_media_shared.ts.
| Property | Notes |
|---|---|
key | Vocabulary machine name, ^[a-z][a-z0-9_]{1,62}$. Taxonomy fields and the API address vocabularies by key or id. |
hierarchical | Whether terms may nest. A flat vocabulary refuses a parentId. |
termPathPattern | Optional, e.g. /topics/{slug} or /topics/{path} ({path} joins the ancestor slugs with /). When set, every term gets a public term page at its path. |
Term.slug | Derived from the label with slugify() unless given; unique among siblings (news, news-2). |
Term.weight | Sort order among siblings; the admin's up/down/indent/outdent buttons write it through POST /api/vocabularies/:id/terms/reorder. |
Term.depth, Term.path | Materialised by the server whenever a term, its ancestors or the vocabulary pattern changes. |
Term.data | Free JSON for kits (a color, an icon, an external code). |
Deleting a term moves its children up to its parent and drops its tags; deleting a vocabulary removes its terms and tags. A term cannot be moved under itself.
The taxonomy field
{ "key": "topics", "label": "Topics", "type": "taxonomy", "vocabulary": "topics", "multiple": true } stores term ids. On save the server checks that every id exists and belongs to that vocabulary (a validation_error otherwise) and rewrites the entity's rows in entity_term. The admin shows a tree picker with search; single fields use radio buttons, multiple fields checkboxes.
Term ids are what you filter on:
- Collections accept
{ "field": "topics", "op": "has_term", "value": "<term id>" }(or an array of ids). The filter matches entities tagged with the term or any of its descendants;fieldmay be a taxonomy field key or*for any field. - Term pages (when the vocabulary has
termPathPattern) list the published entities of every type tagged with the term or its descendants, newest first, twelve per page (?page=2), in the site shell with breadcrumbs and child-term links. They carry the cache tagsterm:<id>,list:<type>for each listed type andsite:<id>, so publishing tagged content, or renaming or moving the term, evicts them. - Search indexes the labels of an entity's terms with its body, so searching for a topic finds everything tagged with it.
- The Article template renders taxonomy fields as a line of links to the term pages.
Rich text
A richtext value is an HTML string written in the admin's Tiptap editor: headings h2 to h4, paragraphs, bold, italic, underline, strike, links, bulleted and numbered lists, block quotes, inline code and code blocks, tables, images from the media library and horizontal rules.
Two kinds of markup carry extra data:
- Internal links are stored as
<a href="/current-path" data-entity-id="<entity id>">. At render time the server replaceshrefwith the entity's current path, so a link survives a slug change, and when the entity is not published the link is unwrapped (the text stays). Pages are taggedentity:<linked id>so they re-render when the target moves. Previews keep links to drafts. - Media embeds are stored as
<img data-media-id="<media id>" src="/media/..." alt="...">. At render time they become a responsive<picture>(WebPsrcsetplus the original format) through the derivative pipeline; an embed whose media was deleted is dropped.
Everything else goes through the server-side sanitizer (packages/core/src/richtext/sanitize.ts) on save and again at render: an allow-list of tags and attributes, href/src limited to relative paths, http(s):, mailto: and tel: (never javascript:, data: or protocol-relative URLs), no style, class, event handlers, scripts, frames or comments; b/i/strike/h1/h5/h6 are normalized to strong/em/s/h2/h4; unclosed tags are closed and stray closing tags dropped; target="_blank" always gets rel="noopener". POST /api/richtext/sanitize runs the same function for the admin preview.
Shared blocks
A shared block (SharedBlock) is one block (Puck component data) saved in the component library with a key and a label, managed under Shared blocks in the admin (types.manage) or through /api/shared-blocks. Pages place it with the Shared block, whose only stored prop is sharedKey. Before a page renders, the server attaches the library copy (with that block's own resolvers applied, so a shared Collection still lists its items) and tags the page shared:<key>; saving or deleting the shared block evicts every page that uses it. A shared block cannot itself be a Shared block.
In the page builder, select any block and use Share in its action bar ("Save as shared block"): name it, and optionally swap the selected block for a Shared placeholder so the page follows the library copy from then on. The Shared block's field is a picker over the library; it shows a placeholder in the editor and renders the real block on the published page.
Slugs and paths
- An entity's
slugis derived from its title withslugify()(NFKD normalize, strip accents, lower-case, non-alphanumerics to-, max 120 characters) unless you set one explicitly. - If the type has
hasPath, the server resolvespathfrompathPatternwithresolvePath(). Tokens:{slug},{id},{type},{yyyy},{mm}(year and month ofcreatedAt, UTC). A pattern without a leading/gets one. - A path must be unique per site and locale; the database enforces it with a unique index on
(site_id, locale, path)wherepathis not null. When the path is already taken, the server appends-2,-3, ... to the slug (or to the path, if the pattern has no{slug}) until it finds a free one. The contract mentions a 409 here; the code chooses the suffix instead. - Paths are normalized: a leading slash is added, duplicate slashes collapsed, a trailing slash removed.
- The slug does not change when you edit the title later, so published URLs stay put. Set it explicitly (or to
null) to change it; it is re-run throughslugify(). - The slug of an entity without a path is kept but has no public URL.
Examples with the built-in types: a page titled "About us" gets slug about-us and path /about-us; a post titled "Hello" gets path /blog/hello.
Entities
An Entity has typed columns for the things the server queries (type, siteId, locale, status, title, slug, path, authorId, timestamps) and a data JSON column for the fields. title is denormalized from the type's titleField so lists and search work without parsing JSON.
Other properties:
currentRevisionId: the latest saved revision, whether draft or published.publishedRevisionId: the revision the public site shows, ornull.publishedAt: when it was last published, ornull.locale: BCP 47 tag, one of the site's locales (defaults to the site's default locale). The public URL adds/<locale>for every locale but the default:/es/about.translationGroupId,isSource,translationStatus: an entity is one translation; translations of the same content share a group whose id is the source's id, and a translation isoutdatedonce the source's translatable content changed after its last save. Translations are created withPOST /api/entities/:id/translations; see localization.md.siteId: the site the entity belongs to;defaulton an instance with one site. See multisite.md.
Statuses
status is one of:
| Status | Meaning |
|---|---|
draft | Being written. Not visible on the public site. |
review | Submitted for review (POST /api/entities/:id/submit). Not visible on the public site. |
published | Visible on the public site at its path. |
archived | Kept but hidden. Set by a scheduled Unpublish at or by the Archive bulk action; there is no single-entity archive endpoint. |
Transitions and who can make them (see ROLE_PERMISSIONS in @spillway-cms/shared):
- Create and edit:
content.create,content.edit_own(authors) orcontent.edit_any(editors and up). - Submit for review:
content.submit_review(authors and up). - Publish and unpublish:
content.publish,content.unpublish(editors and up). - Delete:
content.delete(editors and up).
Editing a published entity keeps status at published but creates a new, unpublished revision; the public site keeps showing publishedRevisionId until you publish again. Unpublishing sets the status back to draft and clears publishedRevisionId.
Revisions
Every create, update and restore writes a row to revision:
| Property | Notes |
|---|---|
number | 1, 2, 3 ... per entity (unique per entity). |
status | The entity status at the time of the snapshot. Publishing updates the published revision's status to published. |
title, data | A full snapshot, not a diff. |
authorId | Who saved it. |
note | Optional, from the note property of PUT /api/entities/:id. |
GET /api/entities/:id/revisions lists them newest first. POST /api/entities/:id/revisions/:revisionId/restore copies an old revision's title and data into a new revision with the note "Restored from revision N" (it does not rewrite history) and makes it current. The status does not change.
Every create, update, transition, restore and delete also writes a row to audit_log (actor_id, action such as entity.publish, target_type, target_id, before, after) inside the same transaction. There is no admin screen or API for it yet; read it with SQL.
Revisions are deleted with their entity (ON DELETE CASCADE).
The layout field and Puck data
A field of type layout holds the page builder document. Its shape (PuckData in @spillway-cms/shared, a dependency-free mirror of Puck's Data):
{
"content": [
{ "type": "Hero", "props": { "id": "Hero-1", "title": "Hello", "...": "..." } }
],
"root": { "props": { "title": "Home" } },
"zones": {}
}
contentis the ordered list of top-level blocks. Each has atype(a block name from@spillway-cms/blocks) andpropswith a uniqueidplus the block's field values.- Blocks with slots (Columns) keep their children inside their own
props, so the document is a tree. root.props.titleis the root "Page title" field. When you save from the page builder, a non-empty root title replaces the entity title. The page<title>comes fromseoTitlewhen set, otherwise the entity title, plus the site's title suffix.zonesis kept for compatibility with older Puck documents and is empty for pages created in the page builder.
The admin opens the layout field of an entity in the full-screen page builder (/admin/content/:type/:id/layout). The server renders it with Render from @puckeditor/core/rsc. See blocks.md for every block and its props.
The two built-in types
Every site starts with these (from BUILTIN_TYPES in @spillway-cms/shared):
page (/{slug})
| Key | Type | Notes |
|---|---|---|
title | text, required | |
layout | layout | The page content as blocks. |
seoTitle | text | Search result title, under 60 characters. |
seoDescription | textarea | Search result description, 120 to 160 characters. |
post (/blog/{slug})
| Key | Type | Notes |
|---|---|---|
title | text, required | |
summary | textarea | Shown in lists. |
body | richtext, required | |
heroImage | media | |
publishDate | date |
Both can be edited like any other type. The setup wizard creates one published page titled "Home" (slug home, path /home) and points SiteSettings.homePageId at it, so / renders it as well.
Spillway 0.1.0 · Edit this page on GitHub