Skip to main content
A hub is the member destination. It owns pages, content, community spaces, policies, checkout display, and member-specific settings. You can create a hub in three ways: in the admin at app.member.dev, with the mio CLI, or with the raw API. The admin is the quickest way to create, open, rename and delete a hub. Its Pages list can create and delete pages and link to supported page views on the member-facing hub. Hub owners can edit supported pages in the mio-hub inline editor; availability depends on the hub environment’s release. The CLI and API remain options for authoring page trees. Start with mio hubs scaffold, which builds an entire hub in one command; drop to the step-by-step recipe when you need something the template does not cover. It also spells out the render-contract gotchas that cause silent failures: the API returns 200, but the hub renderer drops malformed input, so a card or section never appears and nothing tells you why. These silent drops are the number-one pain point in real CLI-only builds.
Every mio command in this guide is verified against the CLI. Prefer the CLI for hub authoring — it validates blob keys, typed menu items, and hub-scoped hrefs before firing a request. The raw API is documented at the end for direct integrators.

In the admin

The admin at app.member.dev is where you manage your account: your hubs, the media library, API keys and account settings. If you are new to it, Getting started in the admin walks you from sign-up to your first open hub. This section covers the hub tasks the admin handles and names the API request that does the same thing. Only the team owner can create, change or delete a hub, in the admin and through the API alike.

Create a hub

  1. Open Hubs in the sidebar. The admin opens on this page when you sign in.
  2. Click Create hub at the top of the page. When you have no hubs yet, the empty state has the same button, and so does the Create a hub card on the Getting started page.
  3. Fill in the Create hub dialog and click Create hub.
The new hub appears in the list and the admin confirms with Created 'Member Academy'. Once the MIO-4519 Admin release is deployed and that environment enables the scaffold API, Admin creates hubs from the active starter template. The template gives the hub neutral branding, a Home, Content, Discussions, Events, and Members menu, public Announcements and General discussion spaces, a Getting Started playlist with placeholder lessons, a welcome post, and a starter homepage. The homepage is public, but a hub created with Private hub on remains private until you make it public. You can edit or replace the starter content after creation. Scaffolding is enabled per backend environment and is off by default. While it is unavailable, Admin creates a bare hub through the regular create endpoint; that fallback does not add the starter menu, spaces, playlist, welcome post, or homepage. Availability in one environment does not mean it has rolled out to every environment. Slugs are unique across all of Membership.io, not only within your team. If another hub already uses the slug you chose, your hub is still created, with a random six-character suffix added, and the confirmation says so: Created 'Member Academy' with slug 'member-academy-a1b2c3'; your requested slug was unavailable. You can choose a different slug afterwards in General settings.
Self-registration depends on whether the starter scaffold is available in your environment. The starter template enables self-registration even when the new hub is private; making that hub public later does not require a separate registration change. If Admin falls back to bare hub creation, only a hub created public gets self-registration turned on automatically. Making a bare hub public later does not turn it on. Admin does not yet have a registration control; use the CLI (mio hubs update <hub_id> --registration-enabled) to change it. See what publishing does not turn on.
API equivalent for the MIO-4519 Admin release where scaffolding is enabled: POST /api/v1/teams/{team_id}/hubs/from-template. The fallback uses POST /api/v1/teams/{team_id}/hubs/. See Create a hub with the API.

Open the live hub

Each hub card shows the hub’s name, its slug with a button to copy it, when it was last updated, and a Private or Public badge. The card actions are:
  • Manage opens the hub’s Pages list in the admin (see Manage a hub).
  • Open hub opens the live hub in a new tab when the admin has a hub origin configured. The destination uses that environment’s hub origin and the hub slug, even if the hub has a custom domain. A private hub is visible only to its members; to anyone else the address answers as if the hub did not exist. The action is not shown when the origin is unavailable.
The card’s ⋯ menu has Copy slug and Delete hub. API equivalent: the hub resource has no URL attribute, so build the address from data.attributes.slug in the same way. To read a hub by its slug, see Look a hub up by slug.

Manage a hub

