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: description is shown on the marketplace card and detail modal (alongside the README and changelog). The author displayed in the marketplace is always the publishing shop's name, not the author field above — the manifest author is 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:

FormMeaning
{% 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):

BlockFileScope argWraps
priceproduct.liquid and snippets/product-card.liquidproduct (+ 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_cartproduct.liquidThe Add to Cart / Out of Stock buttons (inside the product form).
product_galleryproduct.liquidproductThe PDP image gallery + thumbnails.
product_titleproduct.liquidproductThe PDP <h1> title.
card_mediasnippets/product-card.liquidproductThe product-card image / placeholder.
card_badgessnippets/product-card.liquidproductThe product-card stock/badge area.
product_detailsproduct.liquidproductThe 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_descriptionproduct.liquidproductThe PDP description block. Wraps the whole conditional, so an override runs even when product.desc is empty.
cart_line_pricecart.liquiditemA cart line item's price.
order_totalssnippets/order-summary.liquidorderThe 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_checkoutcart.liquidThe cart's Checkout button/link. Override to relabel it, add an express-checkout button alongside it, or gate it behind a condition.
place_ordercheckout.liquidThe 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_emptysearch.liquidThe 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 the email.* plugin hooks. A theme branch for one of these types is dead code; a plugin can't restyle or suppress them. (Note: password_reset is 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., 1999 becomes $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 explicit true/false to 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; | json is specifically for emitting structured data into JS.

  • {% if value | present %}: Single, reliable "is this meaningfully set?" check for conditions. Returns false for nil/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 != blank or != "". This engine's blank only matches nil (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 "". present is 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 The layout field & layout_render for 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) with order in 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.total is in cents, so divide by 100. The gtag function 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 bare cdn_url already 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=raw to 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.

KeyTypeEffect
fullWidthboolInner container uses sw-layout-full (edge-to-edge) instead of sw-layout-container.
paddingstringCSS padding on the section.
gapstringSets the --sw-layout-gap CSS variable on the inner container.
backgroundColorstringCSS background-color.
backgroundImagestringCSS 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.

typesettings keysNotes
headingtext, level (default h2), align, colorText is HTML-escaped.
texttext, size (small/large, default medium), align, colorRenders <p> with sw-layout-text-{sm,md,lg}. Escaped.
richtextcontentRaw HTML, not escaped — trusted merchant input only.
htmlcontentRaw HTML, not escaped — trusted merchant input only.
imageurl, alt, link, width (default 100%)Wrapped in an <a> when link is set.
buttontext, url, style (default primary), align (default left)Renders <a class="sw-btn sw-btn-{style}">.
hooknameRenders the named template hook (lets plugins inject into a layout).
productstitle, 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.

richtext and html blocks emit their content verbatim. All other text fields are HTML-escaped by the renderer.

layout_render from a plugin (sw.liquid.render). When a plugin renders a layout fragment through sw.liquid.render in a route or widget handler (not the storefront render path), the products block works — it runs a real, priced product search just like the storefront. The hook block, however, renders nothing in that context: template hooks only fire during the storefront render pipeline, which a standalone sw.liquid.render call doesn't set up. If you need plugin-injected content in a plugin-rendered fragment, emit it directly rather than relying on a hook block.

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 a layout setting (or any string field) that layout_render consumes 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 block types and their settings keys — unknown block types are silently skipped at render time. If your builder needs a new block kind, propose it as a first-class layout_render type 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 (/product for /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:

HookLocation
checkout_topAfter the page <h1>, before the form (store-wide notices, trust badges).
checkout_after_contactAfter the Contact fields (email / name / phone).
checkout_after_shipping_methodAfter the shipping-method options, before the address.
checkout_after_addressAfter the shipping-address section (skipped for digital carts).
checkout_summary_startTop of the Order Summary card, before the coupon box.
checkout_summary_after_itemsAfter the line items, before the subtotal/total rows.
checkout_summary_endBottom of the Order Summary card, after the total.

Other storefront pages expose matching injection hooks:

PageHooks
cart.liquidcart_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.liquidaccount_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.liquidsearch_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.liquidlogin_top (after the <h1>).
register.liquidregister_top (after the <h1>).
forgot-password.liquidforgot_password_top (after the <h1>).
reset-password.liquidreset_password_top (after the <h1>).
order-lookup.liquidorder_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.

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 />):

actionFieldsEffect
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.
updateindex, qtySets the quantity of line index (qty 0 removes it).
removeindexRemoves line index.
buy_nowsame fields as addOne-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_now to the FormData of any AJAX POST to /calculate-shipping and /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 / actionFieldsEffect
POST /account action=cancel_subscription | pause_subscription | resume_subscriptionsubscription_idCancel / pause / resume.
POST /account action=update_subscription_addresssubscription_id, name, address_line1, address_line2, city, state, zip, countryUpdates the shipping address and re-prices shipping + tax for future renewals (fires shipping.calculate + tax.calculate).
POST /subscription-paysubscription_idMints 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 from manifest.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_count is rendered as 0 on shared (cacheable) pages and must be hydrated client-side. The platform sets a JS-readable cart_count cookie on every cart mutation; the default theme reads it in js/main.js and updates the header badge. If you build your own header, read the cart_count cookie from JavaScript on load (treat a missing cookie as 0) rather than trusting the server-rendered count. On logged-in (non-shared) pages and on the cart/checkout pages, the server-rendered cart_count is 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 from cart_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_subtotal are only bound on the cart and checkout pages, 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 field name="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 named name="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:

FieldDescription
shop.idNumeric shop ID.
shop.nameStore name.
shop.sloganStore tagline (may be empty).
shop.subdomainThe shopswired-managed subdomain (e.g. myshop).
shop.domainsArray of the shop's verified custom domains (may be empty).
shop.canonical_hostCanonical 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_urlCanonical 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_themeID of the active theme (used with cdn_url).
shop.currencyISO currency code (e.g. USD).
shop.payment_providerActive payment gateway name (e.g. stripe), or empty.
shop.themeTheme branding object: logo_url and the color_* palette.
shop.authCustomer-auth flags: passwordless_login, require_account.

Prefer shop.canonical_url over 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_url always 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 %}):

FieldDescription
customer.idNumeric customer ID.
customer.emailCustomer email.
customer.nameCustomer name.
customer.price_levelThe 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_price is 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.url is the canonical URL for the page and is built from shop.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 %}
AttributeOnPurpose
data-load-morethe next-page <a>Marks the link as a progressive trigger.
data-targetthe next-page <a>CSS selector of the list container new items are appended into.
data-pager-nextthe wrapper around the linkThe control region that gets swapped for the next page's control (or removed on the last page).
data-pager-listthe 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 bindingHas-more bindingList
search.liquidnext_cursorhas_moreproducts
account.liquidorders_cursororders_has_moreorder 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.