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 account — order history (landing page)
├── account/                # Account sub-pages (sidebar-linked)
│   ├── profile.liquid      #   /account/profile — name/email + password reset
│   ├── addresses.liquid    #   /account/addresses — saved address book
│   └── subscriptions.liquid#   /account/subscriptions — recurring contracts
├── 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.

Extending another theme (extends)

A theme can declare a parent with the top-level extends field:

{
    "id": "my-child-theme",
    "name": "My Child Theme",
    "version": "1.0.0",
    "extends": "default"
}

Any file your theme doesn't include — templates, snippets, CSS, JS — is resolved from the parent, and chains any depth (a parent can itself extend another theme). Your theme carries only the files it changes; everything else stays in sync with the parent, including future parent updates. Settings merge too: the parent's settings appear in your theme's settings form, and a child setting with the same key replaces the parent's definition.

One convention to know: the default theme's layout links the stylesheet /theme-assets/css/<active theme id>.css. So a child of default should ship a css/<your-theme-id>.css — that one file is often the entire theme, overriding the design tokens (--sw-color-primary, --sw-font, …) while inheriting every template.

Design tokens cover more than color. Page headings render with a variable-driven size scale.sw-head-2xl / .sw-head-xl / .sw-head-lg / .sw-head-md (largest to smallest) — instead of fixed size/weight utilities. So a child theme can rescale every heading at once by overriding the tokens in your css/<your-theme-id>.css, with no per-template overrides:

:root {
    --sw-head-font: 'Playfair Display', serif;      /* shared heading font */
    --sw-head-weight: 400;                          /* shared weight */
    --sw-head-tracking: -0.01em;                    /* shared letter-spacing */
    --sw-head-leading: 1.2;                          /* shared line-height */
    --sw-head-lg: clamp(1.75rem, 3vw, 2.5rem);      /* per-step size — clamp() is fine */
    /* also: --sw-head-2xl (error/hero), --sw-head-xl (product & confirmation), --sw-head-md (search) */
}

Overriding tokens in :root needs no !important — the .sw-head-* classes read the variable, so there's no specificity fight to win. The shared tokens (--sw-head-font, -weight, -tracking, -leading) apply to the whole scale; sizes are per step. Override a .sw-head-* rule directly only when you need a property the tokens don't cover, or a different weight/font per size. Per-heading spacing (e.g. margin-bottom) stays on the element via mb-* utilities, not these type tokens.

The same token pattern applies beyond the page-title scale:

  • Secondary section/card headings — the .sw-title-* scale (the "Order Summary" / "Digital Downloads" / "Details" headings that sit below a page's .sw-head-* title) mirrors .sw-head-*: a shared --sw-title-weight (default 600) plus a per-step size — --sw-title-lg (default 1.125rem) and --sw-title-md (default 0.9rem).
  • Emphasized values in summary rows — the .summary-value class (order-confirmation card, cart, checkout totals) reads --sw-summary-value-weight (default 700).

Override any of them once in :root to restyle every occurrence at no per-template cost:

:root {
    --sw-title-weight: 700;           /* bolder section headings, whole scale */
    --sw-title-lg: 1.25rem;           /* just the large step */
    --sw-summary-value-weight: 600;   /* lighter emphasis on order/total values */
}

These replaced repeated fs-*/fw-* utility pairs that a child theme's tokens couldn't reach — prefer .sw-title-lg / .sw-title-md (and .summary-value) over the raw utilities when a heading or value plays that role.

In the admin, Customize → Themes → New has an Extends field (pre-filled with default). Extending default scaffolds the minimal child theme for you — a manifest.json plus the css/<id>.css stub; extending any other theme scaffolds just the manifest.json. Opening a theme's manifest.json in the file editor also offers an Extend action that starts a new child of that theme. Leave the field empty to create a standalone theme with no starter files.

For block-level inheritance within a single template file, see Template Inheritance (extends / block) below.

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.

Page body class. The default layout tags <body> with class="page-{{ dataloader }}", where {{ dataloader }} is the current page's data-loader name — page-cart, page-checkout, page-product, page-search, page-account, page-auth (login/register/forgot/reset all share the auth loader), page-account-subscriptions, and so on. This gives an inheriting theme (or a plugin's injected CSS) a stable hook to scope overrides to one kind of page without cloning its template:

body.page-cart .sw-head-lg { text-transform: uppercase; }
body.page-checkout .grid-checkout { gap: 3rem; }

The error page (404, and the other error statuses) carries the error loader — page-error — whatever URL was asked for, so body.page-error styles it and {% if dataloader == 'error' %} branches on it inside a shared snippet. Its template also has status_code and page_title in scope, and route is the path that missed.

Custom (non-built-in) routes have no data loader, so their <body> carries no page-* class — target those by the markup inside your own template instead. If your own layout replaces the default, add the class yourself: <body{% if dataloader | present %} class="page-{{ dataloader | escape }}"{% endif %}>.

Focused checkout. The default theme's Checkout settings tab has a Focused Checkout switch, on by default. With it on, the checkout page (and only that page) swaps the site header and footer for snippets/checkout-header and snippets/checkout-footer, and skips the announcement bar and the breadcrumb — leaving the store name, an optional cart link, and one row of footer links (Checkout Footer Links, falling back to the Footer Menu when empty). The layout decides this once:

{%- assign focused_checkout = false -%}
{%- if dataloader == 'checkout' -%}
    {%- if settings.checkout_focused | present -%}{%- assign focused_checkout = true -%}{%- endif -%}
{%- endif -%}

Both snippets carry their own blocks (checkout_header_logo, checkout_header_cart, checkout_footer_links, checkout_footer_copyright), so a child theme restyles the focused chrome without cloning layout.liquid. Their markup reuses the site-header / site-footer classes, so brand colors and borders stay in sync with the rest of the storefront.

Snippets

Snippets are reusable Liquid files located in the snippets/ directory. Include them using:

{% include 'product-card' %}

The default theme's shared snippets are header, footer, checkout-header, checkout-footer, seo_tags, theme_styles, account-nav, product-card, order-summary, order-info, and breadcrumb. Includes resolve from the theme root through the inheritance chain, so a page in a subfolder (account/profile.liquid) uses the same {% include 'snippets/breadcrumb' %} path, and a snippet your theme doesn't define falls back to the parent theme's copy.

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 customizable regions in named blocks. Child themes override them ({% extends parent %} + {% block … %}), and plugins wrap them (block.<name>). Where the region depends on a record, the block forwards it 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).

Two conventions worth knowing before the tables:

  • A block wraps the whole conditional, not just the markup inside it, wherever that's useful — so an override still runs in the "empty" case (a product with no attributes, an order with nothing to pay, a summary with no discount). Where a block covers a whole card or column, the block sits outside the card element, so {{ block.super }} + your markup adds content beside/below the card, not only inside it.
  • Trailing hooks stay outside the block where a plugin's content should survive a wholesale override (account_nav_end, checkout_summary_end, cart_summary_end). Where a hook is structural to the region — checkout_payment, cart_empty, search_results_empty — it lives inside the block, so keep {{ block.super }} in an override.

Layout, header, footer, SEO