Manage opens the hub’s Pages list (/hubs/{hub_id}/pages). Its sidebar lists Pages, Payments, and Settings. On Pages, select Create page to add a page. The dialog asks for Title, Slug, and Privacy (Members by default). The list shows each page’s access level. Select a page title or choose View page from More options to open a supported page on the member-facing hub in a new tab; some rows do not have a page view. A new, unpublished page is not visible to public visitors or members; its signed-in hub owner can open the draft in the inline editor, while other visitors may see not found. Use More options to delete a page when allowed. In the workspace sidebar, View hub opens the member-facing hub in a new tab. These links are available only when the current admin environment has a hub origin configured. Viewing a page does not sign you in as a hub contact. Public pages can be viewed without a hub session; a private hub or member-only page may require you to sign in through the hub’s existing sign-in flow. To edit a supported homepage, custom page, or sales page, sign in to the hub as its owner and select the floating Edit button. The editor is part of the mio-hub release, so its availability depends on the hub environment. The CLI and API also support page-tree authoring; see the Pages and page builder guide. Choose Settings to open General settings (/hubs/{hub_id}/settings/general). There you can change:
  • Hub name and Description (optional).
  • Slug. This changes the hub’s address, and links that use the old slug stop working. If another hub already uses the new slug, the field shows That slug is already taken. Unlike when you create a hub, no suffix is added for you.
  • Private hub. Making a private hub public does not change its registration setting. A hub created from starter already has registration enabled; a bare hub created private needs registration enabled separately (see the warning under Create a hub).
Click Save settings to apply them. The admin confirms with Settings saved. The Payments page shows the active team’s Stripe connection, which is shared across its hubs. If it is not connected, select Connect Stripe; if setup is incomplete, select Continue in Stripe. The admin opens Stripe directly and returns to this page afterwards. There is no intermediate payment sheet. API equivalent: PATCH /api/v1/teams/{team_id}/hubs/{hub_id}. See Update a hub with the API.

Delete a hub

Delete a hub from Settings → Danger zone (Delete hub) or with Delete hub in the hub card’s ⋯ menu. In the Delete hub? dialog, type the hub’s exact saved name into Type to confirm. The destructive Delete hub button stays disabled until the name matches. A successful delete returns you to the Hubs list. Deleting is a soft delete that cascades: the hub, its pages and their sections, and its content items are all marked deleted, and every membership in the hub ends, so members lose access straight away. Neither the admin nor the API can restore a deleted hub. Its slug becomes free for a new hub. A hub with a custom domain cannot be deleted until the domain is removed. API equivalent: DELETE /api/v1/teams/{team_id}/hubs/{hub_id}. See Delete a hub with the API.

What stays out of the admin

The admin handles account-level work and lists a hub’s pages. Managing a hub’s content and members happens in the hub itself, and the CLI and the API cover the same ground. Admin applies the starter template during hub creation where the scaffold rollout is enabled, but it does not offer a template picker or controls for branding and navigation. Use the CLI recipe in the rest of this guide when you need to choose or customize a template.

Before you start

Set your team (and, once created, your hub) context so you can drop the repeated flags:
Two ways to get this wrong, both of which exit 2 and write nothing:
  • The only writable keys are current_team, current_hub, and api_base. There is no team or hub key — mio config set team … fails with unknown config key "team" (valid: [current_team current_hub api_base]).
  • current_team and current_hub must be a UUID. A prefixed or slug-style id is rejected: invalid current_team "team_abc123": must be a UUID (e.g. 019e204f-9ea0-7601-ac0f-ab522eece374). Read the real value from mio teams list and mio hubs list.
Run mio config list to see the current values and the config file path. The API key is a secret managed by mio login / MIO_API_KEY, not by config set.
Ids elsewhere in this guide are written as readable placeholders (hub_abc123, page_home123); substitute the real UUIDs your own commands return. The CLI flattens JSON:API resources by default, so mio hubs create ... --jq .id gives you the new id for the next step.
Capturing an id into a shell variable needs -o plain. --jq alone renders its result as JSON, and a JSON string keeps its quotes — so ID=$(mio pages create … --jq .id) sets ID to "page_abc123" including the quote characters, and the next command 404s on an id that does not exist. Add -o plain:
Numbers are unaffected (--jq .draft_version gives a bare 1), and -o plain is wrong when you want JSON on disk — it flattens an object to key=value lines, so keep the default output for anything you redirect into a .json file.

