Spillway CMSStructured content like Drupal. Page building like Puck. Setup like Ghost. AI that drafts, people who approve.
Menu

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:

PropertyTypeNotes
keystringMachine name, ^[a-z][a-z0-9_]{1,62}$ (page, post, case_study). Cannot be changed after creation.
label, labelPluralstring"Case study", "Case studies". Shown in the admin navigation.
descriptionstringOptional.
iconstringOptional icon name for the admin.
hasPathbooleanWhen true, entities get a public URL.
pathPatternstringUsed when hasPath is true, for example /blog/{slug}.
titleFieldstringWhich field is the display title. Defaults to title.
fieldsFieldDef[]The fields, in display order.
createdAt, updatedAtISO 8601Set 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):

PropertyTypeNotes
keystring^[a-z][a-z0-9_]{0,62}$. The key in entity.data.
labelstringShown in the admin.
typeFieldTypeSee the table below.
requiredboolean
multiplebooleanStore an array of the type instead of one value.
helpstringShown under the input in the admin.
aiHintstringWhat belongs in this field. Read by the AI tasks (page from brief, field copy, translation, type drafting) and shown to agents over MCP.
optionsstring[]For select.
refTypestringFor reference: the content type key the field points to.
vocabularystringFor taxonomy: the key of the vocabulary the field picks terms from.
defaultunknownInitial value for new entities.
translatablebooleanWhether 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.
searchablebooleanDefault 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:

typeStored asExample
textstring"Acme launches v2"
textareastring, may contain newlines"Line one\nLine two"
richtextsanitized HTML string from the Tiptap editor (see Rich text)"<p>Hello <strong>world</strong></p>"
numbernumber42
booleanbooleantrue
dateISO 8601 date or datetime string"2026-03-01" or "2026-03-01T09:00:00Z"
selectone of options"enterprise"
referenceentity id (string) of type refType"01J..."
mediamedia item id (string)"01J..."
linkobject { href: string; label?: string; newTab?: boolean }{ "href": "/pricing", "label": "See pricing" }
jsonany JSON value{ "anything": true }
layoutPuck page data (see below){ "content": [...], "root": {...}, "zones": {} }
taxonomyterm 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.

PropertyNotes
keyVocabulary machine name, ^[a-z][a-z0-9_]{1,62}$. Taxonomy fields and the API address vocabularies by key or id.
hierarchicalWhether terms may nest. A flat vocabulary refuses a parentId.
termPathPatternOptional, 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.slugDerived from the label with slugify() unless given; unique among siblings (news, news-2).
Term.weightSort order among siblings; the admin's up/down/indent/outdent buttons write it through POST /api/vocabularies/:id/terms/reorder.
Term.depth, Term.pathMaterialised by the server whenever a term, its ancestors or the vocabulary pattern changes.
Term.dataFree 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; field may 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 tags term:<id>, list:<type> for each listed type and site:<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 replaces href with 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 tagged entity:<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> (WebP srcset plus 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 slug is derived from its title with slugify() (NFKD normalize, strip accents, lower-case, non-alphanumerics to -, max 120 characters) unless you set one explicitly.
  • If the type has hasPath, the server resolves path from pathPattern with resolvePath(). Tokens: {slug}, {id}, {type}, {yyyy}, {mm} (year and month of createdAt, 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) where path is 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 through slugify().
  • 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, or null.
  • publishedAt: when it was last published, or null.
  • 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 is outdated once the source's translatable content changed after its last save. Translations are created with POST /api/entities/:id/translations; see localization.md.
  • siteId: the site the entity belongs to; default on an instance with one site. See multisite.md.

Statuses

status is one of:

StatusMeaning
draftBeing written. Not visible on the public site.
reviewSubmitted for review (POST /api/entities/:id/submit). Not visible on the public site.
publishedVisible on the public site at its path.
archivedKept 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) or content.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:

PropertyNotes
number1, 2, 3 ... per entity (unique per entity).
statusThe entity status at the time of the snapshot. Publishing updates the published revision's status to published.
title, dataA full snapshot, not a diff.
authorIdWho saved it.
noteOptional, 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": {}
}
  • content is the ordered list of top-level blocks. Each has a type (a block name from @spillway-cms/blocks) and props with a unique id plus the block's field values.
  • Blocks with slots (Columns) keep their children inside their own props, so the document is a tree.
  • root.props.title is 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 from seoTitle when set, otherwise the entity title, plus the site's title suffix.
  • zones is 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})

KeyTypeNotes
titletext, required
layoutlayoutThe page content as blocks.
seoTitletextSearch result title, under 60 characters.
seoDescriptiontextareaSearch result description, 120 to 160 characters.

post (/blog/{slug})

KeyTypeNotes
titletext, required
summarytextareaShown in lists.
bodyrichtext, required
heroImagemedia
publishDatedate

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

Was this page helpful?