Theme API Reference
Themes in ShopsWired are built using Liquid templates, HTML, CSS, and JS. This document will guide you through creating and customizing a theme.
Directory Structure
Themes are located in backend/templates/<theme_name>/.
The default theme is located at backend/templates/default/.
A typical theme structure looks like this:
backend/templates/default/
├── manifest.json
├── layout.liquid # Master layout wrapper
├── index.liquid # Homepage
├── product.liquid # Product detail page
├── search.liquid # Product catalog / search
├── cart.liquid # Shopping cart
├── checkout.liquid # Checkout flow
├── account.liquid # Customer dashboard
├── order-detail.liquid # Individual order details
├── snippets/ # Reusable components (e.g. product-card.liquid)
├── css/ # Stylesheets
└── js/ # Scripts
The manifest.json
The core of every theme is the manifest.json file. It defines the theme's identity, routing logic, and the settings available in the admin panel.
Marketplace note:
descriptionis shown on the marketplace card and detail modal (alongside the README and changelog). Theauthordisplayed in the marketplace is always the publishing shop's name, not theauthorfield above — the manifestauthoris informational only.
{
"id": "my_theme",
"name": "My Theme",
"version": "1.0.0",
"author": "Your Name",
"description": "A short summary shown on the marketplace card.",
"routes": {
"/product/{slug}/{id}": {
"template": "product.liquid",
"data": "product"
},
"/account/order/{id}": {
"template": "order-detail.liquid",
"data": "order-detail"
}
},
"settings": [
{
"tab": "General",
"key": "store_name",
"type": "text",
"default": "My Store",
"label": "Store Name",
"description": "Displayed in the header."
}
]
}
Settings
Settings defined in manifest.json are exposed to the admin UI and can be accessed within any Liquid template using {{ settings.key }}.
Supported field types include text, textarea, number, checkbox, select, image, richtext, editor (a robust code editor with syntax highlighting), color, tags, and layout. You can specify a language inside an options object for the editor field (e.g. javascript, html, css). The region pickers country, us_state, and ca_state render a dropdown over the platform's built-in lists and store the canonical ISO code (e.g. "US", "TX", "ON").
A field can also carry a condition so it only appears when another field in the same settings form has a given value — a single key == value test (e.g. "condition": "ship_from == 'CA'"). The value may be a quoted string, bare token, number, or true/false; only == is supported, and hiding is display-only (a hidden field keeps its value). This is the same conditional-fields mechanism documented for plugins — see Conditional fields (condition) in Plugins.md.
Fields can also carry optional tab and group strings to organize a long form: tab places the field on a named tab (default General), and group renders a sub-heading above a run of consecutively-listed fields sharing that label within a tab. Both are purely visual — values stay flat under each field's key. Same mechanism as plugins — see Settings layout (tab and group) in Plugins.md.
Layouts & Templates
The Layout (layout.liquid)
Most templates are wrapped inside a layout. The layout contains your <html>, <head>, and <body> tags, headers, and footers. The layout must include {{ content_for_layout }} where the specific page content should be injected.
Snippets
Snippets are reusable Liquid files located in the snippets/ directory. Include them using:
{% include 'product-card' %}
Template Inheritance (extends / block)
A theme can override a parent theme file-by-file (drop a same-named file in your theme and it wins). But replacing a whole file means copying every line you didn't want to change — and your copy goes stale when the parent theme updates. Template inheritance is the granular alternative: extend the parent file and override only the named regions you care about. Everything else keeps tracking the parent.
Declaring blocks (base/parent template)
In a base template, wrap each overridable region in {% block NAME %}…{% endblock %}. A block renders its body normally when nobody overrides it:
{# default theme's product.liquid #}
<section class="product">
<h1>{{ product.name }}</h1>
{% block product_price %}
<span class="price">{{ product.price | money }}</span>
{% endblock %}
{% block add_to_cart %}
<button type="submit">Add to cart</button>
{% endblock %}
</section>
Block names are bare identifiers ([A-Za-z0-9_-]+), closed with {% endblock %}. Blocks may nest.
Overriding blocks (child template)
A child template starts with {% extends … %} and redefines only the blocks it wants to change. Non-block content in the child is ignored — the parent drives the output.
{# your theme's product.liquid #}
{% extends parent %}
{% block add_to_cart %}
<button type="submit" class="my-cta">Add to bag</button>
<p class="shipping-note">Ships free over $50</p>
{% endblock %}
Here product_price still renders the parent's version; only add_to_cart changes. When the parent theme updates its price markup, you inherit that automatically.
The extends target is one of:
| Form | Meaning |
|---|---|
{% extends parent %} | The same path, resolved in your parent theme (and on up the chain). This is the usual form — "inherit my parent theme's version of this file." |
{% extends 'default/product.liquid' %} | An explicit themeid/path — that theme's file. |
{% extends 'snippets/base-card.liquid' %} | An in-theme path (first segment isn't a theme id) — a differently-named base file resolved through your theme chain. Handy for shared base templates within one theme. |
Inheritance chains any depth: if your parent theme's file also {% extends parent %}, that resolves relative to it, so child → parent → default all compose.
{{ block.super }} — keep the parent's content
Inside an overriding block, {{ block.super }} expands to the parent's body for that same block — so you can add to a block instead of replacing it:
{% extends parent %}
{% block add_to_cart %}
{{ block.super }} {# the parent's original button, unchanged #}
<p class="shipping-note">Ships free over $50</p>
{% endblock %}
Overriding a nested block
You can override a block nested inside another without redefining its ancestor — as long as you don't override the ancestor too:
{% extends parent %}
{# parent has {% block layout %}…{% block sidebar %}…{% endblock %}…{% endblock %} #}
{% block sidebar %}{{ block.super }}<my-widget/>{% endblock %}
If you override layout, you own its entire body (including whatever sidebar it contains) and a separate sidebar override is ignored.
How it relates to whole-file overrides and hooks
- Whole-file override still works — a theme file with no
{% extends %}replaces the parent file exactly as before. Inheritance is opt-in. - Blocks and
{% hook %}compose. A block body is ordinary Liquid, so it can contain{% hook 'name' %}for plugin injection. Theme authors typically expose hooks inside blocks; plugins can also wrap a block directly (see Plugins.md → "Block wrappers (block.<name>)").
Standard blocks in the default theme
The default theme wraps its most-commonly-customized regions in named blocks. Child themes override them ({% extends parent %} + {% block … %}), and plugins wrap them (block.<name>). Each block forwards the relevant record as a scope argument so a plugin wrapper reads it from ctx.data.bindings even inside a loop (e.g. one product card among many):
| Block | File | Scope arg | Wraps |
|---|---|---|---|
price | product.liquid and snippets/product-card.liquid | product (+ area='card' on the card) | The price display. Same block name in both contexts, so one block.price handler covers the PDP and product cards. The card also passes area='card' so a wrapper can size its markup to the context. |
add_to_cart | product.liquid | — | The Add to Cart / Out of Stock buttons (inside the product form). |
product_gallery | product.liquid | product | The PDP image gallery + thumbnails. |
product_title | product.liquid | product | The PDP <h1> title. |
card_media | snippets/product-card.liquid | product | The product-card image / placeholder. |
card_badges | snippets/product-card.liquid | product | The product-card stock/badge area. |
product_details | product.liquid | product | The PDP attributes/“Details” table. Wraps the whole conditional, so an override still runs when the product has no attributes (supply your own spec table). |
product_description | product.liquid | product | The PDP description block. Wraps the whole conditional, so an override runs even when product.desc is empty. |
cart_line_price | cart.liquid | item | A cart line item's price. |
order_totals | snippets/order-summary.liquid | order | The order subtotal/discount/shipping/tax/total card (storefront order-detail + guest lookup). Override to blank or restyle totals — the storefront analogue of the print packing_slip_prices block. |
cart_checkout | cart.liquid | — | The cart's Checkout button/link. Override to relabel it, add an express-checkout button alongside it, or gate it behind a condition. |
place_order | checkout.liquid | — | The Place Order submit button (#submit-btn). Override to relabel/restyle it or add markup beside it — but keep a type="submit" control with id="submit-btn", since the page script toggles its text/disabled state during submission. |
search_empty | search.liquid | — | The no-results state for search/browse. Override to replace it wholesale (custom illustration, recommended products, etc.). It contains the search_results_empty hook, so plugins can still append to the default markup without overriding. |
A block renders its default body when nothing overrides it, so adding these to a theme is non-breaking. The price block is intentionally placed so it still fires when product.price == 0 (the card's price <div> is only emitted for non-zero prices, but the {% block price %} wrapper around it always runs) — this lets a pricing plugin supply a price for products that have no catalog price. See Plugins.md → "Block wrappers" for the block.price pattern (the bundled bullion-pricing plugin uses it to render live spot prices server-side).
Email Template (email/template.liquid)
A single optional file, email/template.liquid, renders the shop's customer-facing (shop-level) transactional and notification emails. The platform passes an email_type binding and you branch on it; if the file is absent a built-in default is used. Always keep an {% else %} fallback — an unhandled email_type otherwise renders an empty body.
Platform / account emails are not themeable. Account, billing, and shop-lifecycle notices sent by ShopsWired itself — email verification, merchant password reset,
user_invite,shop_access_granted, and the shop-closure lifecycle (shop_close_confirm,shop_frozen,shop_deletion_reminder,shop_deletion_scheduled,shop_closing,shop_purged) — render from fixed built-in templates and bypass the theme entirely and theemail.*plugin hooks. A theme branch for one of these types is dead code; a plugin can't restyle or suppress them. (Note:password_resetis themeable for storefront customer resets — only the merchant-account reset is platform-fixed.)
{% if email_type == 'order_confirmation' %}
…
{% elsif email_type == 'shop_notification' %}
<h2>{{ notification.title }}</h2>
{% if notification.body != "" %}<p>{{ notification.body }}</p>{% endif %}
{% if notification.admin_url != "" %}<a href="{{ notification.admin_url }}">View in {{ shop.name }}</a>{% endif %}
{% else %}
{{ email_content }}
{% endif %}
Common email_type values: order_confirmation, shipping_notification, order_cancelled, order_refunded, order_partially_refunded, digital_download, welcome, magic_link, password_reset, wired_fulfillment_created, and shop_notification (the generic staff-notification email, used when a sw.notify / platform notification is delivered to a staff member's inbox). The shop_notification type binds a notification object: title, body, link (admin-relative path), admin_url (absolute admin link), severity (info/success/warning/error), category (the human-readable category label, e.g. the plugin's manifest-declared notify_categories label — not the raw plugin:id:key), and source (the declaring plugin's display name, or "" for core categories). The shop object is the same full projection used in storefront templates (shop.name, shop.canonical_url, shop.domains, shop.logo_url, etc. — see the storefront context vars table). Prefer shop.canonical_url (the shop's public storefront origin) for customer-facing links back to the store. For platform-level emails that aren't tied to a shop (invites, verification, password reset) shop is an empty map. A top-level app_url binding always holds the ShopsWired platform origin — use it for platform-level links (account, sign-in, billing) and in non-shop emails.
For reference, the two access-grant platform emails (fixed templates, not themeable — see the note above): user_invite — an emailed link for an invited address that has no account yet, binding user (name, email), shop_name, inviter (name, email), and accept_url (the set-password link, valid 7 days); and shop_access_granted — a notice to an existing account that was added to a shop, binding user, shop_name, inviter, and login_url.
Built-in Liquid Filters
ShopsWired's Liquid Engine comes with several custom filters tailored for e-commerce:
{{ price | money }}: Formats a price in cents to dollars (e.g.,1999becomes$19.99).{{ product | product_url }}: Generates the correct URL for a product based on the theme's configured route. If the product has a custom slug (product.slug), this returns the clean, id-less canonical URL for it (e.g./product/summer-sale); otherwise it falls back to the default name + id URL. Always use this filter for product links so the canonical URL is correct.{{ product | in_stock }}: Returns true if the product or its variants have stock > 0.{{ 'path/to/asset.png' | asset_url }}: Prepends the static asset directory URL.{{ 'path/to/image.jpg' | cdn_url }}: Resolves the URL against the configured global CDN. Paths under/theme-assets/and/plugin-assets/are auto-versioned (e.g.?v=...) for cache busting; other paths (/public/, user uploads) are not. Pass an explicittrue/falseto override (e.g.| cdn_url: true).{{ text | truncate_words: 20 }}: Truncates text to the specified word count.{{ value | json }}: Serializes a value (object, array, string, number) to a JSON string. HTML-sensitive characters (<,>,&) are unicode-escaped, so the output is safe to drop straight into a<script>block or an HTML attribute — e.g.<script>var data = {{ product.attrs | json }};</script>. Note: the storefront engine does not auto-escape{{ }}, so for plain text/attribute UGC use| escape;| jsonis specifically for emitting structured data into JS.{% if value | present %}: Single, reliable "is this meaningfully set?" check for conditions. Returns false fornil/undefined,false,"",0(numeric), and empty arrays/objects; true for everything else — including the string"0", non-zero numbers, non-empty strings, and non-empty collections. Prefer this over!= blankor!= "". This engine'sblankonly matchesnil(not empty strings), and an empty string is otherwise truthy, so{% if x != blank %}wrongly renders for an empty-string field and a bare{% if x %}is true for"".presentis the one check that gets all cases right:{% if shop.theme.logo_url | present %}<link rel="icon" href="{{ shop.theme.logo_url | cdn_url }}">{% endif %}(Filters work directly in a condition, but only as the whole condition —
{% if x | present %}, not combined with a comparison like{% if x | present == true %}.){{ layout_data | layout_render }}: Renders a JSON layout configuration (rows → columns → blocks) into HTML sections. Used for merchant-customizable pages such as the homepage. See Thelayoutfield &layout_renderfor the full schema.{{ "search query" | products: 12 }}: Runs a product search and returns the matching products (the same list type as the collection/search pages), so you can build a product list anywhere. The piped value is the search query (empty string returns the default listing) and the argument is the limit (defaults to 12). Iterate the result and reuse the per-product filters:{% assign results = "blue shirt" | products: 8 %} <div class="sw-layout-product-grid"> {% for product in results %} {% include 'product-card' %} {% endfor %} </div>{{ value | liquid }}: Renders a string value as Liquid against the current page scope, so a value that itself contains{{ … }}/{% … %}is evaluated instead of printed verbatim. A plain{{ value }}dumps the string as-is — Liquid never re-parses the contents of a variable — so this is how you make a stored snippet (a custom-script theme setting, a CMS body, any admin-saved field) reference live page data. The default theme already pipes the four Overrides settings (head_start,head_end,body_start,body_end) through it, so a merchant can paste a page-aware snippet there with no theme edit. Non-string input (or a string with no{) passes through unchanged; a render error falls back to the raw string. Treat the input as trusted (admin-entered) — it runs with full Liquid capability.Typical use — a Google Ads conversion on the order-confirmation page. The default theme ships a dedicated Settings → Overrides → Order Success Scripts field that renders only on the thank-you page (
order.liquid) withorderin scope, so paste the snippet directly — no page guard:<script> gtag('event', 'conversion', { send_to: 'AW-XXXXXXXXXX/yyyyyyyyyyyyyyyy', value: {{ order.total | divided_by: 100.0 }}, currency: 'USD', transaction_id: '{{ order.number }}' }); </script>For a snippet that must run on a different page, use Body End (global) and guard it yourself with
{{ dataloader }}, the page identifier — e.g.{% if dataloader == 'checkout-success' %}…{% endif %}. (order.totalis in cents, so divide by 100. Thegtagfunction itself must already be loaded — e.g. via the Google Analytics plugin or your own loader in Head End.)
Image optimization (resizing & format)
Public images (product images, uploaded assets — anything served under /public/) are resized and optimized on the fly by the asset service. You control it with two query params on the image URL:
?s=<px>— resize so the longest side is at most<px>. The value snaps up to a fixed bucket (50, 100, 200, 400, 800, 1600), so request whatever you need and it rounds to the next bucket. Use it for thumbnails and responsive sizes:<img src="{{ product.image_url | cdn_url | append: '?s=400' }}" alt="{{ product.name | escape }}">Even with no
?s=, images are auto-optimized: those over 1 MB are downscaled to 1600px, and any non-WebP image over 50 KB is converted to WebP at its original dimensions (kept as-is only if WebP wouldn't be smaller). So a barecdn_urlalready returns an optimized image — but prefer an explicit?s=on prominent images (e.g. a product's main/LCP image) so you also ship appropriately-sized pixels, not just a smaller format. Use?fmt=rawto opt out entirely.?fmt=jpg/?fmt=png— force a specific output format (see below). Combine with?s=:...?s=400&fmt=jpg.?fmt=raw— opt out of all optimization and serve the stored bytes untouched, at full original quality and dimensions, whatever the type. For when you deliberately want the original (e.g. a downloadable high-res asset). Overrides?s=and the auto-resize.
Output is WebP by default. Any resized/converted image is encoded as WebP regardless of the source format (JPEG, PNG, GIF, BMP, or WebP) — smaller files, and transparency is preserved (no more PNG-for-alpha bloat). Opaque images (typically photos) use lossy WebP tuned for size; images with an alpha channel are encoded losslessly, so transparent logos/graphics keep crisp edges with no compression artifacts. The service also won't work against you: it never recompresses an image that's already WebP when no resize is needed, and never returns a file larger than the source. You don't need to do anything to opt in; just serve the image and it comes back optimized.
Old-browser fallback is automatic. The default theme's js/main.js transparently rewrites any failed /public/ image to ?fmt=jpg for the ~2% of browsers that can't render WebP (IE11, Safari < 14, Opera Mini). Because the fallback is a query param it shares the CDN cache cleanly. If you build a custom theme/layout, carry this logic over (or your own equivalent) so those browsers still see images. For guaranteed zero-flash fallback on a critical image (e.g. a hero/LCP image) you can also use a <picture> element with an explicit ?fmt=jpg source:
<picture>
<source srcset="{{ img | cdn_url | append: '?s=1600' }}" type="image/webp">
<img src="{{ img | cdn_url | append: '?s=1600&fmt=jpg' }}" alt="{{ alt | escape }}">
</picture>
The layout field & layout_render
A manifest setting of type: "layout" gives the merchant a visual page builder in the admin. It stores its value as JSON, which you render in a template with the layout_render filter:
{{ settings.homepage_layout | layout_render }}
The filter accepts the value as a JSON string or as an already-parsed object/array. Invalid JSON renders an HTML comment (<!-- layout render error: invalid json -->); a non-string/non-array/non-object value renders nothing.
Top-level shape
Two forms are accepted. Prefer the versioned form for new content:
{
"version": 1,
"layout": [ /* array of rows */ ]
}
The legacy form is a bare array of rows ([ { ...row }, ... ]) and is still rendered for backward compatibility.
Structure
The layout is a list of rows; each row has columns; each column has blocks:
layout (rows[])
└─ row { settings, columns[] }
└─ column { blocks[] }
└─ block { type, settings }
{
"version": 1,
"layout": [
{
"settings": {
"fullWidth": false,
"padding": "2rem 0",
"gap": "1.5rem",
"backgroundColor": "#f7f7f7",
"backgroundImage": "https://cdn.example.com/hero.jpg"
},
"columns": [
{
"blocks": [
{ "type": "heading", "settings": { "text": "Summer Sale", "level": "h1", "align": "center" } },
{ "type": "button", "settings": { "text": "Shop now", "url": "/search", "style": "primary", "align": "center" } }
]
}
]
}
]
}
Row settings
Wraps each row in <section class="sw-layout-row"> with an inner container.
| Key | Type | Effect |
|---|---|---|
fullWidth | bool | Inner container uses sw-layout-full (edge-to-edge) instead of sw-layout-container. |
padding | string | CSS padding on the section. |
gap | string | Sets the --sw-layout-gap CSS variable on the inner container. |
backgroundColor | string | CSS background-color. |
backgroundImage | string | CSS background-image (rendered cover / center). |
Each column renders as <div class="sw-layout-col">.
Block types
Every block is { "type": "<type>", "settings": { ... } }. Unknown types are skipped.
type | settings keys | Notes |
|---|---|---|
heading | text, level (default h2), align, color | Text is HTML-escaped. |
text | text, size (small/large, default medium), align, color | Renders <p> with sw-layout-text-{sm,md,lg}. Escaped. |
richtext | content | Raw HTML, not escaped — trusted merchant input only. |
html | content | Raw HTML, not escaped — trusted merchant input only. |
image | url, alt, link, width (default 100%) | Wrapped in an <a> when link is set. |
button | text, url, style (default primary), align (default left) | Renders <a class="sw-btn sw-btn-{style}">. |
hook | name | Renders the named template hook (lets plugins inject into a layout). |
products | title, filter (search query), columns (default 4), limit (default 4) | Runs a product search and renders each result via the product-card snippet inside sw-layout-product-grid. Honors customer/B2B pricing. |
richtextandhtmlblocks emit theircontentverbatim. All other text fields are HTML-escaped by the renderer.
layout_renderfrom a plugin (sw.liquid.render). When a plugin renders a layout fragment throughsw.liquid.renderin a route or widget handler (not the storefront render path), theproductsblock works — it runs a real, priced product search just like the storefront. Thehookblock, however, renders nothing in that context: template hooks only fire during the storefront render pipeline, which a standalonesw.liquid.rendercall doesn't set up. If you need plugin-injected content in a plugin-rendered fragment, emit it directly rather than relying on ahookblock.
Placeholder substitution
layout_render accepts keyword arguments that perform {key} → value string replacement on the raw JSON before parsing — handy for injecting dynamic values into otherwise-static layout JSON:
{{ settings.homepage_layout | layout_render: shop_name: shop.name, year: "2026" }}
Any {shop_name} or {year} token anywhere in the JSON is replaced with the supplied value.
Plugin developers: the schema above is a stable, public contract — anything that emits this JSON shape renders. The built-in
type: "layout"editor is intentionally minimal, so there's room to build a richer page builder (live preview, drag-and-drop rows/columns, custom block palettes, reusable section presets) as a plugin and have it write alayoutsetting (or any string field) thatlayout_renderconsumes unchanged. Two rules to stay compatible: (1) emit the versioned form ({ "version": 1, "layout": [...] }) so the renderer takes the supported path, and (2) only use the documented blocktypes and theirsettingskeys — unknown block types are silently skipped at render time. If your builder needs a new block kind, propose it as a first-classlayout_rendertype rather than inventing one the renderer won't understand.
Custom Product Slugs
By default a product URL is {prefix}/{name}/{id} (e.g. /product/blue-shirt/42), where the name part is cosmetic and the id resolves the product. Merchants can optionally give a product a custom slug in the admin (the Slug field on the product editor) for a clean, id-less URL:
- A product with a custom slug serves a canonical URL of
{prefix}/{slug}— e.g./product/summer-sale— resolved back to the product internally. The route prefix is the literal part of your theme's product route (/productfor/product/{slug}/{id}). - The previous URL forms (the default name/id URL and any older slugs) 301-redirect to the current canonical URL, so links never break. Old slugs are retained as redirects until the merchant prunes them.
- Products without a custom slug behave exactly as before — no redirects, default URL. The feature is fully opt-in.
- This applies to a shop's own products. Wired (cross-shop) products keep the
{prefix}/{name}/{sourceShopId}-{id}form.
In templates, just use {{ product | product_url }} for links — it emits the canonical (custom-slug or default) URL automatically. The slug value is also available as {{ product.slug }} (empty string when none is set).
Plugins and Hooks
Plugins can inject dynamic content into themes using hooks. You can specify a hook location inside your liquid templates using:
{% hook 'product_bottom' %}
This allows installed plugins to seamlessly add content, such as related products or reviews, without requiring manual theme edits.
Hook points available in the default theme include: head_start, head_end, body_start, main_start, main_end, body_end, header_nav_end (end of the header nav — add an icon/link such as a currency switcher or wishlist), footer_start, footer_content, product_after_price, product_after_add_to_cart, product_after_description (after the PDP description — reviews, related items), product_after_tags, product_after_form, and product_footer.
On the checkout page: checkout_payment (the payment-method area, used by payment gateway plugins to render their SDK element) and checkout_review (just above the Place Order button). Both render inside the checkout <form>, so a plugin can emit named inputs and any meta[<key>] field posts straight onto order.meta (e.g. a gift-message <textarea name="meta[gift_message]">). See Plugins.md → "Capturing checkout fields into order.meta".
The checkout page also exposes section hooks so plugins can inject between regions without overriding the file. Those inside the <form> (everything except checkout_top) can also emit meta[<key>] inputs:
| Hook | Location |
|---|---|
checkout_top | After the page <h1>, before the form (store-wide notices, trust badges). |
checkout_after_contact | After the Contact fields (email / name / phone). |
checkout_after_shipping_method | After the shipping-method options, before the address. |
checkout_after_address | After the shipping-address section (skipped for digital carts). |
checkout_summary_start | Top of the Order Summary card, before the coupon box. |
checkout_summary_after_items | After the line items, before the subtotal/total rows. |
checkout_summary_end | Bottom of the Order Summary card, after the total. |
Other storefront pages expose matching injection hooks:
| Page | Hooks |
|---|---|
cart.liquid | cart_top (after the <h1>); cart_line_after (after each line item, scoped item); cart_summary_end (bottom of the summary card); cart_empty (in the empty-cart state). |
account.liquid | account_top (above the tabs); account_tabs_end (after the last tab in the tablist); account_panes_end (after the last tab pane). To add a whole tab, append both a tab in account_tabs_end and its matching role="tabpanel" pane in account_panes_end. |
search.liquid | search_top (after the toolbar); search_sidebar_start / search_sidebar_end (top/bottom of the filter sidebar); search_results_top (above the product grid); search_results_empty (in the no-results state). |
order.liquid (Thank-You / confirmation) | order_confirmation_top (after the thank-you message — conversion pixels, post-purchase upsells) and order_confirmation_end (after the order card). |
order-detail.liquid (account order view) | order_detail_top and order_detail_end (both scoped order). |
login.liquid | login_top (after the <h1>). |
register.liquid | register_top (after the <h1>). |
forgot-password.liquid | forgot_password_top (after the <h1>). |
reset-password.liquid | reset_password_top (after the <h1>). |
order-lookup.liquid | order_lookup_top (the lookup form) and order_lookup_detail_top (the found-order view, scoped order). |
The snippets/order-summary.liquid partial (shared by the account order view and the guest lookup) also exposes an order_items_after hook (scoped order) after the items list, plus the order_totals block above.
Print documents (packing slips)
The admin can print a packing slip for an order or a wired fulfillment. The backend renders documents/packing-slip.liquid from the active theme (falling back to the default theme) into a standalone, auto-printing HTML page — so you can restyle the slip by overriding that file, and the customer's browser Save as PDF produces a PDF. The template receives order (for an order slip) or fulfillment (for a wired-fulfillment slip), plus the usual shop/settings.
The slip is customer-facing (it goes in the box): it lists items + quantities, and order totals — never supplier costs (a wired-fulfillment slip shows no money at all). The order totals sit in a {% block packing_slip_prices %} so a plugin can blank them; the bundled gifting plugin does exactly that for gift orders.
A wired-fulfillment slip is blind-dropship branded: because the supplier ships on the reseller's behalf, the letterhead shows the reseller's shop name (fulfillment.reseller_shop_name), and the supplier's own name (shop.name) appears nowhere on it — so it looks like it came from the reseller.
It exposes three hook regions for plugins: packing_slip_header, packing_slip_after_items, and packing_slip_footer. A plugin opts into them (and into the packing_slip_prices block) with the packing-slip dataloader (see Plugins.md). Plugin JavaScript does not run on the print page — a strict per-document CSP allows only the platform's own auto-print script — so packing-slip hooks should emit HTML/CSS only.
The account menu (account_menu)
The logged-in customer's Account dropdown in the header is data-driven rather than an HTML hook. account_menu is an array of { label, url } entries the theme renders as links:
{% for item in account_menu %}
<a href="{{ item.url | escape }}" role="menuitem">{{ item.label | escape }}</a>
{% endfor %}
Core seeds it empty; plugins append entries from a template.before_render hook (see Plugins.md), so menu items can be added or removed cleanly without HTML concatenation. The Wishlist plugin, for example, adds { label: "Wishlist", url: "/account/wishlist" }.
Cart actions (POST /cart)
All cart mutations are a form POST to /cart with an action field (include <csrf_tag />):
action | Fields | Effect |
|---|---|---|
add (default) | product_id, shop_id, option_<Name> per variant option, qty, plan (subscription key, empty = one-time) | Adds the line to the cart, then redirects to /cart. |
update | index, qty | Sets the quantity of line index (qty 0 removes it). |
remove | index | Removes line index. |
buy_now | same fields as add | One-click checkout that leaves the saved cart untouched. Instead of mutating the cart, the server encodes this single line into the checkout URL and redirects to /checkout?buy_now=<token>. The shopper's existing cart is neither read nor modified — after the buy-now order they still have whatever they had before. Use for a single-product "Buy now" / direct-to-checkout flow. |
The selected subscription plan comes from the product's subscription.plans (a plan may be scoped to a variant via plan.variant); pass its key as plan. Prices are always recomputed server-side from the product, so a tampered form can't set the price.
Buy-now is stateless — propagate the token on a custom checkout
The buy_now token is the only state for a one-click buy (nothing is written to the cart). The platform binds it to the checkout page as buy_now, and the line is reconstructed from it on every checkout request — page render, the POST /checkout submit, and the /calculate-shipping and /apply-coupon AJAX calls. If you ship your own checkout.liquid (the default theme already handles this), you must carry the token forward or checkout silently reverts to the saved cart:
- Add a hidden field inside the checkout
<form>:{% if buy_now %}<input type="hidden" name="buy_now" value="{{ buy_now | escape }}">{% endif %}(always| escape— the value is URL-supplied). - Append
buy_nowto theFormDataof any AJAXPOSTto/calculate-shippingand/apply-coupon, e.g.var el = document.querySelector('#checkout-form input[name="buy_now"]'); if (el) fd.append('buy_now', el.value);.
The "Buy now" button itself needs no change — it's the same action=buy_now POST to /cart; only a custom checkout template must thread the token.
Checkout name fields
The POST /checkout handler reads the buyer's name from a single name field (the legacy first_name + last_name pair is still accepted as a fallback, so older templates keep working). It also accepts an optional shipping_name field — the shipping recipient when the order ships to someone other than the buyer (e.g. a gift); when blank, the shipping address name defaults to the buyer's name. The default theme renders one "Full Name" input plus a "Ship to a different recipient" checkbox that reveals the shipping_name field. Both names land on the order as customer.name and shipping.name respectively.
Account subscription actions
The subscriptions array on account.liquid exposes per-subscription fields for display: id, status, interval, name, subtotal, tax, shipping_fee, total (tax-inclusive), next_bill_at, cycle_count, shipping (the saved address: .name/.line1/.line2/.city/.state/.zip/.country), and the can_pause / can_resume / can_cancel / can_pay / can_edit_address flags.
Customers manage a subscription with a form POST (include <csrf_tag />):
Endpoint / action | Fields | Effect |
|---|---|---|
POST /account action=cancel_subscription | pause_subscription | resume_subscription | subscription_id | Cancel / pause / resume. |
POST /account action=update_subscription_address | subscription_id, name, address_line1, address_line2, city, state, zip, country | Updates the shipping address and re-prices shipping + tax for future renewals (fires shipping.calculate + tax.calculate). |
POST /subscription-pay | subscription_id | Mints a payable renewal invoice and redirects to it (pay / update card). |
(POST /manage-subscription and POST /subscription-address are JSON equivalents of the cancel/pause/resume and address-update actions for AJAX themes.)
Context Variables
Various contexts are automatically injected depending on the page being viewed. For instance:
product: The product data model (available on product pages).cart: The current session's cart items.settings: Configured theme settings frommanifest.json.
The cart badge on cached pages
Storefront pages other than the inherently personal ones (cart, checkout, account, order/auth pages) are edge-cached and shared across all anonymous visitors regardless of their cart contents — a shopper who has added items still gets the cached page. This keeps your highest-traffic, most-engaged sessions fast instead of bypassing the CDN.
Because the HTML is shared, the contract for these pages is:
cart_countis rendered as0on shared (cacheable) pages and must be hydrated client-side. The platform sets a JS-readablecart_countcookie on every cart mutation; the default theme reads it injs/main.jsand updates the header badge. If you build your own header, read thecart_countcookie from JavaScript on load (treat a missing cookie as0) rather than trusting the server-rendered count. On logged-in (non-shared) pages and on the cart/checkout pages, the server-renderedcart_countis the real value.- Never render cart-derived content server-side on a cacheable page (a mini-cart with
cart_items, a free-shipping progress bar fromcart_subtotal, an "already in your cart" indicator, etc.). It would be baked into the shared cache and shown to the wrong visitor.cart_items/cart_subtotalare only bound on thecartandcheckoutpages, which are never cached; fetch live cart state client-side if you need it elsewhere.
Checkout address regions (countries, subdivisions_json)
Tax rules and shipping zones match the order's country/state against canonical ISO codes (alpha-2 country like US, alpha-2 subdivision like TX). For that matching to work, the address the shopper submits must use those same codes — so the checkout address form should collect country/state as codes, not free text. The platform helps by binding two variables on the checkout page:
countries— an array of{ code, name }for the country<select>. Render with{% for c in countries %}<option value="{{ c.code }}">{{ c.name | escape }}</option>{% endfor %}and keep the fieldname="country".subdivisions_json— a JSON object keyed by country code → array of{ code, name }(currently US states and Canadian provinces). It is trusted platform data emitted raw into a<script>(var SW_SUBDIVISIONS = {{ subdivisions_json }};) so the page can swap the state field to a<select>for countries that have subdivisions and fall back to a free-text<input>for those that don't. Keep the active control namedname="state".
The default checkout.liquid already does both (see its populateStateField / onCountryChange script). As a safety net the backend also normalizes common free-text values ("Texas" → TX, "United States" → US) on submit, but emitting codes from the form is what makes zone/tax matching reliable. The country list is curated (commonly-shipped destinations); extend model.Countries / frontend/src/data/regions.ts together if you need more.
The shop object
Available on every storefront page:
| Field | Description |
|---|---|
shop.id | Numeric shop ID. |
shop.name | Store name. |
shop.slogan | Store tagline (may be empty). |
shop.subdomain | The shopswired-managed subdomain (e.g. myshop). |
shop.domains | Array of the shop's verified custom domains (may be empty). |
shop.canonical_host | Canonical storefront host with no scheme, e.g. store.example.com. Same selection rules as canonical_url; handy for cookie domains, host comparisons, or display. |
shop.canonical_url | Canonical storefront origin — scheme + host, e.g. https://store.example.com, with no trailing slash. Always the shop's public domain (custom domain if set, otherwise the managed subdomain) — never the raw preview host. Use this when building absolute, indexable URLs. |
shop.active_theme | ID of the active theme (used with cdn_url). |
shop.currency | ISO currency code (e.g. USD). |
shop.payment_provider | Active payment gateway name (e.g. stripe), or empty. |
shop.theme | Theme branding object: logo_url and the color_* palette. |
shop.auth | Customer-auth flags: passwordless_login, require_account. |
Prefer
shop.canonical_urlover reconstructing the host yourself. A page may be served on a non-canonical host (a?shop=preview, or a managed-subdomain mirror of a custom-domain shop);canonical_urlalways resolves to the indexable public origin so you never leak an internal host into<link rel="canonical">,og:url, or JSON-LD.
The customer object
Present only when a customer is logged in (absent for guests, so guard with {% if customer %}):
| Field | Description |
|---|---|
customer.id | Numeric customer ID. |
customer.email | Customer email. |
customer.name | Customer name. |
customer.price_level | The customer's assigned pricing tier key (e.g. wholesale), or empty for standard retail pricing. |
Customer (B2B / tiered) pricing
When a logged-in customer has a price_level assigned, product.price and product.compare_price already reflect that level everywhere a product is rendered — product cards, the PDP, search/collection pages, {{ "..." | products: 12 }} results, and layout_render product blocks. Themes need no special handling: keep using {{ product.price | money }}.
- If the level is configured to show a strikethrough,
product.compare_priceis set to the retail price — render it as the "was" price exactly as you would for a sale. - Prices in cart and checkout reflect the same level, so what the customer sees while browsing matches what they're charged.
product.prices(the named-tier map) is still exposed if you want to show, say, the wholesale price next to retail.
SEO bindings
These are pre-built by ShopsWired and consumed by the default theme's seo_tags snippet:
og: Open Graph fields for the current page (og.title,og.description,og.image,og.type,og.url).og.urlis the canonical URL for the page and is built fromshop.canonical_url.json_ld: A ready-to-emit Schema.org JSON-LD string (Product on product pages). Output it inside a<script type="application/ld+json">tag.
Progressive "View More" pagination
Cursor-paginated lists (search/collection pages expose next_cursor + has_more) can opt into in-place "View More" loading with markup only — no per-template JavaScript. The default theme's js/main.js watches for these data- attributes globally:
<div id="product-grid" class="product-grid" data-pager-list>
{% for product in products %}
{% include 'product-card' %}
{% endfor %}
</div>
{% if has_more %}
<div class="text-center mt-xl" data-pager-next>
<a href="/search?{{ base_query }}{% if base_query != '' %}&{% endif %}cursor={{ next_cursor }}"
class="sw-btn sw-btn-secondary"
data-load-more
data-target="#product-grid">View More</a>
</div>
{% endif %}
| Attribute | On | Purpose |
|---|---|---|
data-load-more | the next-page <a> | Marks the link as a progressive trigger. |
data-target | the next-page <a> | CSS selector of the list container new items are appended into. |
data-pager-next | the wrapper around the link | The control region that gets swapped for the next page's control (or removed on the last page). |
data-pager-list | the list container (optional) | Documentation marker; the script targets via data-target. |
How it degrades: the href is a normal next-page URL, so without JavaScript (or if the fetch fails) the link just navigates the whole page. With JavaScript, the click is intercepted, the same URL is fetched, and the next page's items + pager control are lifted out of the returned HTML and swapped in place. The server renders its normal full page — there is no special "fragment" mode — so any cursor-based list works by adding these attributes. (The request carries an X-Requested-With: fetch header, reserved for a future server-side layout-skip optimization; templates need not do anything with it.)
Pager bindings by page. Each paginated page exposes the cursor for the next page and a boolean for whether one exists. Names are scoped per page so a template can carry more than one independent list:
| Page (template) | Cursor binding | Has-more binding | List |
|---|---|---|---|
search.liquid | next_cursor | has_more | products |
account.liquid | orders_cursor | orders_has_more | order history |
Note that only one [data-pager-next] may be active per rendered page — the script swaps the first one it finds in the response. A page with two simultaneously-paginated lists is not supported by this helper.