Start here: scaffold the whole hub

mio hubs scaffold collapses the entire sequence below into a single idempotent command, and it is the recommended way to reach a working hub. It creates the hub and applies a full-experience template — brand colours, navigation, registration, discussion spaces, onboarding schema, policies, playlists, and a homepage. Either the backend builds the whole hub in one request, or the CLI builds it step by step through its own request-builders and client layer (never raw REST), where every template value passes the same validation as the individual commands below; see where the hub gets built.
The shipped starter template applies no imagery — no logo, favicon, social image, or page images. Set your own with --logo-url / --favicon-url (below) and by filling the homepage’s image nodes with durable URLs.A hub with no logo_url renders its title as text rather than a broken image, so a freshly scaffolded hub looks intentional — just unbranded.
starter is the one hub template the current catalog ships. A template id the backend does not serve exits 2 before anything is written, naming the ones it does: hub template "community" is not in the catalog — available: starter. Templates are read from the page-builder catalog served by the backend the CLI is pointed at, so mio hubs templates needs credentials and lists exactly what a scaffold against that backend would apply. It is safe to re-run: existing spaces, onboarding attributes, and playlists are skipped rather than duplicated. The hub is left private by default — add --publish to make it public. Self-registration comes from the template (the starter template sets settings.registration.enabled: true), not from publishing; see what publishing does and does not turn on. When the template declares a welcome discussion post, the scaffold creates it once (a re-run finds it by title and does not post a second one), and the backend makes the team owner the hub’s owner-admin as part of creating the hub.
A --hub run fills gaps; it does not overwrite. It adds what the hub is missing and keeps everything the hub already has:
  • branding and settings keys, including a closed self-registration;
  • navigation menus, bucket by bucket. An empty bucket is filled; a bucket the template does not have, such as the mobile menu, is never removed;
  • policy text and the policy enforcement gate;
  • onboarding attribute settings.
Flags you pass on the same command (--primary-color, --logo-url, --registration-enabled, and the rest) still win over the hub. Each value it keeps is reported on stderr and in --dry-run.To let the template win instead, add --reapply-template. Off a TTY it needs --yes, and without --hub it is a usage error. It changes the hub like this:
  • every branding and settings key the template sets replaces the hub’s value, except the policy settings (enforcement is covered below). Keys the template does not set are left alone;
  • the template’s menu replaces the hub’s whole menu, so a bucket the template does not have, such as the mobile menu, is removed;
  • hand-written legal text is replaced with the template’s text, or reset to the platform default when the template has none;
  • policy enforcement is turned on when the template enables it. It is never turned off;
  • the onboarding settings of the template’s attributes are overwritten.
Pages are never overwritten, with or without --reapply-template. Every --hub run checks the template’s pages first. It stops with exit code 2 before anything is written, --dry-run included, if it finds any of these:
  • a page you edited;
  • someone else’s page at a template slug;
  • an existing homepage that the template’s homepage would replace.
The error names each page, and no flag gets past it: you resolve the page yourself. Existing spaces, onboarding attributes and playlists are skipped either way.When members are asked to accept the terms again. A --reapply-template run asks members to accept the terms of service again only when it changes the terms’ version. Only a template whose terms require acceptance changes it, and the starter template’s do:
  • A reset to the platform default sets the version to default-v1. If mio hubs policies get already reports default-v1 for the terms, the version stays the same and nobody who accepted the terms is asked again, although the text is still reset. Any other version was set when custom text was saved with acceptance required (--require-acceptance), and later saves or resets without it keep that version. In that case the reset asks everyone who accepted it.
  • Template text that differs from the stored text gets a new version, so everyone is asked. Identical text keeps its version.
  • A template whose terms do not require acceptance never changes the version, so the text is replaced without asking anyone who accepted the terms to accept them again.
