The theme contract
A theme is one JSON document — the bundle — and nothing else. Everything the customizer edits, the public renderer paints, and a publish freezes is derived from that document. Get this shape right first: the catalog, the pages table, the canvas, and the seam are all downstream of it, and a bundle that cannot be parsed is a site that silently stops rendering.
The mental model is Shopify Online Store 2.0, ported to a headless React renderer:
| Shopify OS 2.0 | Here |
|---|---|
| Theme (live + unpublished library) | theme rows; exactly one published per workspace |
settings_data.json | ThemeBundle.settings — design tokens, SEO, brand |
| JSON templates | ThemeBundle.templates.{index,page,…} |
Sections + {% schema %} | SectionManifest in code + SectionInstance in JSON |
| Blocks | BlockInstance nested in a section |
| Header/footer section groups | ThemeBundle.sectionGroups.{header,footer} |
| Theme editor | a Customizer over a live preview iframe |
The one place the model breaks from Shopify is pages. A page is Content,
not Theme — it lives in its own table, shared by every theme. See
cms-pages-and-templates. Do not put pages back in the bundle to make the
metaphor tidier.
Every setting value is a primitive
type SettingValue = string | number | boolean | null;
Complex values (a menu tree, a link list) are JSON-encoded into a string and parsed by the field's reader. Persistence, validation, and DTO mapping never have to widen. This is why a bundle can live for a year without a migration. Do not add an object arm — the moment settings hold nested objects, every writer and every reader grows a shape they will disagree on.
Normalize on every read — never migrate
A bundle lives in three places at once: the theme's working draft, every published version, and every rollback snapshot. You cannot migrate all three, and a bundle that fails to parse is a site that 500s.
export function normalizeBundle(raw: unknown): ThemeBundle {
// coerce legacy shapes forward, forever
}
Run it on every read, not as a one-time migration. Detection is on the VALUE (does this look like the old shape?), so it is idempotent. Legacy shapes coerce forward until the next save persists them. Widening a field is free; renaming one costs a coerce forever — which is cheaper than rewriting append-only history.
The contract package takes no dependencies
Put the types, the reconciler, the CSS compiler, and the preview-message
union in a package that imports nothing — no React, no node:, no IO,
no runtime deps. Both the editor and the public renderer import it. A
dependency added there becomes a dependency of the public site, which is
the surface you most want small and cacheable.
Enforce the empty dependencies block with a test. The same test is what
lets you claim the editor and the renderer share every resolver by
construction rather than by discipline.
Section type is globally unique
One flat component registry, keyed by type. Rendering needs no brand or
tenant context. Catalog independence is an owner tag on the manifest (and
a filter at every enumeration surface), not a second type namespace.
A customer-imported section is namespaced custom:<id> so it can never
shadow a first-party type — for them or for anyone else on the deployment.
See cms-imported-sections.
The reconciler is declarative
Bundle surgery (add section, swap anchor, ensure chrome) returns the
new bundle plus warnings[]. A programmatic caller self-corrects instead of
failing. Fixed sections that a page cannot do without are inserted when
missing; extras in an anchorGroup are dropped, keeping the first.
Never throw from a reconcile because a draft is half-built. The customizer saves drafts. A schema that only accepts finished bundles makes a freshly dropped section unsaveable, which makes the editor unusable.
What does not live in the bundle
- Pages — workspace-scoped rows. The theme supplies only the layout each page renders through.
- Media bytes — settings store resolved URLs. Deleting a library row must not break a theme that already used the file.
- Membership, domains, billing — the workspace, not the theme.
If you are about to add a new top-level key to the bundle, ask whether a rollback should restore last week's value. If no, it is Content, and it belongs beside the bundle, not in it.