Skip to content

Rendering & themes

Chapter 5. In Chapter 4 a request reached the origin and "rendered." This chapter opens that word: how a theme is modelled, stored, and turned into HTML — and how merchants change it.

How a theme is modelled, stored, and turned into HTML — and how merchants edit it.

Two render paths — bundle vs page-builder

Ratio-3.0 has two storefront rendering systems, and it helps to know that up front:

  • The bundle theme path (primary). A merchant's live theme is a Liquid bundle (base ⊕ overrides, below). When a tenant has a live bundle theme (tenants.live_theme_id is set), the origin renders through it — this is the path the rest of this chapter describes, and the one all four shipped themes use.
  • The page-builder path (legacy fallback). An older JSON PageDoc system (the pages table's draft_doc / live_doc), rendered by composePage from the section registry. A store falls back to it only if it has no live bundle theme. It's kept for back-compat and is being phased out.

They share the same section registry, resolver, islands, and cache-tag model, so most of this book applies to both. Where they differ — chiefly how resolved data reaches a section (a flat merge in the bundle path vs per-binding namespacing in the page-builder path) — the resolver internals chapter lays them side by side. Unless a page says otherwise, "render" in this book means the bundle path.

A theme is base ⊕ overrides

An editable theme is an immutable base (a published library theme at a specific version) plus a small bundle of overrides — only the files the merchant changed, plus a _deletes manifest.

  • composeTheme(base, overrides) flattens the two into the effective theme; diffFromBase is the inverse (what the merchant actually changed). — packages/builder-core/src/theme/theme-compose.ts
  • Improving the base is a version bump rolled into stores via rebaseToBase; because the merge is file-level, a merchant's edits survive a base upgrade. — theme/base-propagation.ts
  • The shared base library is itself a tenant: _library.

How bundles are stored

  • A theme's text files are packed into one gzip blob, content-addressed by the SHA-256 of the canonical [path, content] pairs (an array, so keys like __proto__ round-trip safely). Binary assets are separate content-addressed objects. A 32 MB cap guards against zip bombs. — theme/bundle.ts, theme/assets.ts
  • Draft = a mutable source bundle. Publish freezes an immutable published/source + a minified published/compiled bundle and flips the live pointer in Postgres. — theme/compile.ts, theme/theme-store.ts
  • Postgres holds metadata (the file index theme_file, version records theme_bundle_version = the hashes, the live pointer on tenants); S3 holds the bytes, content-addressed, read by the origin through CloudFront. See data model.

The two Liquid engines (and why two)

Rendering is LiquidJS. There are two engines because an engine alone can't bound CPU on untrusted code:

In-process engineWorker-thread isolate
Filepackages/builder-render/src/engine.tsworker.mjs + isolate.ts
Runstrusted first-party section markupuntrusted merchant / theme Liquid
Safetycooperative limits, curated filters, edge-safe (no Node built-ins)hard wall-clock kill — a runaway worker is terminated and evicted (self-healing)
Whyspeed + edge-portabilityco-tenant isolation — one store can't starve another

The untrusted engine is a hand-written second copy kept in lock-step with engine.ts (a parity test guards against drift — e.g. the money, sanitize_html, and safe_url filters must behave identically). It's exposed as @ratio/builder-render/isolate and kept out of the edge-safe barrel (index.ts) on purpose.

Editing a filter? Edit both copies.

A Liquid filter change must land in both engine.ts and worker.mjs, plus a new case in the parity test — otherwise the two engines drift silently. This is exactly how the "prices 100× too high" bug shipped. See Render engine & isolate and the guardrails.

No autoescape

LiquidJS does not auto-escape its output tags. Merchant-controlled values must go through | escape (text), | asset_url (asset paths), or | safe_url (href/link — blocks javascript:/data: schemes), because the storefront CSP is script-src 'unsafe-inline' on some (GoKwik) stores. This is enforced by review + the filter allowlist, not by the engine.

The render cycle (inside the origin)

What happens between the edge proxying a cache miss and the origin returning 200 HTML + surrogate tagsapps/origin/src/handlers/storefront.ts. (For the line-by-line mechanics — how a template is parsed, sections and blocks attach, data binds, and the layout/SEO compose — see Render internals.)

A few invariants keep this safe, cacheable, and non-surprising:

  • Fail loud, never a partial page. A missing bundle, a broken layout, or an untrusted section that times out is a 500 — so the edge serves the last-good page, never a silently-cached broken one.
  • The shell is provably cacheable. Per-user content is not rendered into the page — it's an island placeholder (below). So the cached HTML holds only shared bytes.
  • A forgotten s-maxage is silently uncached. The cache-control: … s-maxage=300 … stamped here is what makes the page cacheable — drop it and every request hits the origin (no error, just cost).
  • Sections read data flat. A theme section reads top-level products / product, not Shopify's product.*; a Shopify-style product.title reference resolves to empty (undefined → '', no error). See resolver internals.

Trusted vs untrusted render

Trusted (first-party sections)Untrusted (merchant / theme Liquid)
Wherein-process (engine.ts)worker-thread isolate (isolate.ts)
Limitscooperative (render / mem / parse)cooperative + wall-clock kill
Filtersfull setcurated allowlist only
FailureRenderFailed → 500RenderTimeout / Failed → 500, host safe

Cacheability tiers — what the edge does with each

The registry (not the section author) assigns a tier; the shell's tier = the max of its non-island sections, so it's provably below per-user:

TierExampleIn the cached shell?Cache-Control
statictitle, rich textyeslong TTL
shared-volatileprice, stockyes (shared)long TTL + tag-purge
per-segmentlocale / currencyyes (per segment)keyed by segment
per-usercart, accountno — island onlynever in shell; island no-store

The island path (per-user, after the shell paints)

Islands are the only per-user path — the cached shell carries an inert placeholder, hydrated client-side:

Sections, tiers, and islands

  • Sections come in two flavours: theme sections (Liquid in the bundle, rendered by the isolate) and first-party / platform sections (markup in the registry, rendered in-process). — packages/builder-registry
  • Cacheability tier is decided by the registry's BINDING_CATALOG, not by the section author (static / shared-volatile / per-segment / per-user). Registration is the enforcement gate: a per-user section must be an island or it's rejected.
  • Islands are the only per-user path: the cached shell carries an inert placeholder, hydrated client-side from /api/island/* (always no-store). The header account corner is the one live island today.

Blocks (Shopify-style)

A theme section can declare a {% schema %} (settings + accepted block types + presets). The template carries block instances; the render loop exposes them to the Liquid as section.blocks ({ type, id, settings }), strips the {% schema %} before render, and validates blocks against the schema on save.

  • Block types + settings are edited in the admin (see below). Data-bound sections (product rows, category grids) carry a dataSourceKey and are not freely addable (they'd render empty without a data source).
  • Files: builder-core/src/theme/theme-render.ts (render), section-schema.ts (extract/strip/validate), the 4 shipped themes under builder-core/src/theme/library/{nova,forma,aura,atelier}.

Editing: two surfaces, one draft

Both admin editing surfaces write the same ThemeStore draft, so they stay in lock-step (apps/admin-web/src/features/theme/):

  • Code editor — a Monaco file tree over the theme bundle (theme-editor.tsx, code-editor.tsx), with live preview + version history.
  • Visual customizer — a Shopify-style surface (theme-customizer.tsx, customizer-frame.tsx): a live-preview iframe with in-canvas selection (click a section/block to edit it), add / remove / reorder sections and blocks, an image asset picker, device toggle, and Save / Publish. The selection bridge injects data-rt-* markers into the preview in editor mode only — they never reach a live storefront.

Validation — what's checked, and when

Two gates protect the live storefront: a light one on every save, a strict one on publish.

CheckSave (draft)Publish (owner)
Optimistic concurrency (revision / CAS)✓ 409 on conflict
Block instances vs {% schema %} (accepted types + settings)
Compile + minify
Full-document layout — layout/theme.liquid renders a complete document
Required templates present (header, footer, cart-drawer, account, order-confirmation, main-*)
Section references resolve
  • Save writes a mutable draft bundle — it may be imperfect; that's fine, it isn't live.
  • Publish is atomic: if any publish check fails it's a 422 and nothing changes — the old version stays live. Only a fully valid theme freezes new immutable bundles and flips the live pointer.

File protection

Default/structural files (the layout, the required templates) are non-deletable — a server-side hard gate rejects deleting or breaking them, so a theme can't be published into an unrenderable state. Only a few optional page templates are UI-removable.

Theme lifecycle & versions (multi-theme)

A store can hold many themes (create from a base, or duplicate an existing one); one is live at a time, the rest are drafts/experiments. A theme's state lives across three places — the mutable draft, immutable published versions (theme_bundle_version), and the tenant's single live pointer.

  • makeLive has three modes: true (always flip the pointer), false (cut a version, never flip), and if-already-live — the merchant-publish rule: flip only if this theme is already the live one (or none is). The decision is made under a row lock on the tenant, so concurrent publishes can't race the pointer.
  • Publishing is content-addressed — an identical compiled_hash reuses the existing version instead of churning a new one.
  • activate / rollback are the same primitivesetLive(theme, version?) repoints the live pointer to any published version (omit the version → latest). Every move enqueues a tag purge in the same transaction, so the edge drops the old cached pages.
  • Delete refuses the live theme (and the last one) — switch away first.

The storefront CSP (and why it's strict)

A storefront page ships a deliberately tight Content-Security-Policy. Several chapters touch a piece of it — here's the whole picture in one place:

  • Default: script-src 'none'. A cacheable storefront page carries no first-party JavaScript, so blocking all script is the injection backstop. Every exception below is opt-in.
  • Islands widen it to 'self'. If a page has an island (the account corner), the islands runtime is added and script-src widens to 'self' (same-origin only). A page with no island keeps 'none' and ships zero JS.
  • GoKwik stores loosen to 'unsafe-inline'. The GoKwik cart/checkout/login widgets need inline bootstrap scripts, so those stores' CSP includes script-src 'unsafe-inline', unioned in by the provider seam.
  • The PWA hash makes 'unsafe-inline' inert — and that's handled deliberately. The default-on service-worker registration is authorized by a hash in script-src; per CSP3, the moment a hash appears, 'unsafe-inline' is ignored. So the CSP builder drops the hash from any directive that also carries 'unsafe-inline' (keeping inline working for GoKwik) and keeps it where there's none. This is exactly why the KwikPass bootstrap is served as a same-origin asset, not inline (see Commerce internals).
  • Because inline can be allowed, output is not a safe XSS backstop — which is why merchant values must go through | escape, | safe_url, and | sanitize_html (the No autoescape warning above). The CSP and the filters are two independent guards.

Deeper reading

docs/page-builder-end-to-end.md walks the page-builder path in detail. Confluence: ADR-013 (page builder decision), the theme-ownership + base-propagation epics. See the ADR index.