Members see the prompt only while policy enforcement is on. Enforcement also asks every member who has never accepted the terms, whatever the version, so a run that turns enforcement on asks those members too.This behaviour ships in the mio release after v0.23.0. On v0.23.0 and earlier, every --hub run overwrites the hub’s palette, menu, registration and policy text with the template’s, and --reapply-template is an unknown flag.
A scaffolded page already has a draft. Its draft_version is 1, not 0, so the first tree write you make against it needs --if-match 1 — see step 6. Never guess the token; read it back with mio pages tree get <page_id> --jq .draft_version.

Where the hub gets built

A create (--name and --slug) first asks the backend to build the whole hub in one transaction, with POST /api/v1/teams/{team_id}/hubs/from-template. Each deployment switches that operation on or off, and it is off by default. When the backend does not offer it, the CLI builds the hub itself, step by step, and says so on stderr (hub scaffold op not available on this backend — applying client-side). The CLI also builds client-side, naming the reason on stderr, for --hub, for a create missing --name or --slug, for --catalog, and for any branding override (--branding-json, or a palette flag such as --primary-color). --dry-run never calls the operation. -o json reports the same keys either way. Every playlist the scaffold creates also gets content items: a container for the playlist and a lesson for each of its items. Content items are what give a playlist’s files progress tracking, “My List” saves and comments, and they are what a page’s file binding points at (see which id a dataSource takes). -o json lists them under content_nodes. On a --hub run the CLI reconciles the playlists it created or can recover, and names any others on stderr with the command to run. Only the server-side build records which template built the hub, so a bare mio content reconcile --hub <hub_id>, which reconciles the playlists the hub was scaffolded with, works only on a hub the backend built. On a hub the CLI built, or one built by hand, it answers 422 (exit 2). Pass the playlists instead:
Take the ids from the scaffold’s -o json result (.playlists[].playlist_id), or list the hub’s own playlists with mio media playlists list --limit 100 -o json --jq '.[] | select(.hub_id == "hub_abc123") | {id, title}'. content reconcile is safe to re-run: content items that already exist are adopted, not duplicated.
Content items on the client-side path, and the server-side build for starter, arrive in mio v0.24.0 (the release after v0.23.0). On v0.23.0 and earlier, a template that declares space icons, playlist documents or a playlist dataSource key (starter declares all three) is always built client-side, and the client-side build creates no content items: run mio content reconcile with --playlist-id for each of the hub’s playlists.
The rest of this guide is the same pipeline, one command at a time. Follow it to customize a scaffolded hub, to build one that diverges from every template, or to understand what the scaffold does under the hood.

1. Create the hub

--name maps to the hub title; --slug is the unique lookup key public routes use. A new hub is private and unpublished — not reachable by members yet. The CLI says so and tells you how to publish (this hint prints to stderr, so JSON stdout stays clean):
The public hub URL is not returned by the API — combine the slug with the hub host yourself. In production, the standard address is https://hub.member.dev/{slug}. For other environments, use that environment’s hub origin. The admin builds its Open hub link from its configured hub origin and the slug.

2. Branding, favicon, registration, and menu

