Skip to content

Templates

Both Pro flows let you shape the review page with your own markup: elicit_approval’s displayTemplate, and elicit_selection’s rowTemplate / headingTemplate / footerTemplate / hoverTemplate. All of them are LiquidJS templates rendered by the same engine, with the same rules. This page is that shared contract; each flow’s page covers the specifics of what it renders and where.

Templates are LiquidJS (the JavaScript implementation of Shopify’s Liquid). You get the usual Liquid toolkit — {{ output }}, {% if %} / {% unless %} / {% for %}, filters like {{ x | default: "…" }} and {{ x | upcase }} — evaluated against a context the flow provides: {{ context.* }} for approval payloads, {{ option.* }} for selection rows.

We run LiquidJS with its jsTruthy option enabled, so {% if %}, {% unless %}, and and/or follow JavaScript truthiness:

Value Truthy?
false, null, undefined falsy
0 falsy
"" (empty string) falsy
[] (empty array), {} (empty object) truthy
any non-empty string, non-zero number, populated array/object truthy

So {% if option.count %} is what a JavaScript developer expects — 0 skips the block, an empty string skips the block, a missing field skips the block:

{% if option.logo %}<img src="{{ option.logo }}">{% endif %}
{% if option.discount %}Save {{ option.discount }}%{% endif %} {# 0 → nothing #}

This matches JavaScript and Nunjucks. It deliberately differs from Shopify’s default Liquid (where 0 and "" are truthy — a common source of surprise). One thing to know: an empty array is truthy here (as in JavaScript and Nunjucks — but unlike Jinja2/Python, where empty collections are falsy). To branch on whether a list has items, check its size explicitly rather than the array itself:

{% if option.tags.size > 0 %}...{% endif %} {# not: {% if option.tags %} #}

The default filter follows the same truthiness, so a value of 0 (as well as null, "", or a missing field) triggers the fallback:

{{ option.label | default: option.id }} {# empty/missing label → falls back to id #}

Interpolated data ({{ context.… }}, {{ option.… }}) is HTML-escaped, so payload or option content can’t break the layout or inject markup — your template’s own tags render as-is, but the values dropped into them are inert text. Templates are rendered server-side and their output is displayed inside a sandboxed, null-origin iframe. The exact sandbox differs by flow — the selection table runs with no scripting (sandbox="allow-forms") so its native radio/checkbox controls can’t be spoofed, while an approval display template is display-only (sandbox="allow-scripts", no same-origin access) — see Selection Flow and elicit_approval for the details.

  • Size: each template is capped at 20 KB.
  • Cost: rendering runs under parse-length, render-time, and memory caps; there is no access to the network, the filesystem, or anything outside the context you pass.
  • Own-property only: templates read your data’s own properties, not inherited or prototype fields.
  • Fallback: if a template fails to parse or render, the page falls back to a safe default (the pretty-printed JSON for approvals, a plain id + label table for selections) — a bad template never blocks a request.