Skip to content

Section schema reference

Every built-in section is defined with defineSection({ type, schema, ... }). The type string is the discriminator stored in each section’s JSON file; the Zod schema is the source of truth for content shape.

Each block on disk has this outer envelope:

{
id: string; // unique within the site
type: string; // section type key (see below)
content: { ... }; // type-specific content
options?: { ... }; // type-specific display options
layout?: {
colSpan?: number; // only meaningful inside a container
};
}

Source: packages/primitives/src/components/sections/LinkHeading/index.tsx

A top-level section heading. Generates a navigation anchor.

FieldTypeRequiredNotes
content.headingstringYesHeading text

Source: packages/primitives/src/components/sections/SubHeading/index.tsx

A second-level heading. Excluded from the top-level nav by default.

FieldTypeRequiredNotes
content.headingstringYesHeading text
content.excludeFromNavbooleanNoWhen true, omits this heading from nav generation

Source: packages/primitives/src/components/sections/SubSubHeading/index.tsx

A third-level heading. Never appears in top-level nav.

FieldTypeRequiredNotes
content.headingstringYesHeading text
content.excludeFromNavbooleanNoWhen true, omits this heading from nav generation

Source: packages/primitives/src/components/sections/Prose/index.tsx

A rich-text body block. Content is stored as sanitized HTML.

FieldTypeRequiredNotes
content.bodystringYesHTML string; listed in richTextFields and sanitized on save

Source: packages/primitives/src/components/sections/Media/index.tsx

A single image or video block.

FieldTypeRequiredNotes
content.refSingleMediaReferenceYesImage or video reference (see below)
content.linkLinkValueNoOptional link wrapping the media
FieldTypeRequiredNotes
type"image"YesDiscriminator
imageIdstringYesMedia manifest ID; defaults to ""
captionstring | string[]NoCaption text
backgroundstringNoLegacy; not rendered — use the section’s options.background
invertFromstringNoCSS colour; the image inverts when the page background matches it (e.g. for dark-mode logo swaps)
borderbooleanNoRender a border
objectFit"cover" | "contain"NoCSS object-fit

Inherits all "image" fields, plus:

FieldTypeRequiredNotes
type"video"YesDiscriminator
posterstringNoPoster image URL
autoplaybooleanNoAuto-play on load
loopbooleanNoLoop playback
mutedbooleanNoMuted by default
FieldTypeRequiredNotes
options.squarebooleanNoForce square aspect ratio
options.showCaptionbooleanNoRender the caption below the media
options.borderbooleanNoRender a border
options.objectFit"cover" | "contain"NoCSS object-fit; defaults to "contain"
options.backgroundImageBackgroundNoBackdrop filling the image box; images only, ignored for video. Omit for none (see Shared types)

Source: packages/primitives/src/components/sections/Video/index.tsx

A watchable video player — self-hosted from the media library or embedded from YouTube/Vimeo/Instagram. Instagram embeds render the post card at a kind-derived aspect ratio (reels 9:16, posts 4:5) that editors can override. Use playback "click" for content viewers press play on (controls + audio) and "ambient" for silent looping footage. For decorative loops inside grids prefer the media block; large self-hosted files require the site’s asset storage.

FieldTypeRequiredNotes
content.sourceLibrarySource | ExternalSourceYesWhere the video comes from (see below)
content.captionstringNoRendered beneath the player when options.showCaption is on
FieldTypeRequiredNotes
type"library"YesDiscriminator
ref.imageIdstringYesMedia manifest id of the video asset; defaults to ""
poster.imageIdstringNoMedia manifest id of an image used as the poster instead of the auto-generated frame. Kept as a sibling of ref (not nested inside it) so the SSR ref-baker resolves the override independently, instead of colliding with the video’s own auto-generated poster
FieldTypeRequiredNotes
type"external"YesDiscriminator
provider"youtube" | "vimeo" | "instagram"YesDerived from the pasted URL
idstringYesProvider video id parsed from the pasted URL (Instagram: the post/reel shortcode)
kind"post" | "reel" | "tv"NoInstagram only: which embed path the shortcode came from (/p/ → "post"). Omitted for other providers
urlstringYesThe original video page URL as pasted
widthnumberYesAspect-ratio width (from oEmbed; 16 when unknown; Instagram: 9 for reels/tv, 4 for posts — editors can override via the aspect toggle)
heightnumberYesAspect-ratio height (from oEmbed; 9 when unknown; Instagram: 16 for reels/tv, 5 for posts — editors can override via the aspect toggle)
FieldTypeRequiredNotes
options.playback"ambient" | "click"No"click" (default): poster + native controls with audio. "ambient": autoplays muted in a loop with no controls
options.showCaptionbooleanNoWhen true, render the caption beneath the player