Branding, settings, and meta are JSONB blobs. The --branding-json / --settings-json / --meta-json flags merge (read-modify-write, so a partial edit never clobbers sibling keys). The API stores branding and meta keys as sent, so the CLI validates those keys itself: an unknown key warns on stderr and is still sent, and --strict-keys upgrades that to a hard error with no request fired. The API validates settings keys. An unknown top-level key, or an unknown sub-key of policies, registration, email or auth, is rejected with a 422 naming the key (exit 2), and nothing is written. The API also checks the value of language (en or de) and the shapes of playlistHero, discussionsBanner and buttonColors. It stores the sub-keys of every other section, such as appearance or header, as sent. From v0.24.0 (the release after v0.23.0) the CLI leaves settings keys to the API, with or without --strict-keys. It still warns about an unknown sub-key of settings.achievements, because the API stores those as sent and reads only enabled. On v0.23.0 and earlier the CLI checks settings keys against an older list of its own: it warns that an unknown key “is stored verbatim” and the API then rejects it anyway, and --strict-keys blocks, with no request, keys the API accepts, such as language, moderation, playlistHero and email.from_localpart.
  • --favicon-url sets branding.favicon_url; --logo-url sets branding.logo_url. Both are read-modify-write, so sibling branding keys are preserved.
  • --registration-enabled sets settings.registration.enabled. It is tri-state: pass --registration-enabled to enable, --registration-enabled=false to disable explicitly, or omit it to leave it untouched. mio hubs retrieve surfaces a derived registration_enabled field so you can confirm it.
  • On hubs update, --navigation-json replaces the whole navigation blob. For item-by-item edits use mio hubs navigation list|add|remove|reorder.

What each branding key paints

Most of the branding blob is freeform, but the keys the hub renderer reads are fixed. Getting secondary wrong is the single most common branding mistake.
secondary is not a decorative accent. It is the page foreground for a visitor rendering light — so it paints every heading and paragraph, and tints every tint surface — and the page background for a visitor rendering dark. Set it light and your text goes near-invisible on a white page. See who controls light and dark mode for why it has to survive both.
*_url branding keys are the exception to “stored verbatim”. The rest of the blob has no server schema, but every branding key whose name ends in _url — logo_url, favicon_url, social_image_url, custom_font_url, and any future one — is validated on write: it must be an absolute https:// URL with a real hostname, and must carry no embedded credentials, no percent-encoding in the host, and no control characters. A data: URI, a javascript: URI, a bare http:// URL, or a relative path is rejected with a 422, naming the offending key. So a data: SVG logo fails — upload the asset and use a durable URL instead.

Who controls light and dark mode

A hub cannot force light or dark mode. The rendered mode resolves as: if the hub’s mode is custom, custom wins; otherwise the visitor’s preference decides — their stored theme choice, defaulting to system, which follows the OS prefers-color-scheme. So a hub configured as dark still renders light for every visitor on a light-mode laptop, and no amount of re-writing the setting changes that. If you set it and see no change, the write succeeded; it was never the lever.
The hub’s own mode lives at settings.background.type (light, dark, or custom; default light), and it is consulted for exactly one purpose: custom is the only value that overrides the visitor, and the only mode in which branding.text and branding.background are read at all.
background is a settings key the API accepts, so this passes without a warning on every CLI version. branding.dark_mode does not choose the mode, but it is not inert either: it selects the defaults for text and background — exactly the two keys a custom hub relies on. With dark_mode: true and neither key set, background defaults to secondary and text to white; with it false or absent, background defaults to white and text to secondary. So on a custom hub, {"secondary":"#0B1F1C","dark_mode":true} renders a dark page with white text, and dropping dark_mode flips the same hub to a white page with dark text. Set it in step with the mode you chose — the renderer logs a warning when the two disagree.
This is what makes secondary so easy to get wrong. On any non-custom hub the same secondary has to work in both modes: in light mode it is the page foreground (headings and body copy on a white page), and in dark mode it is the page background (with white text on top). A mid-tone that looks fine in one is unreadable in the other, and you do not control which one a given visitor gets. Pick a dark, saturated value — it reads as text on white and as a background under white — and check the hub in both modes before shipping.
  • Menu items must be typed. The hub frontend’s parser silently drops any header/footer item that lacks a non-empty type (url, page, playlist, or discussions), so a menu shipped without types renders empty. The CLI rejects untyped items up front. header/footer must each be an array of objects; the mobile bucket uses a different {id,label,route,icon} shape.
  • Every menu href must be a root-relative path, and hub-scoped. The API validates this on write: an href must start with exactly one /, carry no URL scheme, no protocol-relative // prefix, no backslashes, no whitespace, and no control characters — the raw value is checked for whitespace and control characters, and its percent-decoded form for control characters only, so an encoded space (/content/My%20Playlist) is deliberately fine. Maximum length is 2048. An absolute https://… menu href is rejected with a 422 — the CLI’s own check still lets it through, so this is one of the few places the CLI is more permissive than the API. Keep the path inside your hub (/member-academy/about), or it escapes the hub and 404s.
  • A type:"url" item needs label, href, and position. Omitting position is a 422, not a default. label is 1–120 characters, and each bucket holds at most 50 items.