BlockFileScope argWraps
faviconlayout.liquidThe favicon / apple-touch-icon links. Override to point at your own icon files instead of the shop logo.
styleslayout.liquidThe font preconnects, Google Font, base + theme stylesheets, and the theme-settings style block. Override to self-host fonts or drop the base stylesheet.
head_scriptslayout.liquidThe money-format bootstrap and the theme's main.js. Use {{ block.super }} and append rather than replacing — main.js powers "View More" pagination and the mobile nav.
site_headerlayout.liquidThe header include — snippets/header, or snippets/checkout-header on the checkout page in Focused Checkout (see below). Override to swap in your own header snippet.
main_contentlayout.liquidThe <main> element, the main_start/main_end hooks, and content_for_layout. Override to wrap the page in extra chrome — always include {{ block.super }}, or the page body renders nothing.
site_footerlayout.liquidThe footer include — snippets/footer, or snippets/checkout-footer on the checkout page in Focused Checkout (see below).
seo_title_tagsnippets/seo_tags.liquidThe <title> element. The seo_title and store_name variables are assigned before the block, so an override can reuse them.
seo_descriptionsnippets/seo_tags.liquidThe meta description, the noindex robots tag, and the canonical link.
seo_socialsnippets/seo_tags.liquidThe Open Graph + Twitter card tags.
seo_json_ldsnippets/seo_tags.liquidThe application/ld+json structured-data script. Override to emit your own schema.
header_logosnippets/header.liquidThe logo/store-name link.
header_menusnippets/header.liquidThe main-menu links (settings menu + auto-listed pages).
header_searchsnippets/header.liquidThe header search form. Wraps the whole "Show Search Bar" setting check.
header_cartsnippets/header.liquidThe cart icon + count badge. Wraps the whole "Show Cart Icon" setting check.
header_accountsnippets/header.liquidcustomerThe account dropdown (logged in) or the Login link (logged out), including the "Show Login/Account Link" setting check.
account_menu_linkssnippets/header.liquidThe links inside the header Account dropdown — the core links (Order History / Profile / Addresses) followed by the account_menu entries, so the dropdown mirrors the sidebar. Override to reorder or trim them (Logout renders after the block and is always kept). The dropdown has no per-page context, so it doesn't gate Subscriptions or set aria-current.
footer_infosnippets/footer.liquidThe footer's store-info column (settings copy + copyright).
footer_copyrightsnippets/footer.liquidJust the copyright line, nested inside footer_info.
footer_linkssnippets/footer.liquidThe footer "Quick Links" column.
checkout_header_logosnippets/checkout-header.liquidThe focused checkout header's logo/store-name link.
checkout_header_cartsnippets/checkout-header.liquidThe focused checkout header's cart link, including the "Show Cart Link" setting check. Keep the cart-link class and the .cart-count element if you re-render it — the theme script syncs the badge from the cart cookie.
checkout_footer_linkssnippets/checkout-footer.liquidThe focused checkout footer's link row (the "Checkout Footer Links" setting, falling back to the Footer Menu), including the whole conditional.
checkout_footer_copyrightsnippets/checkout-footer.liquidThe focused checkout footer's copyright line.
breadcrumbsnippets/breadcrumb.liquidbreadcrumbsThe breadcrumb <nav> rendered from the breadcrumbs binding (see "Breadcrumbs" below). Override to restyle the trail — as a wrapper it fires on every page that includes the snippet.

Product pages and product cards

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.
product_galleryproduct.liquidproductThe PDP image gallery + thumbnails.
product_titleproduct.liquidproductThe PDP <h1> title.
product_skuproduct.liquidproductThe SKU line, including the "Show SKU" setting check. Keep the #sku-value element if you re-render it — the page script swaps it on variant change.
product_price_tiersproduct.liquidproductThe volume-pricing table. Wraps the whole conditional, so an override runs even for a product with no tiers.
product_optionsproduct.liquidproductThe variant option selects and the subscription "Purchase options" select. Keep the option_<name> / plan field names and the variant-select class — the page script prices variants off them.
product_quantityproduct.liquidThe quantity field. Keep #product-qty (name qty) if you replace it.
add_to_cartproduct.liquidThe Add to Cart / Out of Stock buttons (inside the product form).
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.
product_tagsproduct.liquidproductThe tag badges under the description.
product_lightboxproduct.liquidproductThe PDP image-lightbox container (rendered after the page scripts). Override to add gallery navigation (prev/next/caption), or use {{ block.super }} and append a <script> — the block has product in scope, so you can emit your own gallery data — to extend the default lightbox without cloning the template.
card_mediasnippets/product-card.liquidproductThe product-card image / placeholder.
card_titlesnippets/product-card.liquidproductThe product-card title line.
card_badgessnippets/product-card.liquidproductThe product-card stock/badge area.

Search / browse

BlockFileScope argWraps
search_headingsearch.liquidThe results <h1> (query / tag / refinement / "All Products").
search_refinementssearch.liquidactive_refinementsThe "Filtered by:" pills and the Clear-all link, including the whole conditional.
search_sidebarsearch.liquidThe entire filter sidebar <aside>. Override to drop it or replace it wholesale; the blocks below are nested inside it.
search_sortsearch.liquidThe Sort By control.
search_filterssearch.liquidThe price-range and in-stock filters, including both setting checks.
search_facetssearch.liquidfacetsThe generated facet groups. Each has a name (display text, e.g. Shoe Size), a key (what a filter link must use, e.g. attr_shoe_size), and values; each value has value, count, and — where the display text differs, as it does for tags — a label. Build links from facet.key and show facet.name: names are display text and may be reworded, keys are what a filter resolves.
tag_descriptionsearch.liquidThe tag's description, shown under the heading when the page is filtered to a tag that has one.
search_resultssearch.liquidproductsThe product grid and its pagination. Renders only when there are results.
search_paginationsearch.liquidThe "View More" link, nested inside search_results. Override for infinite scroll or numbered pages; keep the data-load-more / data-target attributes to reuse the built-in loader.
search_emptysearch.liquidThe no-results state. 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.

Cart and checkout

