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.
The engine
Section titled “The engine”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.
Truthiness is JavaScript-style
Section titled “Truthiness is JavaScript-style”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 #}Values are escaped
Section titled “Values are escaped”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.
Limits and fallback
Section titled “Limits and fallback”- 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+labeltable for selections) — a bad template never blocks a request.
See also
Section titled “See also”- Approval Flow and
elicit_approval—displayTemplate. - Selection Flow and
elicit_selection— row/heading/footer/hover templates andcss.