Legal links live under /legal/. A footer item pointing at /{slug}/privacy or /{slug}/terms 404s outright — privacy and terms are not routes and are not reserved words, so they fall through to the generic hub-page route, find no page with that slug, and render the 404 page. The real routes are /{slug}/legal/privacy-policy and /{slug}/legal/terms-of-service. Fill their content with mio hubs policies update.
The API checks that an icon is a string of at most 100 characters, but not that the name exists — so a valid-looking wrong name is a silent drop rather than a 422.
  • header accepts any id in the hub frontend’s icon sprite (~205 glyphs), and is the only bucket that actually renders one. icon is optional here: an unknown name drops the icon but keeps the menu item. Verified-present picks: home, content, chat, users, search, star, link, earth, calendar, bell, play, video, settings, email, folder, heart, lock, tag, podcast, download. info and globe are not in the sprite — they render blank with no error. Use information / information-circle and earth / global instead.
  • footer accepts and stores icon, but the footer never renders it — only the header does. A footer icon is not an error, just inert.
  • mobile tabs accept an eight-value whitelist only, and icon is mandatory — an item with a missing or off-list icon is dropped entirely. These are frontend component names, not sprite ids, and the casing matters: Home, Bell, User, Users, MessageSquare, MessageCircle, Search, and content (that last one is deliberately lowercase). Copying a header value such as home loses the tab. Two more mobile-only rules: fewer than three valid tabs and the whole list is replaced by the frontend defaults; more than five and it is truncated to five.
To delete a key (the -json flags are merge-only and cannot remove one — a null persists as literal null), use --unset with a dotted path whose first segment picks the blob:
--unset is repeatable/comma-separated and is applied after the -json merges and scalar flags.

3. Discussion spaces

--access-level is public or restricted; --posting-permission is any_member, admins_only, or segment (with --segment-id).

4. Playlists → items → publish to the hub

Build a playlist, curate its items, give it a cover, then publish it to the hub. Publishing writes the hub_media row that surfaces the playlist on the hub’s /content browse grid and homepage content-grid.
Pass --visibility public so anonymous visitors can see the card. --published-at is optional: when you omit it the CLI now defaults it to now, so the card publishes immediately. Pass an explicit past/future RFC3339 timestamp only to backdate or schedule. (A null published_at is treated as a silent draft — the CLI no longer sends null, so an unset flag can’t hide the card.)
playlists items also supports list, remove <item_id>, and reorder <item_id> --position N. Note that remove and reorder take the item id (the id from items list), not the file id.

5. Images inside page trees — use durable URLs

Page-tree image nodes must reference a URL that does not expire. The variants map returned by an upload is imgproxy-signed and expires in ~24–48h — inline one of those and the image silently 404s a day later. Use media files durable-url instead:
  • The durable URL is the file’s durable_variants entry joined with the required ?hub_id= param (the command adds it) so it resolves for --hub.
  • The URL 404s until the file is published public to that hub. --publish does that inline (visibility public, published now); otherwise run mio media hub-media publish --hub hub_abc123 --file-id file_hero --visibility public first.
  • --preset emits one variant (e.g. thumbnail-160, medium-720, large-1440, webp-medium); omit it to print every preset. Durable URLs are image-only — a non-image file has no durable variants.

6. Build the homepage