BlockFileScope argWraps
cart_linecart.liquiditemA whole cart line row — image, title/options, price, quantity form, and remove button. Renders once per item, so a wrapper sees each line separately. The nested cart_line_price block can still be overridden on its own as long as you don't override cart_line too.
cart_line_pricecart.liquiditemA cart line item's price.
cart_summarycart.liquidcart_itemsThe cart's whole Order Summary column — the card element plus everything in it. The block wraps the card, so {{ block.super }} plus your markup adds content below/outside it (trust badges, policy copy). Renders only when the cart has items.
cart_totalscart.liquidThe cart's subtotal/total rows. Override to add lines (estimated shipping, tax, savings).
cart_couponcart.liquidThe cart's "Have a coupon?" field. Return nothing to remove it and collect codes at checkout only.
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.
cart_emptycart.liquidThe empty-cart state. Override to replace it wholesale — recommended products, a promo, a different call to action. It contains the cart_empty hook, so keep {{ block.super }} to preserve plugin content.
checkout_contactcheckout.liquidcustomerThe checkout Contact region (heading + email / full name / phone). Everything here is inside the checkout <form>, so appended meta[<key>] inputs post onto the order. Keep the email, name, and phone field names if you replace it.
checkout_deliverycheckout.liquidhas_pickupThe Ship / Pickup switch at the top of the Delivery region (hidden when the store offers no pickup option). Keep #delivery-toggle and the two .delivery-tab radios named delivery_mode with values ship / pickup — the page script drives them. Physical carts only.
checkout_addresscheckout.liquidsaved_addressThe shipping address fields, shown while the shopper is shipping. Keep the #address-section wrapper (it's hidden for pickup), the address-field class, and the address_line1 / city / state / zip / country field names — the page recalculates shipping and tax from them. Physical carts only.
checkout_pickupcheckout.liquidshipping_optionsThe pickup locations region — the list of pickup options plus the selected location's details. Keep #pickup-section, #pickup-locations, #pickup-details, and #pickup-info; the page script shows the section in Pickup mode and fills in the details. Physical carts only.
checkout_shipping_methodcheckout.liquidshipping_optionsThe Shipping method list, which follows the address. Wraps the whole conditional, so an override still runs before any options have been calculated. Keep #shipping-method-section (hidden for pickup) and #shipping-methods. Physical carts only; digital carts skip the region entirely.
checkout_paymentcheckout.liquidThe Payment region. Wraps the whole conditional, so an override runs even when the store takes no payment (free order, no provider). It contains the checkout_payment hook that payment plugins render into — keep {{ block.super }}, or the page can take no payment.
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.
checkout_summarycheckout.liquidcart_itemsThe whole Order Summary column, including the column wrapper (.checkout-summary-col) that is the checkout grid's right-hand cell. Override this only to replace the column itself; to add content around the card use checkout_summary_card instead, so your markup stays inside the grid cell.
checkout_summary_cardcheckout.liquidcart_itemsThe Order Summary card alone, inside the column wrapper. {{ block.super }} plus your markup adds content above or below the card (payment-badge row, trust seals, policy copy) without copying the card markup. The wrapper is what sticks as the page scrolls, so your added markup scrolls with the card rather than being left behind.
checkout_couponcheckout.liquidThe discount-code / gift-card field at the top of the summary card. Keep #coupon-input, #apply-coupon-btn, #coupon-message, and #applied-coupons if you re-render it — the page script drives them.
checkout_totalscheckout.liquidThe subtotal / discount / shipping / tax rows and the grand total. Keep the #discount-row, #shipping-row/#shipping-amount, #tax-row/#tax-label/#tax-amount, and #order-total ids — the page script updates them live as the address and shipping choice change.

Orders (confirmation, account detail, guest lookup)

BlockFileScope argWraps
order_headerorder.liquidorderThe order-confirmation ("Thank You") page header — success icon, heading, and subtext.
order_detailsorder.liquidorderThe confirmation page's number / status / total / email card.
order_actionsorder.liquidorderThe confirmation page's bottom call-to-action area (the Continue Shopping button).
order_itemssnippets/order-summary.liquidorderThe order's line-item card (account order detail + guest lookup).
order_totalssnippets/order-summary.liquidorderThe order subtotal/discount/shipping/tax/total card. Override to blank or restyle totals — the storefront analogue of the print packing_slip_prices block.
order_downloadsorder.liquid and snippets/order-summary.liquiddownloadsThe digital-downloads card, including the "any downloads?" check. Same block name in both places, so one wrapper covers the thank-you page and the order views.
order_trackingsnippets/order-summary.liquidorderThe tracking-information card.
order_detail_headerorder-detail.liquidorderThe account order page's "Order #N" heading, status badge, and date.
order_detail_payorder-detail.liquidorderThe Complete Payment card shown for an unpaid order. Contains the checkout_payment hook and the #pay-btn control the page script drives — keep {{ block.super }} unless you own the payment flow.
order_detail_bodyorder-detail.liquidorderThe two-column order summary + order info area.
order_detail_emptyorder-detail.liquidThe "Order not found." state.
order_lookup_headerorder-lookup.liquidorderThe found-order heading, status badge, and date.
order_lookup_bodyorder-lookup.liquidorderThe found-order two-column summary + info area.
order_lookup_formorder-lookup.liquidThe email + order-number lookup form.

Account pages

BlockFileScope argWraps
account_headingaccount.liquid, account/profile.liquid, account/addresses.liquid, account/subscriptions.liquid, order-detail.liquidThe "My Account" page heading. Same block name on every account page, so one override or wrapper covers them all.
account_nav_linkssnippets/account-nav.liquidaccount_activeThe default account sidebar links (Order History / Subscriptions / Profile / Addresses — Subscriptions shows only when the customer has one). Override to remove, rename, or reorder them wholesale; account_active names the current section (orders, subscriptions, profile, addresses) so an override can set aria-current="page" correctly. Entries from account_menu and the account_nav_end hook render after the block, so they survive an override.
account_ordersaccount.liquidordersThe order-history list, its "View More" pager, and the empty state.
account_profileaccount/profile.liquidcustomerThe profile-details card (name/email form + the password-reset section).
account_addressesaccount/addresses.liquidaddressesThe saved-addresses list and its empty state.
account_subscriptionsaccount/subscriptions.liquidsubscriptionsThe subscriptions list (pay / pause / resume / cancel actions, address editor) and its empty state.

Auth and error pages

BlockFileScope argWraps
login_heading / login_form / login_linkslogin.liquidThe page heading, the sign-in form (email + password, or the magic-link form on a passwordless store), and the "Forgot password / Create one" links.
register_heading / register_form / register_linksregister.liquidThe page heading, the create-account form, and the "Already have an account?" link.
forgot_password_heading / forgot_password_form / forgot_password_success / forgot_password_linksforgot-password.liquidThe heading, the request form, the "check your email" confirmation, and the back-to-login link.
reset_password_heading / reset_password_form / reset_password_successreset-password.liquidThe heading, the new-password form, and the success state.
error_title / error_actionserror.liquidThe error heading and the message/action link on the error page (404, 410, 403, 500 — see "The error page" below).
packing_slip_prices_documents/packing-slip.liquidThe printed order totals on a packing slip. Return nothing to hide prices (the bundled gifting plugin does this for gift orders).

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).

The error page (error.liquid)

error.liquid renders every error state, not just 404, and binds status_code (the numeric status) alongside a page_title already worded for that status. Branch on status_code when the wording should differ — the default theme does this in {% block error_actions %}:

{% if status_code == 410 %}
  <p>This page is no longer available.</p>
{% elsif status_code == 500 %}
  <p>Something went wrong on our end. Please try again in a moment.</p>
{% else %}
  <p>The page you're looking for could not be found.</p>
{% endif %}

The codes a storefront page can produce:

CodeMeaning
404No such page — or a product that exists but is currently unavailable (deactivated, or supplied by a store whose connection isn't active). It may come back at this URL.
410The product that lived at this URL was deleted and is not coming back. Search engines drop a 410 faster than a 404, which is why the two are distinguished.
403The request isn't valid for this page.
500Something failed while loading the page. Unlike 404/410 this says nothing about whether the page exists, so it's safe to retry.

A theme that ignores status_code entirely still works — it just shows the same message for every error.

One status is deliberately not yours to style: 429, sent to a client asking for pages far faster than a person can read them (see Features.md → "Automatic abuse protection"). The store answers those itself with a plain built-in page and never renders the theme, because answering a request you are declining to serve by building a full page would cost exactly what declining it was meant to save. Ordinary shoppers never see it, and it is never sent for a checkout request.

Breadcrumbs are data, not markup. Every page that has a trail publishes a breadcrumbs binding — an ordered list of { label, url } entries, first (Home) to last (the current page) — and each template renders it with a single include:

{% include 'snippets/breadcrumb' %}

The snippet links every entry except the last, which renders as <span aria-current="page">; an entry with an empty url renders as plain text. If breadcrumbs is absent or empty the snippet outputs nothing, so it's safe to include on any page.

The built-in trails are:

PageTrail
ProductHome › Shop › product name
CartHome › Cart
CheckoutHome › Cart › Checkout
Login / Create Account / Forgot Password / Reset PasswordHome › page name
Account (and Profile / Addresses / Subscriptions)Home › Account › section
Order detailHome › Account › Order #N
Order lookupHome › Order Lookup, or Home › Order #N once an order is found

The home page and search/browse have no trail by design.

Your own pages get one automatically. A page with no built-in loader — a terms.liquid you added, account/warranty.liquid, a plugin's page route — has its trail derived from the URL, so simply including the snippet is enough:

Page fileURLTrail
terms.liquid/termsHome › Terms
gift-cards.liquid/gift-cardsHome › Gift Cards
account/warranty.liquid/account/warrantyHome › Account › Warranty
docs/legal/[doc].liquid/docs/legal/returnsHome › Docs › Legal › Returns

The rules:

  • Each path segment becomes a crumb, title-cased from its slug (gift-cards → "Gift Cards"). A word you already capitalized is left alone, so FAQ and iPhone-cases survive as written.
  • The last crumb is the current page and is never a link. Its label comes from the page's own page_title when it has one (a CMS page, a plugin page route), otherwise from the slug.
  • An intermediate crumb links only when its path prefix is a real page in your theme. /account/warranty links "Account" because /account exists; /docs/legal/returns leaves "Docs" and "Legal" as plain text unless you also have docs.liquid and docs/legal.liquid. No dead links, ever.
  • Trails are capped at five crumbs, and / (the home page) gets none.

Tip. Because the snippet renders nothing when there's no trail, you can include it once in layout.liquid (just inside main_content) instead of per template — every page that has a trail then shows one, and the rest are unaffected.

Changing a trail. The markup is yours: override snippets/breadcrumb.liquid (or the breadcrumb block inside it) to restyle the trail, add schema.org markup, or hand-write a different trail for one page — the file has breadcrumbs, dataloader, and route in scope, so it can branch per page:

{% comment %} snippets/breadcrumb.liquid in your theme {% endcomment %}
{% extends parent %}
{% block breadcrumb %}
    {% if dataloader == 'product' %}
        {% comment %} …your own product trail… {% endcomment %}
    {% else %}
        {{ block.super }}
    {% endif %}
{% endblock %}

The trail's contents are data, so a plugin changes them the way it changes any other binding — from template.before_render, or by returning breadcrumbs in the bindings of its own page route (see Plugins.md). Appending one crumb for a custom section, or rewriting the whole trail, is a list edit with no markup involved; the bundled Page & Redirect plugin does exactly that, naming each CMS page's crumb after the page title.

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, subscription_payment_failed, 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.

Unsubscribe footer (customer email). Every email a shop sends a customer belongs to a topic the shopper can unsubscribe from, so those renders also bind:

BindingMeaning
unsubscribe_urlOne-click link that opens the unsubscribe page for this topic. Present only on mail the shopper is allowed to unsubscribe from — account/security email (welcome, magic_link, password_reset) has none.
preferences_urlThe shopper's full email-preferences page (/account/notifications).
notify_categoryThe topic's key (orders, shipping, subscriptions, marketing, or a plugin's).
notify_category_labelThe shopper-facing label for that topic, e.g. "Order updates".

Render them at the end of your template, guarded by present so account mail doesn't sprout an unsubscribe link:

{% if unsubscribe_url | present %}
<p style="color:#888;font-size:12px;">
  You're receiving this because you're subscribed to {{ notify_category_label | escape }} from {{ shop.name }}.
  <a href="{{ unsubscribe_url | escape }}">Unsubscribe</a> &middot;
  <a href="{{ preferences_url | escape }}">Email preferences</a>
</p>
{% endif %}

The subscription_payment_failed type binds subscription: error (the customer-safe decline reason), retry_at (the formatted date of the next attempt), and account_url (where they update the card).

Email preferences & unsubscribe pages

Two customer-facing pages back the footer above.

account/notifications.liquid (route /account/notifications) is a normal account page — same sidebar, same {% include 'snippets/account-nav' %} with {% assign account_active = 'notifications' %}. It binds:

  • notify_categories — one row per topic the store can email this customer about: key, label, description, class (transactional/marketing), marketing (boolean), group (the plugin's name for a topic a plugin owns, "" for the store's own), locked (account/security mail — always sent, not editable), enabled, explicit (the shopper chose it, rather than inheriting the default).
  • notify_opt_out_all — the "stop emailing me" switch.

The form posts action=update_notify_prefs with one notify_category hidden input per row it rendered plus a notify_on checkbox per subscribed row (an unticked box is a real "off"), and optionally notify_opt_out_all. Rows with locked are display-only.

unsubscribe.liquid (route /unsubscribe) is reached from an email footer, by a shopper who is usually not logged in. It is optional: without it the platform renders a plain built-in page, so the link in an already-delivered email never breaks. It binds an unsubscribe object — token, email (masked, e.g. j•••[email protected]), category, label, has_topic, subscribed, opted_out, done (success message), error — and posts back to /unsubscribe with the hidden t token and action = unsubscribe, resubscribe, or unsubscribe_all. It takes no <csrf_tag />: the signed token in the link is the authorization, and the page must work in email clients where the CSRF script won't run. Hooks: unsubscribe_top, unsubscribe_bottom, and account_notifications_end on the preferences page.