Library source:

{
"type": "video",
"content": {
"source": { "type": "library", "ref": { "imageId": "a1b2c3d4" } },
"caption": "Behind the scenes"
},
"options": { "playback": "click", "showCaption": true }
}

Library source with a poster override (note poster is a sibling of ref, each with its own imageId):

{
"type": "video",
"content": {
"source": {
"type": "library",
"ref": { "imageId": "a1b2c3d4" },
"poster": { "imageId": "e5f6a7b8" }
}
},
"options": { "playback": "click" }
}

External source:

{
"type": "video",
"content": {
"source": {
"type": "external",
"provider": "youtube",
"id": "dQw4w9WgXcQ",
"url": "https://youtu.be/dQw4w9WgXcQ",
"width": 16,
"height": 9
}
},
"options": { "playback": "ambient" }
}

Instagram reel (aspect ratio derived from the URL kind; Instagram ignores the playback option — the embed card controls its own playback):

{
"type": "video",
"content": {
"source": {
"type": "external",
"provider": "instagram",
"id": "C1a2B3c4D5e",
"kind": "reel",
"url": "https://www.instagram.com/reel/C1a2B3c4D5e/",
"width": 9,
"height": 16
}
},
"options": {}
}

Source: packages/primitives/src/components/sections/Button/index.tsx

A call-to-action button.

FieldTypeRequiredNotes
content.textstringYesButton label
content.linkLinkValueNoDestination; see LinkValue below
content.downloadbooleanNoAdds download attribute to the rendered anchor

Source: packages/primitives/src/components/sections/Container/index.tsx

A layout wrapper that holds child blocks. Nesting is capped at depth 2 (a container may hold leaves, not other containers).

FieldTypeRequiredNotes
content.layout"grid" | "masonry"YesLayout engine: grid arranges children in fixed columns with per-child column spans; masonry flows children through a CSS multi-column gallery at their natural height (no column spans); defaults to "grid"
content.columnsnumber (int, 1–6)YesGrid: number of columns children flow across. Masonry: number of CSS multi-columns children flow through — the editor’s Columns select floors this at 2 in masonry mode (a single masonry column would just be a plain stack). Defaults to 1
content.equalizeRowHeightbooleanYesGrid only: stretch every child to the height of the tallest child in its row (best-effort — applies to children whose root element can stretch, e.g. media/cards). Ignored in masonry. Defaults to false
content.childrenSection[]YesChild blocks (any section type, each with an id and optional layout.colSpan); defaults to []
content.childDefaultsRecord<string, unknown>NoDefault options applied to children added via the editor

Masonry is columns-only (CSS columns-N multi-column flow, gap-8 + per-item mb-8 break-inside-avoid) — there is no justified-rows flow and no aspect-ratio layout engine; items flow at their natural height in real DOM order, so drag-and-drop reordering works the same as grid.

Child blocks may carry layout.colSpan (int ≥ 1). In grid layout, if colSpan exceeds columns, it is clamped to columns on the next parse. In masonry layout, layout.colSpan is stripped from every child on parse — masonry has no column spans.


Source: packages/primitives/src/components/sections/Spacer/index.tsx

An empty vertical gap. No content fields.

FieldTypeRequiredNotes
content{}—Always an empty object

Source: packages/primitives/src/components/sections/Reference/index.tsx

Shows read-only, live copies of other sections. A Reference stores pointers, never content: it resolves them on every render against the published site, so editing a source updates every Reference that shows it.

FieldTypeRequiredNotes
content.tagIdsstring[]YesTag ids from tags.json; every section carrying one is shown. Defaults to []
content.sectionIdsstring[]YesIndividually picked section ids, from any page. Defaults to []

Resolution rules:

  • Union, site order. Tagged and picked sections combine; an overlap shows once. Items are listed in site order (pages in nav order, then position on the page), whatever order the ids are stored in.
  • Never widens exposure. An item shows only if the viewer passes its source page’s access and its source section’s access — on top of the referencing page’s and the Reference’s own.
  • Status. Only live sections on live pages. Hidden (not-in-nav) pages count; archived pages do not.
  • Nesting. Containers come whole. References in the source set are skipped, the Reference itself included. A Reference is top-level only and cannot go inside a container.
  • Nav. Headings (and nav-on TL;DR plates) inside the copies join the referencing page’s nav as its own entries. Their anchors are suffixed with the Reference’s own id (<heading>-<reference id>, then -2, -3 on further collision), so two References showing the same section never collide and a copy’s anchor doesn’t change when References are reordered; anchors native to the page never move. In the editor, the same headings appear in the sidebar nav.
  • Dangling ids. Unknown section ids and deleted tags drop silently on the viewer, and the editor flags them. The MCP connector’s save_sections is stricter: it rejects a Reference that names an unknown tag or section when the Reference is saved. validate_site and pnpm exec authoring validate report the same dangling ids as warnings.

Source: packages/primitives/src/components/sections/Colors/index.tsx, schema.ts

A brand color palette display.

FieldTypeRequiredNotes
content.colorsColorItem[]YesOne entry per color
FieldTypeRequiredNotes
namestringNoDisplay name for the color
spacesColorSpace[] (min 1)YesOne or more color space representations

At least one field must be present.

FieldTypeRequiredNotes
hexstringNo6-digit hex (#rrggbb)
rgbstringNoFree-form RGB string
cmykstringNoFree-form CMYK string
pantonestringNoPantone name or code
FieldTypeRequiredNotes
options.labelstringNoSection label text
options.columnsnumber (int, 2–4)NoDisplay columns
options.collapsingbooleanNoEnable collapsing layout
options.showLabelbooleanNoRender the label; defaults to true in settings

Source: packages/primitives/src/components/sections/IconList/index.tsx

A list of labelled items, each with optional icon and do/don’t marker, or numbered 1, 2, 3… instead of icons.

FieldTypeRequiredNotes
content.itemsIconListItem[]YesOne entry per list item
FieldTypeRequiredNotes
labelstringYesShort label
textstringYesBody text
iconstringNoIcon identifier
dodont"do" | "dont"NoDo/Don’t marker
FieldTypeRequiredNotes
options.iconstring | nullNoDefault icon for all items
options.showLabelbooleanNoRender item labels
options.stackTextbooleanNoStack label + text vertically
options.dodont"do" | "dont"NoWhole-list Do/Don’t. Renders every item as a green check ("do") or red x ("dont") by default; a per-item dodont or a per-item icon overrides it. Inserted ready-made by the “Do / Don’t list” Add-menu preset.
options.marker"icon" | "number"NoWhat marks each item. Absent means "icon". "number" renders bold numerals starting at 1 in the icon’s place (same size, column and color — a per-item dodont still tints its numeral), ignoring per-item and default icons. Standard lists only: ignored when options.dodont is set.

Source: packages/primitives/src/components/sections/DoDontMedia/index.tsx

A media block with an explicit Do / Don’t annotation. Designed to be used inside a container alongside other dodont_media blocks.

FieldTypeRequiredNotes
content.refSingleMediaReferenceYesImage or video reference; same shape as media
content.dodont"do" | "dont"YesAnnotation shown on the block
content.linkLinkValueNoOptional link wrapping the media
FieldTypeRequiredNotes
options.squarebooleanNoForce square aspect ratio
options.showCaptionbooleanNoRender the caption below the media
options.borderbooleanNoRender a border
options.objectFit"cover" | "contain"NoCSS object-fit; defaults to "contain"
options.backgroundImageBackgroundNoBackdrop filling the image box; images only, ignored for video. Omit for none (see Shared types)

Source: packages/primitives/src/components/sections/Document/index.tsx

One downloadable/viewable file — a PDF or a single self-contained HTML file — presented as a clickable row. Clicking opens it in a full-viewport preview overlay without leaving the page; on phones a PDF opens in the OS viewer instead. Pick the file from the media library (documents tab); leave the title blank to show the file name without its extension, or type one to override it. Leave “Show in Documents menu” on for anything a viewer should be able to reach from any page — those entries collect into a derived Documents group at the bottom of the left nav. Use one section per file; for a set of downloads, place several document sections in a container.

Bytes are auth-gated end to end: the row and the overlay both read /api/document-file/{documentId}/{filename}, which enforces the site gate plus the referencing section’s and page’s access. File metadata (name, size, kind) lives in the src/content/document-manifest.json sidecar, keyed by content.doc.documentId — never in image-manifest.json.

FieldTypeRequiredNotes
content.titlestringYesRow title. Blank renders the file name without its extension — a display fallback, never written back into content
content.doc.documentIdstringYesHash key into the document manifest. Deliberately not imageId — documents are not resolved by the media ref-baker
content.showInDocumentsNavbooleanNoDefaults to true. When on, the document joins the derived “Documents” group at the bottom of the left nav
FieldTypeRequiredNotes
options.allowCommentsbooleanNoDefaults to false. When on, editors can leave comment threads on the document in its overlay, and so can named viewer sign-ins of the audiences in options.commentAudiences. Editors read it from the saved draft and viewers from the published site, so a change takes effect when the editor saves (and, for viewers, publishes). Turning it off hides existing threads without deleting them. Hidden in the editor on a site with no comment store: comments need platform mode, or (standalone) SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY and PORTAL_SITE_ID, and a standalone site must first apply the five comment migrations to its own Supabase project — see Auth & audiences setup. Editor guide: Reviewing with comments
options.commentAudiencesstring[]NoDefaults to []. Comment access: the audience names whose named sign-ins may comment when options.allowComments is on, each seeing every thread on the document, from other assigned audiences and from editors. An assigned audience must also be able to open the document (page and section access). Empty means editors only. Viewers read it from the published site. Shown in the editor as Who can comment, offering the audiences the section’s access allows
options.inlinePreviewbooleanNoDefaults to false. When on, the file also renders inline below its row at the content width, in a fixed-height frame it scrolls within (HTML in the same sandboxed iframe as the overlay, PDFs in the same pdf.js viewer); clicking the row still opens the overlay. Shown in the editor as Inline preview on the Display tab
options.queryStringstringNoDefaults to "", max 1024 characters. HTML files only: the query string the document opens with (leading ? optional), so its own scripts can read it from location.search. It rides the gated URL into the overlay and any new-tab open, never a download; on the bucket tier the file route signs it into the presigned redirect, since a parameter appended after signing would break the signature. Names the route or storage reserve are dropped: download, x-amz-*, response-*, and storage subresources (acl, attributes, cors, legal-hold, partNumber, retention, tagging, torrent, uploadId, uploads, versionId). Shown in the editor as Query string on the Parameters tab, for HTML files only
options.breakpointsbooleanNoDefaults to false. Takes effect only when options.allowComments is on and the file is HTML: the document overlay then offers Desktop (1440px), Tablet (768px) and Mobile (390px) width presets, and a thread’s width tag switches to its preset. Shown in the editor only under Allow comments and only for HTML files. Pins show at every width either way

Source: packages/primitives/src/components/sections/SectionSummary/index.tsx

The end-of-section TL;DR plate (“TL;DR / Summary” in the Add menu): a primary-colored plate with a kicker, a script accent, numbered principles (bold headline + optional one-sentence explainer, each with an optional jump-link back to the sub-section it distills) and a footer with a note and a copy-summary action. Renders as static HTML on the viewer — no hydration; the copy action is a tiny inline script. Colors and fonts are theme tokens: --color-primary / --color-on-primary / --color-primary-contrast, and the optional site hooks --font-label and --font-accent, both falling back to the heading font.

It is the one non-heading section that can put itself in the page nav. With options.nav set, it adds an entry under options.navLabel: "heading" at the top level (like a Link Heading), "subheading" nested under the preceding Link Heading — and, exactly like a Sub Heading, dropped from the nav when there is none (the editor shows a hint). The anchor is derived from the label; if another anchor on the page already uses it (a heading’s, or another summary’s), it gets -2, -3… Heading anchors never move.

FieldTypeRequiredNotes
content.kickerstringNoUppercase label, top-left. Default ""
content.accentstringNoScript flourish, top-right, e.g. "tl;dr". Default ""
content.itemsSummaryItem[]NoThe principles, in display order; numbered automatically
content.footNotestringNoFooter note; empty = auto "<count> principles"
FieldTypeRequiredNotes
headlinestringNoThe principle, short and bold
explainerstringNoOptional one-sentence explainer
linkLinkValueNoOptional jump-link; empty renders nothing but keeps its line
linkLabelstringNoJump-link text; auto-derived for internal links, falls back to the URL for external
FieldTypeRequiredNotes
options.showAccentbooleanNoDefault true
options.showFooterbooleanNoDefault true
options.showCopybooleanNoCopy-summary action. Default true
options.numberedbooleanNoItem numerals. Default true
options.nav"off" | "heading" | "subheading"NoPage-nav entry level. Absent = off
options.navLabelstringWhile nav is onNav entry text; no label = no entry. Prefilled from the kicker in the editor

Defined in packages/primitives/src/schemas/shared.ts. A per-placement backdrop behind an image, discriminated on mode. Used by media and dodont_media as options.background.

Light / Dark (mode: "theme") — the site’s light or dark surface, chosen separately per color mode. A dark plate chosen for light mode stays dark.

FieldTypeNotes
mode"theme"
light"none" | "light" | "dark"Backdrop in light mode
dark"none" | "light" | "dark"Backdrop in dark mode

Custom (mode: "custom") — one color in both modes.

FieldTypeNotes
mode"custom"
colorstring6-digit hex, e.g. "#3b1f3a"

Defined in packages/primitives/src/schemas/link.ts. A discriminated union on kind.

External link (kind: "external"):

FieldTypeNotes
kind"external"
hrefstringMust be empty, relative, http(s)://, or mailto:; dangerous schemes (javascript:, data:) are rejected
target"_self" | "_blank"

Internal link (kind: "internal"):

FieldTypeNotes
kind"internal"
pageIdstringID of the target page
anchorSectionIdstring | null | undefinedOptional in-page anchor
target"_self" | "_blank"