Create the page, scaffold a node-tree from the page-builder catalog, fill in real values, then set the draft and publish it.
home is a reserved page slug. --slug home is rejected with a 422 (“Slug ‘home’ is reserved and cannot be used for a page. Use the is_homepage flag instead.”) so that GET /pages/home always resolves to the is_homepage page. Use a real slug and mark the page with --is-home. Omitting --slug entirely fails with Field required.pages create --privacy defaults to members, so a page created without --privacy public is login-walled — you ship what looks like a public hub and every anonymous visitor hits the login screen. Valid values: public, members, private.
  • pages tree set --if-match is optional and defaults to 0. Omit it only for the first tree on a page that has never had a draft: pages tree get 404s until a draft exists, so there is no version to read back. 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, so you can never silently clobber an existing draft. For every later write, pass the draft_version from a prior pages tree get. A page created by mio hubs scaffold already has a draft at version 1, so your first write against it needs --if-match 1.
  • Scaffolding is offline-capable and prints an informational catalog: … line to stderr, so > tree.json captures only the JSON. A page-* template emits a complete {"root": …} tree ready for tree set; a section template emits a bare subtree to drop into a root’s children.

Scaffold the page, then fill it in

Page templates come in three kinds, and each is filled in differently: mio pages catalog templates lists the templates the backend you point at serves. To classify any of them, run the command below. It prints "outline", "complete" or "system", quotes included, because scaffold output is always JSON:
An outline is a skeleton. page-homepage’s hero child arrives as {"kind":"row","template":"hero","settings":{}} — no surface, no values. Scaffolding each section on its own gives you the real, design-conformed recipe (correct kind, settings.surface, column widths): the same hero scaffolded from the hero template is a container carrying {"maxWidth":"content","padding":0,"surface":{"padding":"section","background":{"type":"tint"}}}. So scaffold the page for the skeleton, scaffold each section separately, and splice:
row is the unified 1–4 column section — pick the layout with --variant (1col, 2eq, 2left, 2right, 3eq, 4eq, bound-cards, cta-band, faq). cta-band, faq, and bound-cards arrive already filled with placeholder values, which is the fastest way to see the node contract in practice. Discover the rest with mio pages catalog templates and mio pages catalog section-types --writable-only; there is no pages catalog list.

A sales page

page-sales is a complete page, so scaffold it and edit its values in place:
--type sales makes the hub render the page without its header and mobile navigation, because a sales page owns its full-bleed layout; the footer and its menu still render. Every other page type keeps the hub’s chrome, and a page created without --type is generic. Pass --privacy public here too: a page of any type other than login, register or payments is created members-only without it.
pages tree get and pages tree set do not use the same shape. tree get returns the resolved author draft with tree set to the bare root node — already unwrapped from {"root": …} — alongside draft_version. tree set requires the wrapper. Re-wrap on the way back in:
Feeding a tree get response straight back into tree set fails: a tree with no top-level "root" key is a 422 invalid_tree. pages catalog scaffold already emits the set shape, which is why the scaffold → set pipe needs no transform. Note that tree get returns the resolved draft (actions mapped, dataSource expanded), so it is a read-back, not a byte-exact copy of what you wrote.
See the page-builder catalog for the full list of templates and section types, and Pages and the page builder for the node shape and the draft/publish contract.

7. Publish the hub

The hub is now reachable by members at your hub-frontend host + slug. --registration-enabled is not redundant here. Because this recipe created the hub private in step 1, the self-registration default was never injected, and publishing does not add it — so without that flag the hub goes live with no way for members to sign themselves up. See what publishing does not turn on.

Render-contract gotchas

The API validates structure, not renderability. Every mistake below returns 200 and then goes wrong at render time with no error anywhere — usually by silently dropping the node, section, or card, sometimes by quietly rendering something other than what you asked for. Verify the outcome, don’t trust the write:
  • A node’s content goes in a top-level value, not settings.value. This is the single biggest silent-drop trap — see the node envelope.
  • weight must be numeric. A node weight is a number like 700, never a CSS keyword like "bold" — an unrecognized weight is discarded and the node renders at whatever weight it would have had with no weight set at all. (pages tree set catches any non-numeric weight client-side, before any HTTP.)
  • A section must carry its template. The catalog scaffold sets template on every section node ("hero", "carousel", "row", …). If you hand-build a tree, a section without its template will not render. A blank or non-string template is rejected client-side.
  • Button nodes need the correct action shape. A malformed or missing action on a button node leaves a button that renders but navigates nowhere.
  • An off-enum surface.background.type renders a transparent band with no error at all — see surfaces and backgrounds.
  • section_count is your “did it apply” signal. mio pages publish returns a page_publishes resource with section_count and gate_count. If section_count is lower than the number of sections you authored, the renderer rejected some — inspect the tree, don’t infer success from the 200.
  • Homepage content-grids need STATIC cards, not a data-source binding. The homepage route prefetches only type:"playlist" sources and runs hub_playlists with enabled:false, so a content-grid bound to dataSource:{type:"hub_playlists"} renders empty on the homepage. (hub_playlists feeds the /content browse page, not the homepage.)