Marketing opt-in. Marketing email is off until the shopper asks for it, so the register and checkout forms carry an optional notify_marketing checkbox. Ticking it subscribes them; leaving it unticked never unsubscribes someone who already opted in. Two settings control it: Marketing Opt-in Label (Register) is the wording, and emptying it hides the checkbox everywhere; Ask for Marketing Opt-in at Checkout (Checkout) decides whether it also appears mid-purchase, so a store can ask at sign-up only. The server accepts a posted notify_marketing regardless — the checkout setting governs the form, not the consent.

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 a currency string (e.g., 1999 becomes $19.99). The whole part is grouped with thousands separators (123456789$1,234,567.89); a store can turn grouping off with the theme's Group thousands with commas setting (under General) for a plain $1234567.89. Pass a symbol argument to override the currency symbol (e.g. {{ price | money: '£' }}). For prices you render client-side (a variant swap, a live checkout total), format them with window.SW.money(cents) — a helper the default theme's layout defines that mirrors this filter exactly (same currency symbol, decimals, and grouping setting), so server- and client-rendered prices always match. If you ship a custom layout, define your own or carry the snippet over.

  • {{ 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, cache-busted by your theme version (?v=<version>).

  • {{ 'path/to/image.jpg' | cdn_url }}: Resolves the URL against the configured global CDN. Paths under /theme-assets/ and /plugin-assets/ are auto-versioned (?v=<version>, from your theme's version) for cache busting; other paths (/public/, user uploads) are not. Pass an explicit true/false to override (e.g. | cdn_url: true). Bump your theme version when you change a JS/CSS asset so shoppers fetch the new file instead of a cached copy (a stored fallback busts assets on other store changes, but the version is the reliable signal — same as a plugin).

  • {{ text | truncate_words: 20 }}: Truncates text to the specified word count.

  • {{ "My Option Name" | handleize }}: Converts a string to a URL/ID-safe handle — lowercased, non-alphanumerics collapsed to - ("My Option Name"my-option-name). Use it to derive stable id/for/anchor values from variant option names, facet labels, or headings. Non-string input passes through unchanged.

  • {{ count | pluralize: "product", "products" }}: Returns the word only, not the count — pick the singular when the piped number is exactly 1, the plural otherwise. Emit the number yourself: {{ n }} {{ n | pluralize: "product", "products" }}. Both arguments are optional and default to item/items.

  • {{ description | strip_html }} / {{ text | newline_to_br }}: Strip HTML tags from a string, or convert its newlines to <br>. Handy for turning a rich-text product description into a plain-text meta description, or a plain-text field into simple HTML.

  • {{ value | default: "—" }}: Substitutes a fallback when the value is nil, "", 0, or false. Note it treats a numeric 0 and false as missing — for an "is it set?" test that keeps 0, use | present below.

  • {{ a | plus: b }}, | minus:, | times:, | divided_by:: Arithmetic. All four work in floating point{{ 10 | divided_by: 4 }} is 2.5, not 2, so format the result yourself if you need a whole number (whole results still print clean: {{ 10 | divided_by: 5 }} is 2). | divided_by: 0 returns the original value rather than erroring.

  • {{ 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>
    
  • {{ facets | sort_by_order: settings.search_facet_order }}: Reorders an array of objects by a priority list. Objects whose name matches an entry in the list (case-insensitive) come first, in the list's order; everything else follows sorted alphabetically by name. With no list (or an empty one) the whole array is sorted alphabetically — so the result is always deterministic, never the array's raw/undefined order. Pass a second argument to match on a different key: {{ product.attrs | sort_by_order: settings.product_attribute_order, "label" }}. Typical use is pinning a few search facets (e.g. Brand, Color) to the top of the sidebar while the rest fall through A–Z.

  • {{ tag | tag_key }}: Converts a tag to its canonical form — "Men's Shoes""mens-shoes". Tags match regardless of capitalization or punctuation, so use this whenever you need the canonical value of a tag (a comparison, a tag= filter parameter) and every spelling of that tag resolves to one value instead of several. Don't use handleize for this: it strips accented and non-Latin characters, so it can produce a value that matches a different tag or none at all.

  • {{ tag | tag_url }}: The link to a tag's landing page — "Men's Shoes"/tag/mens-shoes. Use this for every tag link rather than writing the path yourself. It canonicalizes the tag (as tag_key does) and follows the tag route the theme actually declares, so a theme that moves the page to /collections/{tag} keeps working. A hand-written link risks a redirect at best and a 404 at worst.

  • {{ 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_addressAfter the shipping-address fields, before the pickup locations and the shipping methods (skipped for digital carts).
checkout_after_shipping_methodAfter the shipping-method options, before Payment (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.

The same regions are also wrapped in overridable blocks — checkout_contact, checkout_delivery, checkout_address, checkout_pickup, checkout_shipping_method, checkout_payment, place_order, checkout_summary, checkout_summary_card, checkout_coupon, and checkout_totals — so a child theme can extend one region with {% extends parent %} + {% block … %}{{ block.super }}…{% endblock %} instead of copying the whole file, and a plugin can wrap it with block.<name>. See "Standard blocks in the default theme" above.

Checkout delivery flow

The default theme's checkout collects delivery in one region, in this order:

  1. A Ship / Pickup switch (checkout_delivery), shown only when the has_pickup binding is true (see "Checkout delivery bindings").
  2. In Ship mode: the address fields (checkout_address), then the Shipping method list (checkout_shipping_method). The methods stay behind a short note until the address carries a country, city, and ZIP — the same point rates are actually fetched — so the shopper never picks from rates that don't apply to where they're shipping. The note's wording is the store's: the default theme exposes it as the Checkout → Shipping Methods Placeholder setting (cleared ⇒ nothing renders there). The page hands the same string to its script (CheckoutConfig.shippingPlaceholder) so the note it swaps back in as the address changes matches the one that rendered.
  3. In Pickup mode: the pickup locations (checkout_pickup) with the selected location's address/hours/instructions. The address fields are hidden and stop being required.

Either way exactly one option is selected at a time and posts as shipping_method, so a store can offer both without the two lists competing. An option of type pickup always lands in the pickup list, never among the shipping methods — including options a plugin pushes in (see below).

Checkout errors

A failed checkout re-renders checkout.liquid with the reason in error, so {% if error | present %} is all a theme needs to show it. Two kinds of message arrive there and they read differently:

  • From the payment provider — a decline reason, an expired card, a store's own rule ("we don't ship there"). Specific, already phrased for the shopper, and worth showing as-is.
  • A problem on the store's side — the provider could not be reached, or the attempt ran out of time. These always arrive as exactly Payment failed.

That second one is one unchanging string on purpose: a theme can match it exactly and replace or extend it, without guessing at wording that varies by failure. It is deliberately bare — the alert sits directly above the pay button, so any guidance beyond it is yours to write. One thing to avoid if you do: a request that timed out may still have gone through at the provider, so don't tell the shopper their card was definitely not charged.

{% if error | present %}
  <p class="checkout-error" role="alert">
    {% if error == 'Payment failed.' %}
      We're having trouble reaching our payment provider — please try again in a moment.
    {% else %}
      {{ error | escape }}
    {% endif %}
  </p>
{% endif %}

Whatever the message, keep it in a role="alert" container and move focus to it, so a shopper using a screen reader is told the checkout failed rather than left on a page that looks unchanged.

Checkout client-side events (shipping)

Beyond the markup hooks above, the checkout page's script dispatches window events as the shopper's shipping selection changes, so a plugin can react without overriding checkout.js:

Evente.detailFires when
shipping_options_updated{ options, selectedId }options is the full list now shown (each { id, name, price, type, price_note?, pickup? }), selectedId is the id selected after the updateThe shipping-method list is (re)rendered — after an address recalc, or when a plugin injects options (see below).
shipping_option_selected{ id, price, type, priceNote }The shopper (or the page) selects a shipping method.

Both are observation only — listening never alters the checkout calculation. price_note, when a shipping option carries one, is the string the theme shows in place of the price (e.g. "—" for a freight/quote rate whose amount is still pending, so a price: 0 placeholder isn't shown as "Free"); it's set by a shipping.calculate plugin (see Plugins.md / Entities.md) and rendered natively at every price site, so a listener doesn't need to patch the display.

To push options in — e.g. a dealer/freight rate a plugin fetched early, before the address is complete — call the global renderShippingOptions(options, selectedId). It replaces the method list with your options (same shape as above), routing any type: 'pickup' option to the pickup list and the rest to the shipping methods, selects selectedId (or a sensible default) from whichever list the shopper is in, updates the totals, and then fires shipping_options_updated. Pass the full list you want shown — options you leave out are dropped, and if none of them is a pickup option the Ship/Pickup switch goes away. This is the supported way to inject selectable options; the events alone only let you observe.

A custom checkout.liquid that ships its own checkout script should dispatch these two events (and expose renderShippingOptions) if it wants plugins that depend on them to keep working; the bundled default theme already does. It should also honor an option's price_note wherever it renders a price — the default theme's template does this as {% if opt.price_note | present %}{{ opt.price_note | escape }}{% elsif opt.price == 0 %}Free{% else %}{{ opt.price | money }}{% endif %}.

⚠️ The two shipping events above are optional — a checkout that skips them merely loses shipping-aware plugins. The payment events are not: a custom checkout script must also dispatch checkout_pre_submit on submit (awaiting e.detail.promises and forwarding e.detail.meta as meta[*] form fields) and, for gateways that need a browser confirmation step, checkout_authorize. Every payment-gateway plugin renders into checkout_payment and completes the charge through that contract, so a checkout that doesn't fire them can take no payment at all. See Plugins.md → checkout_payment for the full client contract.

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 pages (account.liquid, account/profile.liquid, account/addresses.liquid, account/subscriptions.liquid, order-detail.liquid)account_top (above the sidebar layout, on every account page); account_nav_end (in snippets/account-nav.liquid, after the last nav link — inject extra sidebar links; prefer the data-driven account_menu binding below, which the sidebar also renders); account_panes_end (bottom of the /account order-history content, kept for backward compatibility). To add a whole account section, add a nav link plus your own page (a plugin route under /account/…, like the Wishlist plugin's /account/wishlist). The order-detail page (/account/order/{id}) renders inside the same sidebar layout with Order History active.
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); order_summary_end (immediately after the order-summary card, before digital downloads); and order_confirmation_end (after the order card).
order-detail.liquid (account order view)order_detail_top and order_detail_end (both scoped order).
error.liquid (404 / error page)error_top (top of the section, before the heading) and error_end (bottom of the section). The heading and the message/action link are also wrapped in overridable {% block error_title %} and {% block error_actions %} blocks.
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_items, order_totals, order_downloads, and order_tracking blocks 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" }.

Both the header Account dropdown and the account pages' sidebar (snippets/account-nav.liquid) render the built-in core links (Order History, Profile, Addresses, Email Preferences) followed by these account_menu entries, so one appended entry shows up in both places and the two menus stay in sync. The header dropdown wraps its links in the account_menu_links block and the sidebar wraps its in account_nav_links (see the block-wrappers table) — override either to reorder or trim.

The header renders three data-driven menus. All of them can carry a submenu, and a plugin can contribute to them from a template.before_render hook (see Plugins.md) — no HTML concatenation.

BindingShapeSubmenu depth
settings.main_menu{ label, url, children: [{ label, url }] }one level
account_menu{ label, url }flat
tag_menusee The tag menuany depth

So a plugin adding a top-level menu with its own submenu appends to settings.main_menu:

const menu = ctx.data.bindings.settings.main_menu || [];
menu.push({
    label: "Guides",
    url: "/guides",
    children: [
        { label: "Sizing", url: "/guides/sizing" },
        { label: "Care", url: "/guides/care" }
    ]
});
ctx.data.bindings.settings.main_menu = menu;

An entry with no children renders as a plain link. label and url are plain strings — escape them in your template ({{ item.label | escape }}), since nothing here is escaped for you.

If you render one of these yourself, keep the label a link and put the opener in its own control. The default theme gives every menu a row of <a> (the label, navigating to its own page) plus a separate <button> that opens the submenu:

<span class="nav-item">
  <a href="{{ item.url }}" aria-haspopup="true">{{ item.label | escape }}</a>
  <button type="button" aria-expanded="false" aria-controls="menu-{{ forloop.index }}"
          aria-label="Show {{ item.label | escape }} menu">›</button>
  <div class="nav-dropdown" id="menu-{{ forloop.index }}">…</div>
</span>

On desktop the panel opens on :hover/:focus-within and the button is hidden. On touch there is no hover, so the button is what opens it — and if the label doubled as the toggle, the page it points at would be unreachable on a phone.

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

account/subscriptions.liquid shows only subscriptions that are still billing by default — cancelled and paused ones are left out, because the page's job is "what am I paying for" and a long-standing customer shouldn't have to scroll past everything they ever ended. ?all=1 includes them, and include_all tells you which mode you're in.

The bundled theme's toggle is a link, not a checkbox: the state lives in the URL, so it survives the reload it causes and needs no JavaScript. It carries role="switch" and aria-checked so it still reads as a toggle to assistive tech. A checkbox in a form works too, but you have to submit it yourself and re-check it from include_all on the way back.

Pair include_all with has_subscriptions (which counts every status) for the empty state: true with an empty list means the customer's subscriptions are all paused or ended, and saying so is what stops someone who paused one from being unable to find it again.

The list is ordered by soonest charge first — what is about to take money, which is the question the page exists to answer. Subscriptions that aren't billing sort after the ones that are, whatever their dates say: cancelling or pausing leaves the last bill date in place, so a contract ended a year ago would otherwise float to the top.

The list is paged: subscriptions_has_more and subscriptions_cursor drive a "View More" control exactly like the order history's, and the pager link must carry &all=1 when the filter is on — the two modes are different queries, so a cursor from one is meaningless to the other. Use the generic data-load-more markup and it appends in place. Note that appended rows have never run any of your page's setup — the bundled theme watches the list with a MutationObserver so newly-added rows still fetch their next-cycle amount.

The subscriptions array on account/subscriptions.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.

Two further fields describe a subscription that isn't collecting:

FieldTypeMeaning
payment_errorstringWhy the last charge didn't go through, phrased for the shopper (e.g. "The saved payment method was declined."). Present only when the customer can fix it — pair it with can_pay to offer the update-card form. Blank otherwise.
on_holdbooleanAutomatic billing has stopped and the store has to resolve it. The shopper can't fix this by updating a card, so point them at support rather than a payment form.

| variable_amount | boolean | The amount changes cycle to cycle, so total is the standing amount rather than what the next renewal will actually cost. See below. |

payment_error is deliberately silent about problems the store is already retrying on its own — if it's blank, there is nothing for the shopper to do. Guard it with {% if sub.payment_error | present %}.

Showing the next amount on a variable subscription. When variable_amount is true, the store works the real figure out by asking the store's scheduling plugin, which takes long enough that it is deliberately not part of the page. Fetch it per subscription after the page renders:

GET /subscription-next-cycle?subscription_id={id}

It answers { "available": true, "total": 12000, "items": [{name, qty, price}], "skipped": false, "next_bill_at": "…" }, or { "available": false } when there is nothing to show yet — a paused contract, or an amount that couldn't be worked out. Treat available: false as "say nothing" and remove your placeholder; skipped: true means the customer won't be charged at all for that cycle. Amounts are in minor units like every other money field. The bundled theme does this in account/subscriptions.liquid — one request per subscription, so the page appears immediately and each figure lands when it is ready instead of everything waiting on the slowest one. Give the placeholder aria-live="polite" so the amount is announced when it arrives.

Customers manage a subscription with a form POST (include <csrf_tag />). Account actions may be posted to any account page path (/account, /account/profile, /account/addresses, /account/subscriptions) — the customer is redirected back to the path the form posted to, so post each form to its own page:

Endpoint / actionFieldsEffect
POST /account/subscriptions action=cancel_subscription | pause_subscription | resume_subscriptionsubscription_idCancel / pause / resume.
POST /account/subscriptions 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.

Checkout delivery bindings

Bound on the checkout page, alongside shipping_options (the calculated options, each { id, name, price, type, price_note?, pickup? }):

BindingDescription
has_shippingThe store has shipping methods to offer at all. false ⇒ skip the shipping-method region entirely. Always false on a digital-only cart.
has_pickupAt least one of the options is a pickup location — the one condition for offering the Ship / Pickup choice. Plugin-supplied pickup options count. Don't re-derive this by scanning shipping_options.
cart_is_digitalNothing in the cart ships. true ⇒ no delivery region at all (no switch, no address, no methods).

A digital-only cart is never rated: shipping_options is empty, has_shipping is false, no shipping.calculate hook runs, and cart_total (and the taxable base behind tax_amount) carry no shipping — matching what the order is actually charged. /calculate-shipping returns no options for such a cart too, so a custom checkout can't offer a method that wouldn't be billed.

Pickup is offered wherever the shopper is: it's never filtered by shipping zones, since a pickup order has no destination to match and checkout presents the choice before any address exists.

Visitor location (geo)

geo is available on every storefront page, describing where the visitor appears to be:

FieldDescription
geo.countryISO country code, e.g. US.
geo.regionRegion/state name, e.g. Texas.
geo.cityCity name.
geo.postalPostal/ZIP code.
geo.latlongApproximate coordinates as one "lat,long" string, e.g. "32.7767,-96.7970". Split it where you need numbers; a malformed pair is dropped, so anything bound here splits cleanly.

Unknown fields are absent, not empty — so {% if geo | present %} asks "do we know where this visitor is?" and each field guards the same way:

{% if geo.city | present %}<p>Shipping to {{ geo.city | escape }}? Order by 2pm for same-day pickup.</p>{% endif %}

The intended use at checkout is ordering or hiding pickup locations before the shopper has typed an address — hand geo.latlong to your own script alongside coordinates for each location:

{% if geo.latlong | present %}<script>var SHOPPER_AT = "{{ geo.latlong }}".split(",").map(Number);</script>{% endif %}

These are the same values a plugin reads from ctx.request.headers as X-Geo-* (see Plugins.md → "Visitor network data"), field for field — so a shipping.calculate hook can sort the pickup options it returns and the theme just renders them in order.

Three caveats:

  • It's a hint, not an address. Coordinates are city-level and can be wrong (VPNs, mobile carriers). Use it to sort, pre-select, or suggest — never to decide what a shopper is charged, and never as a substitute for the address they enter.
  • It's per-visitor, and pages are stored by default. Every storefront page except cart, checkout and account is stored and reused, so a page that renders geo differently for different visitors will hand one visitor's city to the next. Two ways to be correct about it: keep the variation client-side (read the values into a <script> and branch in the browser), or have the merchant turn on Pages that change by country under Settings → Developer, which makes the visitor's country part of the storage key so each country gets its own copy. The setting is off by default and only the theme knows whether it needs it — the store cannot tell by looking. Note that it splits by country: geo.city, geo.postal and geo.latlong are finer than that, so a page that varies on those still needs the client-side approach.
  • Location is unavailable in local development and theme previewgeo is simply empty there. Design for the empty case first.

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.timezoneThe store's IANA timezone (e.g. America/New_York), or empty when the merchant hasn't set one (meaning UTC). Dates in bindings are UTC instants, so pass this to client-side Intl.DateTimeFormat when you want to render a time in the store's clock rather than the visitor's.
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.payment_methodSaved payment method on file as a display hint { brand, last4, exp_month, exp_year } (e.g. show "Visa ending 4242"); absent when nothing is saved. The reusable token itself is never exposed.

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. og.image is already an absolute URL — output it as-is ({{ og.image | escape }}) and do not pipe it through cdn_url. Social crawlers can't resolve a relative path, so any image you supply yourself for og:image/twitter:image (a theme setting, say) does need | cdn_url to become absolute.
  • 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.
  • noindex: true when the page should not be indexed. Emit <meta name="robots" content="noindex, nofollow"> when it is set.

Tag pages

Each tag has a landing page at /tag/{tag}, which renders your search.liquid with that tag already applied. Every theme gets this route automatically — you don't declare it, and there is no extra template to write.

To move it, declare your own route with "data": "tag" and it replaces the built-in one:

"routes": {
    "/search":            { "template": "search.liquid", "data": "search" },
    "/collections/{tag}": { "template": "search.liquid", "data": "tag" }
}

Declaring a route claims its template, so pointing a custom tag route at search.liquid means you must declare /search alongside it — otherwise the search page loses its own route. Pointing it at a template of its own avoids this.

Always build links with {{ tag | tag_url }} (above) so they follow whatever pattern is in effect.

The tag index (tag.liquid)

tag.liquid is the index of tags, served at /tag — not the single-tag page. The two are easy to mix up:

PathTemplatedata loader
/tagtag.liquidtags
/tag/{tag}search.liquidtag

The default theme ships tag.liquid as a grid of clickable cards, one per top-level tag, showing the tag's image, name and description, with its sub-tags nested beneath.

The tags binding holds the top-level tags, each { key, name, url, desc?, image?, descendants?, close_all }. descendants is that tag's entire sub-tree at any depth, flattened depth-first, so you render a whole hierarchy with one loop instead of nesting loops per level. Each entry adds:

  • depth — 1-based level below the top-level tag.
  • open — this entry starts a new nested list.
  • closea list to iterate, one entry per nested list to close before this entry (empty when open, or when it's a sibling of the previous entry).
  • has_children — a sub-list opens off this entry. Use it to draw a submenu arrow or an expand control, rather than looking ahead in the list yourself. It reflects the markup actually emitted, so it stays correct after the size and depth limits have trimmed the tree.
  • panel_of — on an entry that opens a list (open), the key of the tag that list belongs to. Give the <ul> an id built from it and point an expand button at it with aria-controls.
  • and close_all on the tag itself — likewise a list, one entry per list still open after the last entry. has_children is on the tag too.

Those three are what let you emit correctly nested markup from a flat list:

{% for sub in tag.descendants %}
  {%- if sub.open -%}<ul>
  {%- else -%}
    {%- for i in sub.close -%}</li></ul>{%- endfor -%}
    </li>
  {%- endif -%}
  <li><a href="{{ sub.url | escape }}">{{ sub.name | escape }}</a>
{% endfor %}
{%- for i in tag.close_all -%}</li></ul>{%- endfor -%}

close and close_all are lists rather than counts on purpose — iterate them, never compare or count them. Numbers reaching a template can be floats, and a float drives neither a (1..n) range nor a > 0 test, so a version that treats these as counts fails at render time. For the same reason, don't work the depth out yourself with prev | minus: sub.depth.

Nesting the markup for real is also what exposes the hierarchy to screen readers; indenting a flat list with padding only draws it.

Nesting is capped at 10 levels as a backstop. Unlike the header menu the number of tags is not capped — listing them is the page's purpose. The page is indexable when it has at least one tag, and noindex when empty. Two settings drive it: tags_page_title and tags_page_intro.

Blocks: tags_header, tags_grid, tag_card, tag_card_media, tag_card_title, tag_card_desc, tag_card_children, tags_empty. Hooks: tags_top, tags_bottom, tags_empty.

Themes are not required to ship this page. When it is absent there is simply no /tag index — the individual /tag/{tag} pages are unaffected.

On a tag page the loader adds a tag object — tag.key (canonical form), tag.name (the merchant's label, or a tidied version of the tag), and tag.desc / tag.image when the merchant has set them. search.liquid uses these for the heading and the tag_description block. Everything else on the page — facets, sorting, price filters, "view more" — behaves exactly as it does on /search.

Filtering from a tag page deliberately returns to /search. A tag page is a stable landing page for one tag; the moment a shopper adds a facet, a sort or a price range they are back on the general results page. Keep it that way in a custom theme: build refinement links from base_query (which already carries the tag) as search.liquid does, rather than layering parameters onto the /tag/ path.

Only the bare tag page is indexable, and only once the merchant has given that tag a description in Products → Tags. Anything else — a filtered view, a different spelling of the tag — is served with noindex and left out of the sitemap.

The tag menu (tag_menu)

When the merchant enables it, tag_menu holds a ready-made two-level navigation menu built from their curated tags:

  • tag_menu.label — the menu's name (default "Tags").
  • tag_menu.url — the tag index page, present only when the theme has one; fall back with | default: '/search' so the menu never links somewhere that doesn't resolve.
  • tag_menu.items — top-level entries, exactly the same shape as the tags binding on the tag index page (see above): { key, name, url, desc?, image?, descendants?, close_all }, with depth/open/close on each descendant.

Because the shape is identical, the markup that renders the index page renders the menu too — write the nesting loop once and use it in both places. Tags with no parent become the top level; a tag given a parent nests under it, to any depth.

The only difference is bounds: the menu is on every page, so its tag count and subtree size are capped, while the index page is uncapped.

The binding is absent entirely when the feature is off or no tags are curated, so guard with {% if tag_menu | present %}.

{% if tag_menu | present %}
<nav aria-label="{{ tag_menu.label | escape }}">
  <ul>
    {% for item in tag_menu.items %}
      <li><a href="{{ item.url | escape }}">{{ item.name | escape }}</a>
        {%- for sub in item.descendants -%}
          {%- if sub.open -%}<ul>
          {%- else -%}
            {%- for i in sub.close -%}</li></ul>{%- endfor -%}
            </li>
          {%- endif -%}
          <li><a href="{{ sub.url | escape }}">{{ sub.name | escape }}</a>
        {%- endfor -%}
        {%- for i in item.close_all -%}</li></ul>{%- endfor -%}
      </li>
    {% endfor %}
  </ul>
</nav>
{% endif %}

The default theme renders this in the header as the header_tag_menu block, driven by two Navigation settings (tag_menu_enabled, tag_menu_label). Each level with sub-tags opens its own flyout panel beside its parent, at any depth.

On desktop, opening is CSS-only via :hover and :focus-within. :focus-within is what makes a flyout menu keyboard-operable without scripting — tabbing to a link deep in the tree keeps every ancestor panel open, because it matches all the way up the chain. The theme's script only flips a panel to the other side when it would run off the right edge of the window.

Below the mobile breakpoint there is no hover and no room beside the parent, so the panels stack inline and start collapsed. Each parent row splits into two targets: the tag name stays a link to that tag's page, and a separate button next to it expands its sub-tags. That applies to the menu's own label too — it points at the tag index, so it must stay a link rather than being hijacked to open the menu.

Keep that split in a custom theme. Making a name double as the toggle costs the shopper the ability to reach that page at all, and hover/focus must not open the panels on mobile, since a tap registers as a hover and one stray press would unfold the whole tree.

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

Search page bindings. search.liquid binds products (the current page) plus result_count — an approximate count of all matching products, for a "N results" heading. It's capped: past 1,000 matches it stays at 1,000, so show a "1,000+" style label at the cap rather than treating it as an exact grand total. It's 0 when unknown; fall back to products.size (the page count) then. (In the bundled default theme the count heading is opt-in — the "Show Result Count" search setting, off by default — and its "Result Count Accuracy" setting controls the cap; when off, no count is computed and result_count is 0.) Other search bindings: search_query, current_tag, current_sort, price_min, price_max, in_stock, active_refinements, facets, base_query, tag.

The tag binding. When the page is filtered to a tag, tag describes it: tag.name (what to show a shopper — the tag's display name if the merchant set one, otherwise a tidied form of the tag), tag.value (the tag exactly as it came in on the URL), tag.key (its canonical form — lower-cased, punctuation reduced to hyphens), and, when the merchant filled them in, tag.desc and tag.image. It is absent when no tag filter is active, so guard with {% if tag.name | present %}.

Prefer tag.name over current_tag for anything a shopper reads: tags match regardless of how they were capitalized or punctuated, so current_tag is whatever spelling the link happened to carry. Keep using current_tag where you're echoing the filter back into a form or link — that's what it's for. Use tag.desc for an intro paragraph on a tag's page.

Filtering search results. The results URL accepts filter query params that search.liquid reflects back as bindings so you can pre-fill and preserve them across sort/facet links: price_min / price_max (a min/max price range) and in_stock (in_stock=1 restricts results to products currently available to purchase — same availability rule as the in_stock filter, i.e. any variant with stock, unlimited stock, or back-orders enabled counts). in_stock binds as a boolean. In the bundled default theme both filters are opt-in search settings — "Show Price Filter" (on by default) and "Show In-Stock Filter" (off by default). Note the in-stock filter only takes effect once products have been re-indexed after it's first used.

search.liquid also binds page_title, already summarizing the refined view so the browser tab and <title> aren't a generic "Shop" on every filtered URL: the query (Search: shoes), else the tag (Summer Sale), else the active refinements and price filter joined together (Color: Blue, $10–$50), else Shop. Use it as-is for <title>; build your own on-page heading from search_query / current_tag / active_refinements if you want different wording there.

Account page bindings. Every account page (account.liquid, account/profile.liquid, account/addresses.liquid, account/subscriptions.liquid) binds customer (.name, .email) plus two nav-visibility flags: has_subscriptions and has_addresses — the sidebar hides the Subscriptions/Addresses links when there's nothing to show. Section data loads only on its own page: orders/orders_cursor/orders_has_more on /account, addresses on /account/addresses, subscriptions on /account/subscriptions. Plugin pages can join the same sidebar layout by returning an account_active binding from their route (see Plugins.md → "Account section pages").

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.