Skip to main content
Pages are the structured surfaces a hub renders: homepages, sales pages, link-in-bio pages, and other public or member-facing views.

Admin page management

Pages are scoped to a team and hub:
Use pages when you need a hub-rendered layout, not just a content lesson.

Sections

Sections are the building blocks inside a page. They can represent text, calls to action, grids, media, search, and other structured page blocks.

Draft-tree authoring

The draft tree is the mutable, unpublished state of a page’s node hierarchy. Write it with:
Team-owner only. The request body is a JSON:API envelope; the If-Match header carries the optimistic-concurrency token (OCC). Bootstrapping the token: read the page first — the admin read (GET /api/v1/teams/{team_id}/hubs/{hub_id}/pages/{identifier}) returns the current version in both attributes.draft_version and the ETag response header. Echo that value as If-Match on your first write; every subsequent write/publish response hands you the bumped token:
The tree must be wrapped in a top-level root key, and root must carry a children array. Below the root, every node needs an id (422 invalid_tree otherwise), settings/dataSource must be objects when present, children must be an array, and the whole tree is capped at 500 elements. The root itself is checked more loosely — its id is optional and its settings is not type-checked — but the renderer wants both, so author it exactly like any other node. See Node shape for what the renderer needs on top of that. On success you get 200 with the bumped version echoed in both the ETag response header and attributes.draft_version:
Store the new draft_version (or echo the ETag value verbatim) as your If-Match token for the next write or publish call. Error codes

Reading the author draft (resolved)

Use the author draft-resolve endpoint to read the fully-transformed draft tree for the page-builder editor.
Authentication: platform bearer + team owner. Both query parameters are required. audience must be author and resolve must be true; omitting either is a 400 (invalid_audience / invalid_resolve). Note that the generated endpoint reference currently lists both as optional — the backend declares them as nullable and enforces them in the handler — so a client generated from the spec will compile without them and then fail at runtime. Send both. The endpoint applies the same cascade, action, and dataSource structure transforms as the portal render path so the editor preview matches what members will see — but it reads tree_draft (the live working copy), not the published tree, and it never prunes nodes behind membership gates. Response type: page_trees
The read and write shapes differ. This endpoint returns tree as the bare root node, already unwrapped from the {"root": …} wrapper, so the hub renderer can consume it directly. PUT .../tree requires the wrapper. Re-wrap the node as {"root": <tree>} before writing it back, or the write fails with 422 invalid_tree“Tree must have a ‘root’ key at the top level.”This is also a resolved projection — action transforms and dataSource.type: "page" expansions have already been applied — so it is a read-back for editing, not a byte-exact copy of what you last wrote.
Error codes

Preview tokens

Mint a short-lived JWT so the page-builder editor can pass an unforgeable token to the mio-hub draft-preview iframe.
Authentication: platform bearer + team owner. The token carries aud="preview", a 15-minute TTL, and embeds page_id, hub_id, and the author’s platform user id. The hub preview host verifies the signature client-side without a backend round-trip. This token is not an alternate auth gate on the resolve endpoint — use the resolve endpoint directly with a platform bearer when you need to read the draft. Response type: preview_tokens
Error codes

Publish

Publishing compiles the editable tree into the served page state.
Send the current draft_version as If-Match (same OCC contract as the tree write). If a concurrent draft write happened after your last read, publish returns 409 — re-read and re-send. The backend uses optimistic concurrency for publish operations. If a frontend edits pages collaboratively, preserve and send the versioning headers shown in the generated endpoint reference.

Node shape

The API validates a tree’s structure, not its renderability. A node with the wrong shape is stored, returns 200, and then renders as nothing — with no error anywhere. Everything in this section is a case where that happens.

The node envelope