What publishing does not turn on

Registration and Moderation defaults are injected only when the hub is created, never on update — so which flow you use decides what you end up with:
Creating a private hub and publishing it later leaves self-registration off. The registration default is injected on create and only for a hub created public, and hubs update never injects it — so the step-by-step recipe above, which creates a private hub and publishes it in step 7, ends with no settings.registration.enabled at all. Members cannot sign themselves up. Set it explicitly:
Moderation is injected on every create regardless of privacy, so you rarely need to set it — but it is equally untouched by update, so a hub that predates that default needs --meta-json '{"moderation":{"enabled":true}}'.

Raw API reference

Direct integrators can manage hubs without the CLI. The create, update and delete requests below are the ones the admin sends for the same actions. $TOKEN is a platform access token (see Authentication) or a team API key. Creating, changing and deleting hubs is limited to the team owner; anyone else gets 403. The generated endpoint pages have the full request and response schemas: create, list, get, update and delete.

Create a hub with the API

The body is a JSON:API envelope, {"data": {"type": "hubs", "attributes": {…}}}. The envelope, the resource object and the attributes all reject keys the schema does not list (additionalProperties: false), so a misspelled attribute, a name instead of title, or an id in the body returns 422 rather than being ignored. The CLI --name flag maps to the title attribute. The collection path ends with a slash.
The other optional attributes (branding, navigation, settings, meta, discussions_default_title and discussions_default_description) are covered by the recipe above and listed on the generated endpoint page. A successful create returns 201 with the new hub. Read data.id for later requests, and read the slug back from data.attributes.slug instead of assuming the one you sent: if that slug is taken, the hub is created with a random six-character suffix (for example member-academy-a1b2c3). A 503 with the code email_pool_unavailable means hub creation is temporarily unavailable; try again later. See Error handling for the error format. To create the whole starter hub through the API, use POST /api/v1/teams/{team_id}/hubs/from-template. This route is also controlled by a per-environment flag and returns 405 when it is off. It requires an Idempotency-Key header and the current catalog digest; omitting description is supported. Repeating the same key and request returns 201 with the same hub id and summary, replayed: true, and an empty created_resource_ids map. Reusing the key with a different request returns 409.
Read meta.digest from GET /api/v1/page-builder/catalog for CATALOG_DIGEST. Set publish to true to create a public hub. See the generated scaffold endpoint reference for the full contract.

Update a hub with the API

Send only the attributes you want to change. Name the hub in the path by its id or its current slug. The body has no id, and adding one returns 422.
A slug that is already taken is not suffixed on update. The request fails with 409 and the code hub_slug_conflict, which the admin shows as That slug is already taken. GET and PATCH responses carry an ETag header. Send it back as If-Match on your next update, and if someone else changed the hub in between, the API answers 409 with the code stale_hub instead of overwriting their change. Setting is_private to false does not turn on self-registration; see what publishing does not turn on.

Delete a hub with the API

A successful delete returns 204 with no body. It is a soft delete that cascades to the hub’s pages, their sections and its content items, and it ends every membership in the hub. There is no restore endpoint. A hub with a custom domain returns 409 with the code hub_has_custom_domain until you remove the domain.

Look a hub up by slug

Read a hub by its slug on the public route:
Set the hub’s terms and privacy policy text, or use mio hubs policies update: