{"title":"Liquid patterns and recipes","slug":"liquid-patterns-and-recipes","url":"https://support.storeconnect.com/articles/liquid-patterns-and-recipes","url_markdown":"https://support.storeconnect.com/articles/liquid-patterns-and-recipes.md","subtitle":null,"summary":"Copy-and-adapt Liquid recipes for the jobs themes actually do: cart and checkout operations, account and login flows, paginated catalog listings, deferred and cached fragments, and outbound integrations. Use it when you know what you need to build and want a working shape rather than tag reference.","type":"Developer_Documentation","video_url":"","keywords":"liquid recipes, liquid patterns, add to cart liquid, cart component reload, promo code form, voucher form, login guard, profile write-back, paginate products, category filters, breadcrumbs, deferred component, cache fragment, n+1 query, process_event, session variables, controller final phase, storeconnect theme","last_modified":"2026-09-15T02:32:04+0000","body_markdown":"Use these recipes when you know what a storefront needs to do and want a working shape to adapt. Each one is drawn from patterns in production StoreConnect themes, states the problem it solves, and names the failure mode it avoids. For the tag syntax behind each recipe, see [Liquid tags reference](liquid-tags-reference); for the controller phases, see [Liquid controllers guide](liquid-controllers-guide).\n\nTwo conventions run through every recipe:\n\n- Prefix your own working variables with an underscore (`_qty`, `_payload`) so they never collide with a drop or a variable a parent template set.\n- Never pass shopper-specific state into `{% cache %}` `items:`. If the output differs per visitor, move it out of the cache instead.\n\n## Cart and checkout\n\n### Add to cart with a quantity guard\n\n**Problem** — a posted quantity can arrive blank, zero, or negative, and downstream arithmetic on it produces a nonsense line total or a silently dropped line.\n\n\n```liquid\n\n{% before %}\n  {%- liquid\n    assign _qty = current_request.params.quantity | plus: 0\n    if _qty \u003c 1\n      assign _qty = 1\n    endif\n    assign _max = current_request.params.max_quantity | plus: 0\n    if _max \u003e 0 and _qty \u003e _max\n      assign _qty = _max\n    endif\n  -%}\n  {% params quantity: _qty %}\n{% endbefore %}\n```\n\n\n**How it works**\n\n1. `| plus: 0` coerces the raw string parameter to a number. A blank or non-numeric value becomes `0`, which the guard then lifts to `1`.\n2. The upper clamp only applies when a maximum was actually posted, so a form without the field is unaffected.\n3. `{% params %}` hands the corrected value to the platform's add-to-cart action, so the guard runs before the line is created rather than after.\n\n**Watch out for** — do the coercion once, at the top, and reuse `_qty`. Reading `current_request.params.quantity` again further down reintroduces the raw string, and a string in a `times:` chain gives you `0`, not an error you will notice.\n\n### Update quantities and remove a line from one cart form\n\n**Problem** — a cart page needs per-line quantity stepping and per-line removal without a separate form (and a separate round trip) for each line.\n\n\n```liquid\n\n{%- form \"cart\", data-submit-on-change: true, data-success: \"sc.cart-updated\", data-type: \"json\", remote: true %}\n  {%- for cart_item in current_cart.items %}\n    {%- unless cart_item.reserved_product? %}\n      \u003cdiv class=\"SC-CartItem\"\u003e\n        \u003cspan\u003e{{ cart_item.name }}\u003c/span\u003e\n        {%- if cart_item.product.can_select_quantity? %}\n          \u003clabel for=\"qty-{{ cart_item.id }}\"\u003eQuantity\u003c/label\u003e\n          \u003cinput\n            id=\"qty-{{ cart_item.id }}\"\n            type=\"number\"\n            name=\"cart_items[{{ cart_item.id }}][quantity]\"\n            value=\"{{ cart_item.quantity }}\"\n            min=\"{{ cart_item.min_quantity }}\"\n            max=\"{{ cart_item.max_quantity }}\"\u003e\n        {%- endif %}\n        \u003ca href=\"{{ cart_item.delete_path }}\" data-method=\"delete\" aria-label=\"Remove {{ cart_item.product.name }}\"\u003eRemove\u003c/a\u003e\n      \u003c/div\u003e\n    {%- endunless %}\n  {%- endfor %}\n{%- endform %}\n```\n\n\n**How it works**\n\n1. One `cart` form wraps every line. The field name `cart_items[\u003cid\u003e][quantity]` is what associates an input with a **Cart Item**, so the whole cart submits as a single update.\n2. Posting `0` for a line removes it. A hidden input with `value=\"0\"` is how a \"remove\" control inside the form works, and it is why `min` matters on the visible stepper.\n3. `cart_item.delete_path` with `data-method=\"delete\"` is the out-of-band removal route, handled by the `cart_items/destroy` controller.\n4. `data-submit-on-change` submits on each stepper change; `data-success` names the event that reloads the cart components (see the reloadable cart recipe below).\n\n**Watch out for** — skip `reserved_product?` lines. Promotion, shipping, rounding, and tip lines appear in `current_cart.items` but carry no editable product, and rendering a quantity input for one produces a line the shopper can break. Reading `cart_item.product.name` on a reserved line gives you blank output, not an error.\n\n### Apply and validate a promo code\n\n**Problem** — a promo code field needs to show the platform's own validation message inline, next to the input, rather than as a page-level flash.\n\n\n```liquid\n\n{%- if current_store.has_promotions? %}\n  {% form \"apply-promo-code\" %}\n    {%- assign field = form.fields[\"code\"] %}\n    \u003cdiv class=\"SC-Field{% if field.errors != blank %} has-error{% endif %}{% if field.required? %} required{% endif %}\"\u003e\n      \u003clabel for=\"{{ field.id }}\"\u003ePromo code\u003c/label\u003e\n      \u003cinput\n        type=\"text\"\n        name=\"{{ field.name }}\"\n        id=\"{{ field.id }}\"\n        value=\"{{ field.value }}\"\n        class=\"SC-Field_input\"\u003e\n      \u003cspan class=\"SC-Field_error\"\u003e{{ field.errors | try: \"messages\" }}\u003c/span\u003e\n    \u003c/div\u003e\n    \u003cinput type=\"submit\" value=\"Apply\" data-disable-with=\"Applying\"\u003e\n  {% endform %}\n{%- endif %}\n```\n\n\n**How it works**\n\n1. `current_store.has_promotions?` keeps the whole block off stores with no promotions configured, so there is no dead control to explain.\n2. Field access is `form.fields[\"code\"]`, never `form.code`. The latter renders blank.\n3. `field.errors | try: \"messages\"` reads the message list without raising when the field has no errors, which is the normal case on first render.\n4. A field exposes no `label`, so supply the label text yourself and point it at `field.id`.\n\n**Watch out for** — never wrap a form in `{% cache %}`. A cached form serves one visitor's authenticity token and one visitor's error state to everyone who follows.\n\n### A reloadable cart component\n\n**Problem** — adding, removing, or repricing a line has to update the cart summary and the header cart count without a full page load.\n\n\n```liquid\n\n{%- comment %} pages/cart.liquid {% endcomment %}\n{%- component \"cart-header\", reload: \"sc.cart-updated\" %}\n{%- component \"cart\", reload: \"sc.cart-updated\" %}\n\n{%- comment %} snippets/header.liquid {% endcomment %}\n{%- component \"cart-menu\", reload: \"sc.cart-updated sc.voucher-applied sc.voucher-removed\" %}\n```\n\n\n**How it works**\n\n1. Each `{% component %}` renders a `components/\u003cname\u003e.liquid` template into an independently refreshable region.\n2. `reload:` takes a space-separated list of client event names. Any listed event re-renders that region server-side and swaps the markup in place.\n3. The events come from the forms themselves. `data-success: \"sc.cart-updated\"` on the `cart` form is what fires `sc.cart-updated`, so the form and the components that must react to it stay decoupled.\n4. Split the regions by what changes. The header count, the line list, and the totals are three components listening to the same event, so a slow region never blocks a fast one.\n\n**Watch out for** — tag parameters are initial-render inputs only and are not resent on a reload. A component that needs a value on every render must store it itself with `{% context key: value %}` and read it back from `context.key`. Keep those values minimal and non-sensitive; they travel to the client. See [Component tag reference](component-tag-reference).\n\n### Voucher list with a PIN follow-up\n\n**Problem** — a gift card or voucher may need a PIN before it can be redeemed, so the same region has to render either an entry form or a PIN form depending on the last code's state.\n\n\n```liquid\n\n{%- if current_store.has_vouchers? %}\n  {%- for voucher in current_cart.all_vouchers %}\n    \u003cdiv class=\"SC-Voucher-row\"\u003e\n      \u003cspan\u003e{{ voucher.code }}\u003c/span\u003e\n      \u003cspan\u003e{{ voucher.balance | money }}\u003c/span\u003e\n      {% form \"remove-voucher\", voucher: voucher, data-success: \"sc.voucher-removed\", data-type: \"json\", remote: true %}\n        \u003cbutton type=\"submit\"\u003eRemove\u003c/button\u003e\n      {% endform %}\n    \u003c/div\u003e\n  {%- endfor %}\n\n  {%- assign _last = current_cart.all_vouchers.last %}\n  {% form \"apply-voucher\", data-success: \"sc.voucher-applied\", data-type: \"json\", remote: true %}\n    {%- if _last.state == 'requires_pin' %}\n      {%- assign code_field = form.fields[\"code\"] %}\n      {%- assign pin_field = form.fields[\"pin\"] %}\n      \u003cinput type=\"hidden\" name=\"{{ code_field.name }}\" value=\"{{ _last.code }}\"\u003e\n      \u003clabel for=\"{{ pin_field.id }}\"\u003ePIN\u003c/label\u003e\n      \u003cinput type=\"password\" name=\"{{ pin_field.name }}\" id=\"{{ pin_field.id }}\"\u003e\n      \u003cinput type=\"submit\" value=\"Confirm\"\u003e\n    {%- else %}\n      {%- assign code_field = form.fields[\"code\"] %}\n      \u003clabel for=\"{{ code_field.id }}\"\u003eVoucher code\u003c/label\u003e\n      \u003cinput type=\"text\" name=\"{{ code_field.name }}\" id=\"{{ code_field.id }}\" value=\"{{ code_field.value }}\"\u003e\n      \u003cinput type=\"submit\" value=\"Apply\"\u003e\n    {%- endif %}\n  {% endform %}\n{%- endif %}\n```\n\n\n**How it works**\n\n1. `current_cart.all_vouchers` lists every voucher attached to the cart, applied or not, so a code awaiting a PIN is still visible.\n2. The state machine lives on the voucher: branch on `voucher.state` (`applied`, `requires_pin`) rather than tracking it yourself.\n3. The PIN branch resubmits the same form name, `apply-voucher`, with the code carried in a hidden field. There is no separate confirm form to register.\n4. `data-success` names distinct events for apply and remove, so a totals component can listen for both while the header cart listens for only the ones that change a count.\n\n**Watch out for** — render the PIN input as `type=\"password\"`, and never echo a PIN back into a `value` attribute on re-render. Also give the entry form no `value` at all once a code is applied, or the shopper can resubmit a code that is already on the cart.\n\n## Customer and account\n\n### Guard a page behind login\n\n**Problem** — a page that renders customer data must not render at all for a signed-out visitor, and must not rely on the template remembering to check.\n\n\n```liquid\n\n{% before %}\n  {%- unless current_customer %}\n    {% redirect to: current_store.login_path, alert: \"Please sign in to view this page.\" %}\n  {%- endunless %}\n{% endbefore %}\n```\n\n\n**How it works**\n\n1. `{% redirect %}` inside `{% before %}` stops the request before the page renders. The `{% after %}` and `{% final %}` phases are skipped too.\n2. `alert:` sets a flash message that survives the redirect, so the sign-in page can explain why the visitor landed there.\n3. `current_store.login_path` keeps the redirect correct on a store mounted under a path prefix, which a hardcoded `/auth/sign_in` does not.\n\n**Watch out for** — put the guard in the controller, not the page template. A guard rendered inside the page has already run the queries you were trying to protect, and a page-level `{% if %}` cannot stop a `{% respond %}` in a sibling snippet. Re-check the guard in every controller for a multi-step flow: any step can be reached directly from a bookmark, a second tab, or the back button.\n\n### Gate content on membership\n\n**Problem** — a promotion, price, or content block should only appear for customers on a particular membership or price book.\n\n\n```liquid\n\n{%- liquid\n  assign _visible = false\n\n  if current_membership != blank\n    assign _membership_name = current_membership.name | downcase | strip\n    if _membership_name == 'trade'\n      assign _visible = true\n    endif\n  endif\n\n  if current_pricebook != blank\n    assign _pricebook_name = current_pricebook.name | downcase | strip\n    if _pricebook_name == 'wholesale'\n      assign _visible = true\n    endif\n  endif\n-%}\n\n{%- if _visible %}\n  {{ all_content_blocks['trade-pricing-notice'].render }}\n{%- endif %}\n```\n\n\n**How it works**\n\n1. `current_membership` and `current_pricebook` are resolved per request from the signed-in account, so no query is needed.\n2. Normalize with `| downcase | strip` before comparing. Membership and price book names are merchant-entered and pick up stray case and whitespace.\n3. Default `_visible` to `false`. An unrecognized membership then hides the gated content, which is the safe direction for a price nobody meant to publish.\n\n**Watch out for** — `current_membership`, `current_account`, and `current_pricebook` are part of the page cache key, so a gate built on them is safe inside cached output. `session_variables` is not. A gate that reads a session value inside a cached region lets the first visitor's render decide what every later visitor sees.\n\n### Write a profile field back to Salesforce\n\n**Problem** — a storefront form needs to save a value into a Salesforce field that the standard account form does not cover, such as a birthdate or a marketing preference.\n\n\n```liquid\n\n{% liquid\n  assign params = current_request.params\n\n  after\n    if params.birthdate != blank\n      update current_customer, field: \"birthdate\", value: params.birthdate\n    endif\n    if params.dietary_notes != blank\n      update current_customer, field: \"dietary_notes__c\", value: params.dietary_notes\n    endif\n  endafter\n%}\n```\n\n\n**How it works**\n\n1. `{% update %}` runs in `{% after %}`, once the platform has already saved the fields it owns, so your write does not race the standard update.\n2. The custom field must have a Custom Data Mapping set to Read/Write. Without it the tag is a no-op and nothing in the page tells you so.\n3. `{% update %}` modifies existing records only. There is no insert, and the record must be reachable from `current_customer`.\n4. When a value is captured before the record exists, park it and write it on the next request: `{% session birthdate: current_request.params.birthdate %}` in `accounts/create`, then read `session_variables.birthdate` in `accounts/show`, apply the update, and clear it with `{% session birthdate: blank %}`.\n\n**Watch out for** — the guard is `!= blank`, not \"the parameter was sent\". A form that submits an empty field on every save would otherwise overwrite a stored value with nothing. Where clearing the field is a real intention, drop the guard deliberately and say so in a comment.\n\n### List a customer's own records safely\n\n**Problem** — an account page shows saved quotes, carts, or a custom object, and an identifier in the URL must not be enough to read another customer's record.\n\n\n```liquid\n\n{%- assign _identifier = current_request.local_path | split: '/' | last %}\n{%- assign _contact = current_customer | recordize %}\n{%- assign _contact_sc_id = _contact.s_c__sc_id__c %}\n\n{%- if _identifier != blank and _contact_sc_id != blank %}\n  {% query 's_c__Cart__c' as carts,\n      s_c__sc_id__c: _identifier,\n      s_c__contact_id__r__s_c__sc_id__c: _contact_sc_id %}\n\n  {%- if carts.size \u003e 0 %}\n    {%- assign _cart = carts | first | cast: 'Cart' %}\n    {% render \"orders/order_summary\", source: _cart %}\n  {%- endif %}\n{%- else %}\n  {%- assign _sorted = current_customer.carts | sort: 'created_at' | reverse %}\n  {%- paginate _sorted by 25 %}\n    {%- for cart in _sorted %}\n      \u003ca href=\"{{ current_store.account_path }}/carts/{{ cart.id }}\"\u003e{{ cart.created_at | date: \"%d %b %Y\" }}\u003c/a\u003e\n    {%- endfor %}\n    {% render \"shared/pagination-nav\", paginate: paginate %}\n  {%- endpaginate %}\n{%- endif %}\n```\n\n\n**How it works**\n\n1. The `{% query %}` filters on the record identifier *and* on the signed-in contact, joined through the relationship syntax `s_c__contact_id__r__s_c__sc_id__c`. Both conditions are combined with AND, so a guessed identifier returns nothing.\n2. `| cast: 'Cart'` turns the raw record into the drop the summary snippet expects, giving you the drop's computed pricing rather than raw fields.\n3. `| sort: 'created_at' | reverse` runs before `{% paginate %}`. A drop collection such as `contact.carts` carries no ordering of its own, so paginating it unsorted slices an arbitrary set.\n\n**Watch out for** — `{% query %}` is not store-scoped. Add the ownership condition yourself on every account-facing query. Leaving it off is the single most common way a storefront leaks one customer's records to another.\n\n### Save items to a wishlist and respond with JSON\n\n**Problem** — a heart button needs to toggle a product in a stored list and return a small payload the page can act on, without navigating away.\n\n\n```liquid\n\n{%- liquid\n  default product_id: blank\n\n  assign _wishlist = current_customer.data | try: 'wishlist_json__c' | unescape | deserialize\n  if _wishlist == blank\n    new List _wishlist\n  endif\n\n  unless _wishlist contains product_id\n    assign _wishlist = _wishlist | push: product_id\n    assign _stored = _wishlist | serialize\n    update current_customer, field: 'wishlist_json__c', value: _stored\n  endunless\n\n  new Map _result\n  assign _result = _result | set_key: 'ok', true\n  assign _result = _result | set_key: 'count', _wishlist.size\n  assign _body = _result | serialize\n-%}\n{% respond body: _body, status: 200, layout: false %}\n```\n\n\n**How it works**\n\n1. The list is stored as serialized JSON in one custom text field, so adding an item costs no new object and no junction record.\n2. Session and custom-data JSON comes back HTML-escaped. `| unescape | deserialize` in that order is required; deserializing the escaped string yields nothing.\n3. `layout: false` is essential. Without it the entire theme layout renders into a body the fetch discards, turning a small toggle into a full page render.\n4. To render the list later, query the stored ids in one call: `{% query 'Product2' as products, sfid: _wishlist %}`, then `| cast: 'Product'` each record.\n\n**Watch out for** — always pass `status:` explicitly on `{% respond %}`. Set it from a variable when the outcome varies, for example `assign _status = 401` for an unauthenticated caller and `assign _status = 200` on success, then `{% respond body: _body, status: _status, layout: false %}`. Guard the whole block on `current_customer != blank` as well; a signed-out visitor has no record to write to and the update silently does nothing.\n\n## Catalog and content\n\n### Paginated product listing\n\n**Problem** — a listing page needs to render a page of products with working page links, and a plain `{% for %}` over a product collection renders nothing at all.\n\n\n```liquid\n\n{%- if current_search.count \u003e 0 %}\n  {% paginate current_search.results.products by current_search.per_page, window: 3 %}\n    \u003cdiv class=\"SC-CardGrid\"\u003e\n      {%- for product in current_search.results.products %}\n        {% render \"products/card\", product: product %}\n      {%- endfor %}\n    \u003c/div\u003e\n\n    {%- if paginate.pages \u003e 1 %}\n      \u003cnav class=\"SC-Pagination\"\u003e\n        {%- for part in paginate.parts %}\n          {%- if part.gap? %}\n            \u003cspan\u003e\u0026hellip;\u003c/span\u003e\n          {%- elsif part.current? %}\n            \u003cstrong\u003e{{ part.page }}\u003c/strong\u003e\n          {%- else %}\n            \u003ca href=\"{{ part.url }}\"\u003e{{ part.page }}\u003c/a\u003e\n          {%- endif %}\n        {%- endfor %}\n      \u003c/nav\u003e\n    {%- endif %}\n  {% endpaginate %}\n{%- else %}\n  \u003cp\u003eNo products match your selection.\u003c/p\u003e\n{%- endif %}\n```\n\n\n**How it works**\n\n1. `{% paginate %}` is what makes a paginated collection produce rows. Without it, a `{% for %}` over `all_products` or `current_search.results.products` renders nothing while `.size` still reports the true total, which is why the bug reads as \"the page is blank but the count is right\".\n2. `by` is positional: `by 20`, never `by: 20`.\n3. Test `part.gap?` before `part.current?`. A gap part carries no page or URL, and calling `current?` on one errors.\n4. `current_search.count` is the total across all pages, so it is the right thing to test for the empty state. `.size` inside the block is the page.\n\n**Watch out for** — two paginated regions on one page share the default query parameter and move together. Give at least one an explicit `as:` name, for example `{% paginate reviews by 10, as: 'reviews_page' %}`. See [Paginate tag reference](paginate-tag-reference).\n\n### Filter and sort a category listing with facets\n\n**Problem** — a category page needs tag, trait, price, and availability filters that survive a keyword search and produce shareable URLs.\n\n\n```liquid\n\n{%- assign fields = current_search.fields %}\n\n\u003cform action=\"{{ current_search.path }}\" method=\"get\" data-filters\u003e\n  {%- if current_request.params.q != blank %}\n    \u003cinput type=\"hidden\" name=\"q\" value=\"{{ current_request.params.q | escape }}\"\u003e\n  {%- endif %}\n\n  {%- assign _tags = fields.filters.tags %}\n  {%- unless _tags == blank or _tags.options.size == 0 %}\n    \u003cfieldset\u003e\n      \u003clegend\u003eTags\u003c/legend\u003e\n      {%- for option in _tags.options %}\n        \u003clabel\u003e\n          \u003cinput\n            type=\"checkbox\"\n            name=\"{{ _tags.name }}\"\n            value=\"{{ option.value }}\"\n            {% if option.selected? %}checked{% endif %}\u003e\n          {{ option.label }} ({{ option.count }})\n        \u003c/label\u003e\n      {%- endfor %}\n    \u003c/fieldset\u003e\n  {%- endunless %}\n\n  \u003cbutton type=\"submit\"\u003eApply filters\u003c/button\u003e\n\u003c/form\u003e\n```\n\n\n**How it works**\n\n1. `current_search.fields.filters` is built from the current result set, so option counts already reflect the other filters in force.\n2. The form is a `GET` to `current_search.path`. Every filter state is therefore a real URL the shopper can bookmark, share, and reach with the back button, and search engines can crawl.\n3. Carrying `q` in a hidden field is what stops a filter click from discarding the keyword search. Escape it: it is shopper-supplied text going into an attribute.\n4. Guarding on `options.size == 0` keeps an empty facet from rendering as a bare heading with nothing under it.\n\n**Watch out for** — to swap the results without a full page load, add a controller action rather than duplicating the listing markup. In `controllers/product_categories/show.liquid`, capture the results snippet and return it on its own when a flag is present:\n\n\n```liquid\n\n{% liquid\n  after\n    if current_request.params.api == 'html'\n      capture _body\n        render \"products/product_category\"\n      endcapture\n      respond body: _body, status: 200, layout: false\n    endif\n  endafter\n%}\n```\n\n\nThe same snippet then serves both the full page and the fragment, so the two can never drift apart.\n\n### Breadcrumb trail with structured data\n\n**Problem** — a trail has to render for the shopper and emit matching `BreadcrumbList` JSON-LD, without maintaining the hierarchy twice.\n\n\n```liquid\n\n{%- if current_breadcrumbs %}\n  \u003col class=\"SC-Breadcrumb\"\u003e\n    {%- for breadcrumb in current_breadcrumbs %}\n      \u003cli\u003e\n        {%- if forloop.last %}\n          \u003cspan aria-current=\"page\"\u003e{{ breadcrumb.name }}\u003c/span\u003e\n        {%- else %}\n          \u003ca href=\"{{ breadcrumb.path }}\"\u003e{{ breadcrumb.name }}\u003c/a\u003e\n        {%- endif %}\n      \u003c/li\u003e\n    {%- endfor %}\n  \u003c/ol\u003e\n\n  {%- liquid\n    new List _items\n    for breadcrumb in current_breadcrumbs\n      assign _name = breadcrumb.name | default: \"\" | unescape\n      assign _url = current_request.base_url | append: breadcrumb.path\n      new Map _item\n      assign _item = _item | set_key: \"@type\", \"ListItem\"\n      assign _item = _item | set_key: \"position\", forloop.index\n      assign _item = _item | set_key: \"name\", _name\n      assign _item = _item | set_key: \"item\", _url\n      assign _items = _items | push: _item\n    endfor\n\n    new Map _list\n    assign _list = _list | set_key: \"@context\", \"https://schema.org\"\n    assign _list = _list | set_key: \"@type\", \"BreadcrumbList\"\n    assign _list = _list | set_key: \"itemListElement\", _items\n  -%}\n  \u003cscript type=\"application/ld+json\"\u003e{{ _list | serialize }}\u003c/script\u003e\n{%- endif %}\n```\n\n\n**How it works**\n\n1. `current_breadcrumbs` is resolved from the page context, so one loop drives both the visible trail and the structured data.\n2. The last crumb is the current page. Render it as text with `aria-current=\"page\"`, not as a link to itself.\n3. Structured data requires absolute URLs, so prepend `current_request.base_url` to each `breadcrumb.path`.\n4. `| unescape` before serializing. Names hold HTML entities, and an escaped `\u0026quot;` inside JSON breaks the block for every consumer.\n\n**Watch out for** — `{% new Map %}` and `| serialize` silently drop a Map nested directly inside a Map. Build nested structures as a Map inside a List, as the `itemListElement` above does, and check the rendered JSON in the browser rather than assuming it is well-formed.\n\n### Render a menu without paying for it twice\n\n**Problem** — a header menu renders on every page, and naively reading its items costs a database call whether or not the menu has any.\n\n\n```liquid\n\n{%- liquid\n  assign _has_menu = false\n  if menu.data.menu_tree_json__c != blank\n    assign _has_menu = true\n  elsif menu.menu_items.size \u003e 0\n    assign _has_menu = true\n  endif\n-%}\n\n{%- if _has_menu %}\n  {%- require \"scripts/menu.js\" -%}\n  \u003cul class=\"SC-Menu tier1\" id=\"SC-Menu-{{ menu.identifier }}\" data-menu=\"{{ menu.identifier }}\"\u003e\n    {% render \"menu/menu_items\", menu: menu %}\n  \u003c/ul\u003e\n{%- endif %}\n```\n\n\n**How it works**\n\n1. The cheap check comes first. A custom data field on the **Menu** record is read from data already loaded, while `menu.menu_items` hits the database, so the `elsif` only runs when the cheap answer is unavailable.\n2. `{% require %}` sits inside the conditional, so a page whose menu is empty never loads the menu script.\n3. `menu.identifier` gives both the DOM id and the JavaScript hook, so client code never has to hardcode a menu name.\n\n**Watch out for** — resist rendering the tree recursively without a depth limit. A merchant can nest **Menu Items** arbitrarily deep, and each level is a collection read. Cap the depth in your `menu_items` snippet and render the rest as a link to the section page.\n\n## Performance\n\n### Defer expensive content off the first render\n\n**Problem** — shipping rates, recommendation panels, and stock lookups are slow and hold up the whole page, but nothing on the page fires an event that would refresh them.\n\n\n```liquid\n\n{% component \"checkout/shipping_rates/page\",\n    defer: true,\n    reload: \"sc.cart-updated sc.voucher-applied sc.voucher-removed\" %}\n```\n\n\n**How it works**\n\n1. `defer: true` renders the region empty and the client fetches it automatically after page load, so the slow work never sits on the critical path.\n2. `reload:` still applies. The region refreshes again when the cart changes, so the deferred first fetch and the later updates use the same template.\n3. Where you want a visible placeholder rather than an empty gap, render a skeleton on the first pass and the real content on the reload, branching on the `reloaded` flag inside the component:\n\n\n```liquid\n\n{%- if reloaded %}\n  {% render \"products/carousel\", identifier: context.container_identifier %}\n{%- else %}\n  {%- context container_identifier: container_identifier %}\n  \u003cdiv class=\"skeleton\" aria-hidden=\"true\"\u003e\u003c/div\u003e\n{%- endif %}\n```\n\n\n**Watch out for** — `defer:` and `lazy:` are not interchangeable. `lazy:` renders empty and fills on the **first firing of a `reload:` event**, with no automatic fetch, so `lazy:` without `reload:` renders empty forever. Use `lazy:` only for content behind a real trigger, such as a drawer the shopper opens, and `defer:` for everything that simply has to load on its own.\n\n### Cache a public fragment safely\n\n**Problem** — a fragment that is identical for every visitor is rebuilt on every request, and caching it wrongly serves one visitor's content to another.\n\n\n```liquid\n\n{%- assign _menu_expiry = store_variables['cache.header.menu.expires_in'] | default: 60 %}\n{% cache 'menu', items: [current_store, menu], expires_in: _menu_expiry, race_condition_ttl: 5 %}\n  {% render \"menu/menu_items\", menu: menu %}\n{% endcache %}\n```\n\n\n**How it works**\n\n1. `items:` lists everything stable that changes the markup: the store, the record being rendered, and any display option passed in. Change one and the key changes, so an edit in Salesforce shows up without a manual purge.\n2. `expires_in` is a backstop for changes the key cannot see. Reading it from a store variable lets support tune it without a theme deploy.\n3. `race_condition_ttl` stops a stampede of simultaneous rebuilds when a popular fragment expires.\n4. The cache name is required. A blank value prints `Liquid error (line N): internal` into the page.\n\n**Watch out for** — never cache forms, component containers, checkout or payment content, pricing, cart or account state, consent controls, flash messages, or anything else specific to one visitor or one request. Adding customer, cart, price book, path, or query state to `items:` does not make personalized output safe; it multiplies the cache and still risks a wrong hit. If part of a region is per-visitor, move that part outside the cache and leave the public shell inside. See [Cache tag reference](cache-tag-reference) and [Debugging and performance in Liquid](liquid-debugging-and-performance).\n\n### Query once and filter in memory\n\n**Problem** — a loop that queries inside itself issues one database call per row, so a 24-product grid becomes 24 round trips.\n\n\n```liquid\n\n{%- liquid\n  new List _product_ids\n  for product in products\n    assign _product_ids = _product_ids | push: product.id\n  endfor\n\n  new Map _stock_by_product\n  if _product_ids.size \u003e 0\n    query 's_c__Stock__c' as _stock_rows, s_c__product_id__c: _product_ids\n    for _row in _stock_rows\n      assign _stock_by_product = _stock_by_product | set_key: _row.s_c__product_id__c, _row.s_c__quantity_available__c\n    endfor\n  endif\n-%}\n\n{%- for product in products %}\n  {%- assign _available = _stock_by_product[product.id] | default: 0 %}\n  {% render \"products/card\", product: product, available: _available %}\n{%- endfor %}\n```\n\n\n**How it works**\n\n1. The first loop collects identifiers only. No query runs inside it.\n2. Passing a List as a filter value produces an `IN` match, so one `{% query %}` covers every row.\n3. The results are indexed into a Map keyed by the identifier the render loop already has, making each lookup free.\n4. The `size \u003e 0` guard matters. A query with an empty filter list is an unfiltered query, which returns the whole object.\n\n**Watch out for** — the same trap appears with a junction object, where duplicate rows would double-count a value. Deduplicate while building the Map, using a second Map as a seen-set, rather than trusting the query to return one row per key.\n\n## Integration and events\n\n### Call an external service without slowing the page\n\n**Problem** — a request should notify an external system, and doing it inline adds the third party's latency (and its outages) to the shopper's page load.\n\n\n```liquid\n\n{% final %}\n  {%- liquid\n    new Map _payload\n    assign _payload = _payload | set_key: 'event', 'page_view'\n    assign _payload = _payload | set_key: 'path', current_request.path\n    assign _payload = _payload | set_key: 'store', current_store.id\n\n    capture _bearer\n      echo 'Bearer '\n      echo store_variables['analytics.api_key']\n    endcapture\n    assign _bearer = _bearer | strip\n\n    new Map _headers\n    assign _headers = _headers | set_key: 'Authorization', _bearer\n  -%}\n  {% api url: 'https://analytics.example.com/v1/events', method: 'post', headers: _headers, data: _payload %}\n  {% endapi %}\n{% endfinal %}\n```\n\n\n**How it works**\n\n1. `{% final %}` runs after the HTTP response has already been sent, so nothing in it can delay the page.\n2. Any `{% api %}` call inside `{% final %}` is automatically asynchronous. There is no `response` object to read, and nothing you can branch on, which is exactly why it is safe.\n3. Credentials come from `store_variables`, so no key is committed to the theme and each store can hold its own.\n4. `{% api %}` is a block tag. Close it with `{% endapi %}` even when the body is empty.\n\n**Watch out for** — use `{% final %}` only for work whose result you do not need. When the render depends on the answer, for example a live shipping quote, the call has to run in `{% before %}` where a `response` is available, and you must then handle a slow or failing third party yourself with a guard on the response and a usable fallback. Never put a call in `{% final %}` and assume it succeeded.\n\n### Consume analytics events with process_event\n\n**Problem** — platform events such as a completed purchase need to reach a tag manager exactly once, without the theme guessing when they occurred.\n\n\n```liquid\n\n{%- for event in current_events %}\n  {% process_event event %}\n    \u003cscript\u003e\n      window.dataLayer = window.dataLayer || [];\n      {%- case type %}\n        {%- when \"purchase\" %}\n          window.dataLayer.push({ event: \"purchase\", ecommerce: {{ event_data | json }} });\n        {%- when \"cart.add\" %}\n          window.dataLayer.push({ event: \"add_to_cart\", ecommerce: {{ event_data | json }} });\n      {%- endcase %}\n    \u003c/script\u003e\n  {% endprocess_event %}\n{%- endfor %}\n```\n\n\n**How it works**\n\n1. `{% process_event %}` consumes one event from `current_events` and marks it handled, so a refresh does not fire it again.\n2. Inside the block you get `type` (the event name) and `event_data` (a Map), plus any object the event carries, in practice `order` on a purchase.\n3. Outside the block, an event's `type` and `event_data` both return nil. That is a deliberate gate, not a fault, so all reads must sit inside `{% process_event %}`.\n4. `| json` renders the Map as a JavaScript literal, ready to push.\n\n**Watch out for** — `event_type` and `order_number` do not exist. Older examples use them and they render blank under strict variables, producing an event that reaches the analytics provider with empty fields rather than an error you would notice. Use `type` and read anything else from `event_data`.\n\n### Relay a server-side event through the session\n\n**Problem** — an event has to be reported on the page the shopper lands on, but the fact worth reporting is only known during the POST that redirected them there.\n\n\n```liquid\n\n{%- comment %} controllers/carts/add.liquid {% endcomment %}\n{% after %}\n  {%- liquid\n    assign _pid = current_request.params.product_id\n    assign _qty = current_request.params.quantity | plus: 0\n    if _qty \u003c 1\n      assign _qty = 1\n    endif\n\n    assign _json = blank\n    for item in current_cart.items\n      if item.product.id == _pid\n        unless item.reserved_product?\n          assign _unit_price = item.pricing.price | times: 1.0 | divided_by: item.quantity\n\n          new Map _line\n          assign _line = _line | set_key: \"item_id\", item.product.product_code | default: item.product.id\n          assign _line = _line | set_key: \"item_name\", item.name\n          assign _line = _line | set_key: \"price\", _unit_price\n          assign _line = _line | set_key: \"quantity\", _qty\n\n          new List _items\n          assign _items = _items | push: _line\n\n          assign _value = _unit_price | times: _qty\n\n          new Map _ecommerce\n          assign _ecommerce = _ecommerce | set_key: \"currency\", current_store.currency_code\n          assign _ecommerce = _ecommerce | set_key: \"value\", _value\n          assign _ecommerce = _ecommerce | set_key: \"items\", _items\n          assign _json = _ecommerce | serialize\n          break\n        endunless\n      endif\n    endfor\n  -%}\n  {%- if _json != blank %}\n    {% session cart_add_event: _json %}\n  {%- endif %}\n{% endafter %}\n```\n\n\nThen on the next render, push it and clear it:\n\n\n```liquid\n\n{%- assign _add_event = session_variables.cart_add_event %}\n{%- if _add_event != blank %}\n  \u003cscript\u003e\n    window.dataLayer = window.dataLayer || [];\n    window.dataLayer.push({ ecommerce: null });\n    window.dataLayer.push({ event: \"add_to_cart\", ecommerce: {{ _add_event | unescape }} });\n  \u003c/script\u003e\n  {% session cart_add_event: nil %}\n{%- endif %}\n```\n\n\n**How it works**\n\n1. The controller runs while the added line is still identifiable, builds the payload, and parks it in a session variable.\n2. The destination page reads the variable, renders the push server-side on load, then clears it with `{% session cart_add_event: nil %}` so it fires exactly once and does not re-fire on refresh.\n3. A removal uses the mirror of this: build the payload in the `{% before %}` phase of `cart_items/destroy`, while the line still exists to read.\n4. `| unescape` on the way out. Session values come back HTML-escaped, and escaped quotes inside a JavaScript object literal are a syntax error.\n\n**Watch out for** — pushing from a click handler on a control that then reloads destroys the push before the analytics provider reads it. That is the failure this pattern exists to fix. Also keep session payloads small and free of personal data; they are stored per visitor and travel into rendered markup.\n\nEvery recipe here is a starting shape, not a drop-in file. Once adapted, confirm it by the signal the recipe names: the cart component swaps without a page load, the listing page shows rows and page links, the deferred region fills in after first paint, and the analytics event appears once in the browser's `dataLayer`."}