Skip to content

Render internals — how a page is interpreted

Chapter 11. Part IV opens the black boxes. We begin where the system spends most of its effort: the render, traced line by line — template, sections, blocks, and SEO.

The deep mechanics behind the render cycle: what actually happens to a template between the edge proxying a miss and the origin returning HTML. Entry point: renderStorefront (apps/origin/src/handlers/storefront.ts). This is the theme-bundle (Liquid) path — the primary one; the legacy widget page-builder is the degrade fallback.

1. Route → template

matchRoute(path) (packages/builder-core/src/page-builder/router.ts) walks a fixed ROUTES table, most-specific first, and returns RouteMatch { templateKey, pageType, params } — e.g. /products/redpageType: 'product', params: { handle: 'red' }. bundlePageName (handlers/helpers.ts) then maps pageType to a template basename (product, collection, index, …), expanded to templates/<page>.json.

A template is JSON (not Liquid) — an ordered list of section instances plus an optional named data-source map:

json
{
  "dataSources": { "main": { "type": "PRODUCT", "params": { "handle": "{{params.handle}}" } } },
  "sections": [{ "type": "main-product", "dataSourceKey": "main" }]
}
ts
PageTemplate    { sections: SectionInstance[]; dataSources?: Record<string, DataSource> }
SectionInstance { type; id?; data?; dataSourceKey?; blocks?: BlockInstance[] }
BlockInstance   { id?; type; data? }

Not Shopify's template shape

