Pages and page templates
A page is a row, not a node in the bundle. This is the one place a Shopify-OS-2.0-style CMS diverges from "everything is in the theme", and it diverges deliberately: it is Shopify's own split, where a Page is Content and a page template is Theme.
pages table ThemeBundle.templates.page
handle "pricing" default { order, sections }
title "Pricing" wide { order, sections }
visible true all-sections (sandbox, editor-only)
templateVariant "wide" ────────────┘
templateVariant is the template_suffix analogue. Resolve it against
whichever theme is live and fall back to default when that theme does
not ship the layout. That fallback is the entire point. A page is
workspace-wide and a theme is one design among several, so a page WILL meet
themes that never heard of its layout. Falling back means the URL keeps
working. Refusing to resolve lets a theme swap 404 a live page — the
failure the split exists to remove.
One function decides it (resolvePageVariant), shared by the editor and
the public renderer. A second copy is a theme swap that 404s a live URL.
Saving a page is publishing it
A theme version freezes the bundle. Nothing freezes a page row. The public site therefore serves a frozen design beside live page copy, and the two revalidate together — ping the renderer on every page write.
A rollback restores a design and leaves a month of copy edits alone. That asymmetry is the trade that buys pages their independence. Say so out loud in the UI ("saves immediately") so an operator is not surprised.
Consequences:
- Hiding a page is the only way to draft one. Once anything is
published, every visible page renders — through
defaultif the live theme has nothing better. - Sections belong to a LAYOUT that pages share. "Open in Customizer"
from a page screen edits something that may not be only this page's.
Count the others, including pages that merely fall back to the layout,
and say so on the CTA. A
defaultthat looks unused is not. defaultcan be edited, never renamed, never deleted. Every unassigned page renders there.- A dangling
templateVariantis kept, not scrubbed. Show it as "not in the live theme" and render throughdefaultmeanwhile. Nulling it on save forgets the operator's intent the moment the layout comes back. - Renaming a layout re-points page rows only in the LIVE theme. A draft's rename must not move rows the live theme also renders, or an unpublished edit changes the public site. Deleting a layout re-points nothing.
Metadata is a form; sections are the Customizer
Edit title, handle, description, SEO, visibility on a plain admin form. Edit sections only in the Customizer, on the layout. A second section editor on the page screen would need its own preview, its own undo, and its own reconciler calls, and the two would drift within a release.
- One writer for every field. The handle is the page's address and every other screen keys the page by it, so a separate "rename" path lets a description save land on a page the operator already moved.
- The description is a pair. Rich HTML is what the operator writes;
plain text is its projection. The renderer needs a meta description on
every request and the pages list needs a summary, and neither should
be parsing HTML to get one. The rich half wins when both arrive;
supplying HTML at all is an assertion (an empty one is a deliberate
clear); emptiness is judged by the projection, because an emptied
contentEditable is
<p><br></p>and storing that leaves the field non-empty forever. - Empty SEO fields mean "follow the page", not "no listing". Title falls back to the page title plus the theme's suffix; description falls through the page's own description before the theme default. The admin draws a preview by calling the same two functions the renderer calls, so the preview cannot drift from what ships.
handle_takenis a unique index, not a key check in a JSON object. Two operators creating/pricingat the same moment used to both succeed.newis a reserved handle. The static/pages/newsegment wins over[handle], so a page handlednewwould be live on the public site and unreachable in the admin. The rule lives in the contract, because there are two writers and a guard in one is a guard the other forgets.- "New page" navigates rather than creates. A page's handle is a URL. Nothing is written until the form submits. New theme / new form can mint an untitled row; a page cannot.
Visibility and schedules
Absent visible normalizes to visible. Every page written before a
gate existed was public; defaulting the other way takes a live site's
pages down on deploy. An unparseable schedule normalizes to "no
schedule" for the same reason — the failure that matters is the one
that hides a live page.
A schedule is evaluated per request, not at generation time. Convert
date + time inputs to an ISO instant in the browser; parsing
wall-clock values on the server reads them in the container's zone and
silently shifts the date. Nothing on the page screen may read
new Date() during render — that is a hydration mismatch.
The sandbox is a template, not a page
A per-theme scratch surface that was a page would show up in the
workspace's page list, visible in every theme and deletable from any of
them. Make it a reserved template variant (all-sections) whose only
address is a preview-gated route. Exclude it from every "in use" count
— it holds one instance of every section type, so leaving it in lies.
What a page history costs
Layout rolls back with the theme. Page copy has no versions, so an
overwritten title is gone. That is the known cost of taking pages out
of the bundle. The fix is a page-versions table, not putting them back.
See cms-theme-contract for why Content does not live in the bundle.