A section node is the same envelope plus a template and children:
  • value is a sibling of settings, never settings.value. This is the single biggest silent-drop trap: the API stores settings.value happily, the renderer reads node.value, and you get an empty heading, button, or image with a 200 and no error. The one exception is progress-ring, which reads a numeric settings.value.
  • The renderer dispatches on kind, never on type. type is a JSON:API resource-type word and has no meaning inside a node.
  • template marks a node as a section. It opts the node into the surface renderer and tells the publish-time converter which section type to compile. Section roots carry one, and so does the page rootscaffold --template page-homepage emits a root of {"kind":"stack","template":"page-homepage"}. Inner containers legitimately carry none.
  • settings should always be present ({} minimum). Hub-level defaults are resolved at render time, but the code and template layers of the cascade are not wired up, so in practice every value that must render has to be inlined on its own node — and each leaf also applies its own built-in defaults (see the table below).
  • weight must be a number (700), never a CSS keyword ("bold"). An unrecognized weight is discarded rather than dropping the node: a headline lands on its 400 default, while text and field simply render with no weight class — which for a field means losing the weight its role would have applied. A numeric string ("700") happens to resolve, but pages tree set rejects it client-side — write the number.
  • Exactly one level: 1 headline per page. Extra level-1 headlines are demoted to <h2>, keeping their visual size.
  • Every node needs an id, and ids should be unique within the tree. Uniqueness is renderer hygiene rather than a server rule — the API only checks that the key is present, so duplicate ids are accepted and then confuse id-based behaviour like the one-<h1> guard. Short symbolic ids are legal (the catalog’s own recipes use root and hero), but pages catalog scaffold mints fresh UUIDv7 ids, which is the safer habit when you splice subtrees together.

Leaf kinds

These sixteen are the leaf kinds — the nodes that carry content and take no children. They are one family among the catalog’s 33 kinds; the rest are containers (stack, row, grid, carousel, horizontal-scroll, tabs, container, content-card, accordion, banner — a sticky or reveal-after-scroll notice-bar-style band, plan-group — a radiogroup of plan-card children that publishes the selected card), data bindings (field, media-slot, cta-slot) and system blocks (search-bar, file-player, file-attachments). The table names the settings that matter in practice.
The catalog is the authority on settings shapes. Its settingsSchema map carries the complete vocabulary — types, enums, defaults and per-field notes for all 33 node kinds, plus the shared surface, background, gradient and structural shapes — split into core and presentational buckets matching the consumer profiles. GET /api/v1/page-builder/catalog serves it; the catalog reference tracks the current catalogVersion and content digest.
A plan-price node only resolves through a plan-group it can actually find. settings.groupId first tries an exact match against a plan-group node’s id; if none matches, it falls back to the page’s sole registered plan-group — but with zero or two-or-more plan-group nodes and no exact id match, nothing links up and the node renders settings.fallback forever, even after a visitor picks a plan. Keep fallback byte-identical to the group’s defaultSelected plan-card’s own price/period so the pre-hydration and never-linked states don’t visibly disagree.
A freshly scaffolded testimonials section renders an empty carousel. scaffold --template testimonials emits its three quote children with settings: {} and no value at all, and a quote node whose value.quote is missing or empty renders null. Nothing errors — you publish, section_count is correct, and the band is blank. Fill each child’s value object before publishing:
subheadline, paragraph, embed, html, spacer, stat, input, section, and sidebar are not node kinds. They were removed from the renderer or never existed, and authoring one gets you a blank node with a 200. Use headline with level: 3 instead of subheadline, text instead of paragraph, a stack/row gap instead of spacer, and a row whose stack children carry settings.width (1/3 + 2/3) instead of a sidebar. content-grid is a template id, not a node kind. The catalog reference lists every kind the catalog knows.
A button’s action is an object, and its value is always canonical — no scheme prefix, no #:
type is url, page, email, scroll, or playlist. email takes a bare address (no mailto:), scroll a bare anchor id (no #), url a full URL including its scheme, page a system page type, a page id, or a /-prefixed path, and playlist a playlist id. A malformed or missing action leaves a button that renders but navigates nowhere. The legacy settings.href still works but is deprecated.

Surfaces and backgrounds

A section’s settings.surface takes background, gradient, padding, borderRadius, shadow, visibility, and minHeight. background is a discriminated union of exactly six type values:
Two traps live here.Any background.type outside those six is accepted on publish and renders nothing. The renderer falls through to a default case that returns no class and no style, with no warning in production. If a band looks see-through, check the type string first. thumbnail appears in the renderer’s own types but is implemented nowhere and used by no recipe — treat it as one of the values that silently renders nothing.The gradient config is a sibling of background, not nested inside it. Nesting it is silently ignored and you get the default gradient:
gradient.type is monochrome, analogous, complementary, triadic, split, warm-shift, or custom. custom requires valid hex customStart and customEnd; if either is missing or malformed it degrades to split, which is also what an absent gradient resolves to.

Hub homepage descriptor

The hub bootstrap response (GET /api/v1/hubs/{slug}, and hub admin read/write routes) carries a resolved homepage descriptor on data.attributes.homepage:
Always read homepage from the bootstrap response before calling GET /api/v1/hub/{hub_id}/pages/home. The descriptor is authoritative; /pages/home is a convenience route that only resolves for one of the three states: /pages/home 404s by design when homepage.kind is "discussions_index". This is not an error state — it means the hub has no custom-kind homepage page, and the homepage surface is the discussions index instead. Gate the call on homepage.kind; don’t call /pages/home unconditionally and branch on its status code, and don’t log the 404 as a failure in that case.

Writing the descriptor

Accepts the same homepage object on data.attributes.homepage. An explicit null clears the descriptor — distinct from omitting the key, which leaves it unchanged (standard PATCH / exclude_unset semantics). On a hub that still carries a legacy is_homepage page, null re-resolves to that page as kind: "custom" on the next read; it does not destructively clear the legacy flag. Error codes Both homepage_discussions_disabled rows are two directions of the same coupling guard — one on the page-side write, one on the hub meta write — and both reject rather than leaving the hub in an inconsistent state.

Rollout note

Existing hubs read homepage: null until the legacy backfill runs; after backfill, any hub with an existing is_homepage page resolves to {"kind": "custom", "page_id": ...}. Single-hub bootstrap reads (GET /api/v1/hubs/{slug}) are always fully resolved, including the legacy fallback — there’s no stale-null window there. List endpoints (GET /api/v1/teams/{team_id}/hubs) and the PATCH .../hubs/{identifier} write-path echo may show a stale homepage: null for legacy, pre-backfill hubs — both intentionally skip the legacy-fallback lookup to avoid extra queries, and self-heal once the backfill completes.

Member and public rendering

Portal routes return hub-renderable pages:
Access rules can prune or hide page nodes for a contact. Anonymous render endpoints return pre-pruned public artifacts when available.

Authoring from the CLI

The mio CLI wraps this whole draft → publish contract so you don’t manage the If-Match header by hand:
pages tree set --if-match is optional and defaults to 0 — omit it for the first tree on a draft-less page (pages tree get 404s until a draft exists, so there is no draft_version to read back yet). The 0 default does not bypass the concurrency guard: sending 0 (or any stale value) against a page that already has a draft is rejected as a conflict. For every later write, pass the draft_version from a prior pages tree get — including the first write against a page created by mio hubs scaffold, whose draft already exists at version 1. pages publish --if-match is always required, and its response section_count tells you how many sections actually compiled — treat a shortfall as a dropped section, not a success. Round-tripping an existing tree means re-wrapping it, because tree get returns the bare root node and tree set wants the {"root": …} wrapper:
Catalog discovery has three verbs — mio pages catalog templates, mio pages catalog section-types, and mio pages catalog scaffold. There is no pages catalog list. All three are emit-only and work offline against the CLI’s embedded, digest-pinned catalog copy. See Create a hub for the full homepage recipe and the render-contract gotchas that silently drop malformed nodes.

Templates and section types

The full list of available templates and section types is published in the Page-builder catalog, generated from the live catalog. A page-* template scaffolds a page outline, not finished sections — scaffold each section from its own template and splice it into the page root’s children.