Order is the plain array order of sections — there is no order: [] array pointing into a sections map. Authored settings live under data, not settings (the Shopify-shaped settings view is synthesized at render). Only templates/*.json exist on this path — no .liquid alternate templates.

2. Template → ordered section renders

renderThemePage (builder-core/src/theme/theme-render.ts) parses the JSON, resolves data sources first (§5), then loops template.sections in array order. For each, it looks up sections/<type>.liquid in the bundle. The presence of that file is the dispatch key — present → a theme section (untrusted); absent → a first-party platform section (from the registry).

3. Section rendering

Each section is rendered with a context assembled by a deliberate spread precedence:

ts
const data = {
  ...opts.sectionData, // lowest — chrome injects site_name, etc.
  ...bound, // the resolved dataSource value (§5)
  ...inst.data, // authored settings WIN over bound data
  section: { id: inst.id, settings: inst.data ?? {}, blocks }, // Shopify-shaped view
  asset_urls, // reserved, pinned last (unshadowable)
};

So a resolved data key can never silently overwrite an authored setting, and section / asset_urls can't be shadowed. Dispatch:

  • theme sectionrenderUntrusted(liquid, data) — merchant Liquid in a pooled worker-thread isolate.
  • platform sectionpbRegistry.get(type); unknown → fail-loud; an island-declaring record → an inert placeholder; else renderSection in-process (engine.render) for trusted first-party markup.

{% schema %} is stripped by regex before render (section-schema.ts stripSchema) — LiquidJS has no schema tag, so raw source would throw and take down the page. The same span is parsed by extractSectionSchema for validation and the editor's acceptsBlocks flag, not at serve time.

The isolate (packages/builder-render/src/isolate.ts + worker.mjs): a pooled warm worker (cap max(2, cpus-1)), a 2s wall-clock kill that starts only after a worker is in hand (render-only budget) — on timeout the worker is terminated and evicted. Inside, a sandboxed LiquidJS engine (strictFilters: true, no fs, cooperative limits: 100ms render, 8MB memory, 64KB parse) with a filter allowlist — every non-allowlisted built-in is deleted. Custom filters: money (paise → ), asset_url, safe_url, sanitize_html. worker.mjs re-registers these by hand (the second engine copy, guarded by a parity test).

4. Blocks

Template block instances become the Liquid-visible section.blocks:

ts
blocks = (inst.blocks ?? [])
  .filter((b) => b && typeof b === 'object' && !Array.isArray(b)) // drop junk — never 500
  .map((b) => ({ type: b.type, id: b.id, settings: b.data ?? {}, editor_attributes: '' }));

Note the datasettings rename: the stored key is data, exposed to Liquid as block.settings.*. A section iterates them in array order and switches on type:

liquid
{% for block in section.blocks %}
  {% case block.type %}
    {% when 'image' %} <img src="{{ block.settings.src | asset_url }}">
    {% when 'button' %} <a href="{{ block.settings.href | safe_url }}">{{ block.settings.label | escape }}</a>
  {% endcase %}
{% endfor %}

There is no block_orderblocks is an ordered array carrying its own id. Validation is save-time (validateBlocks: block is an object, type ∈ the schema's accepted set, max_blocks cap, per-type limit) → a 400 on save. At render time blocks are not re-validated; the renderer just copes (the filter drops junk) so a hand-edited template never 500s a live page.

5. Data binding

A template's dataSources are resolved before the section loop:

ts
// interpolate {{params.x}} from the route, fan-out fetch, collect cache tags
values = await Promise.all(
  entries.map(([, src]) =>
    resolver.fetch({ ...src, params: interpolateParams(src.params, routeParams) }, ctx)
  )
);
// resolved[key] = value;  tags.push(...value.tags)

interpolateParams replaces {{params.handle}} with the route's handle. resolver is the injected BindingResolver (real ShopkitResolver over GoKwik, or the stub). Each fetch returns { value, tags }; value is a plain object ({ product }, { products: [...] }). A section pulls its source by dataSourceKey:

ts
const bound = inst.dataSourceKey ? (resolved[inst.dataSourceKey] ?? {}) : {};
// ...spread flat into the section context, so the Liquid can do {% for product in products %}

Flat merge, no object namespacing (yet)

The theme path flat-merges the resolved value into the section context — { products } / { product } land at top level. It does not namespace into product.* / collection.* like Shopify (or like the legacy page-builder resolvePage). This is flagged in code as "the safe interim." Cache tags (prod:*, col:*, menu:<handle>) accumulate and become the page's surrogate keys.

6. Layout composition

The section body is rendered with applyLayout: false (sections-only HTML). The layout is a final steprenderThemeLayout renders layout/theme.liquid through the isolate, filling a fixed set of slots. The real contract:

liquid
<title>{{ page_title | default: site_name | default: 'Store' | escape }}</title>
{% if base_css_url != blank %}<link rel="stylesheet" href="{{ base_css_url }}">
{% else %}<style>{{ base_css }}</style>{% endif %}
<style>@layer tokens { {{ token_css }} }</style>
<style>@layer overrides { {{ theme_css }} }</style>
{{ content_for_header }}        {%- comment -%} end of <head> {%- endcomment -%}
</head><body><div class="rt-storefront">
{{ header }}
{{ content_for_layout }}        {%- comment -%} the composed sections {%- endcomment -%}
{{ footer }}
</div>
{{ content_for_body_end }}      {%- comment -%} before </body> {%- endcomment -%}

Who fills each slot (storefront.ts):

SlotProduced by
content_for_layoutthe composed sections (§2–5)
header / footerrenderChrome (§8) — separate slots, not inside the body
content_for_headerintegration head + SEO (§7) + PWA links + SW register + islands <script>
content_for_body_endcart drawer + integration bodyEnd
base_css / token_css / theme_cssstitched CSS, inlined (§8) — all </style-neutralized
page_title / site_nameSEO page title / store name (the layout MUST `

content_for_header is platform-owned

Unlike Shopify's giant auto-injected blob, content_for_header here is trusted platform HTML (islands runtime, integration head, SEO, PWA, SW registration). And the theme owns the whole document — a layout/theme.liquid that doesn't start with <!doctype/<html> is rejected with a 500 (layoutOwnsDocument), it isn't silently wrapped.

7. SEO attachment

buildSeoHead runs in parallel with the page compose and is injected as the first component of content_for_header — so SEO lands at the {{ content_for_header }} placeholder at the end of <head>. It branches on pageType:

  • productrenderSeoHead({ …, jsonLd: [ productSchema(...), breadcrumbSchema(...) ] }) + a pageTitle for <title>.
  • homejsonLd: [ websiteSchema, organizationSchema ].
  • else → site-level head (canonical + OG site name).

renderSeoHead (packages/seo) emits a string: an optional <meta robots noindex> for faceted URLs, <link rel=canonical>, OG/Twitter tags, and one <script type="application/ld+json"> per JSON-LD (every value escaped, < escaped inside JSON-LD so a field can't break out). SEO can never 500 the page — any error degrades to the plain site head. (Known cost: product SEO currently re-fetches the product the main-product section already resolved.)

8. Chrome, per-section CSS, islands

Chrome. renderChrome renders templates/header-group.json / footer-group.json exactly like a page (each resolves its own MENU data source), with a fallback chain (group template → bare sections/header.liquid → built-in) so chrome never disappears. It emits menu:<handle> cache tags, merged into the page's surrogate keys.

Islands. The account corner is wrapped in an inert placeholder — <div data-island="account" data-island-skip-unless-cookie="…">…</div>. hasIsland is true only if the rendered header actually contains that marker, so a theme that never prints {{ account_island }} ships zero JS. When an island is present, the islands runtime <script defer> is appended to content_for_header and the CSP widens from script-src 'none' to 'self'; otherwise the page keeps script-src 'none'.

Per-section CSS. assembleBaseCss stitches one stylesheet at serve time: the root sheet, then all assets/*.css, then every sections/<name>.css (each an @layer sections block). A merchant edits per-section files; the storefront gets a single sheet, inlined (<style>) or linked via /assets/<hash>. Cascade in <head>: base/section CSS → @layer tokens (brand overrides) → @layer overrides (merchant customize.css). The rt- prefix on section classes (rt-storefront, rt-mc-col, …) is an authoring convention in the bundle CSS/Liquid, not injected by the renderer.

How this differs from Shopify — the short list

  • LiquidJS, not Shopify Liquid — an allowlisted ~25-filter set, no {% render %} filesystem partials for merchant themes, {% schema %} stripped by regex, strictVariables: false (undefined → empty), prices in paise.
  • Merchant sections are hard-isolated in worker threads (2s wall-clock kill); first-party sections run in-process.
  • Ordered arrays, not order[] / block_order maps; block instances carry an inline id.
  • datasettings; binding is explicit dataSourceKey → dataSources, resolved by an injected resolver, and flat-merged (no product.* namespacing yet).
  • The theme owns the whole document; content_for_header is platform-trusted; header/footer are separate slots.
  • Per-section CSS is stitched into one sheet at serve time and layered tokensoverrides.