Liquid patterns and recipes
On this page
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; for the controller phases, see Liquid controllers guide.
Two conventions run through every recipe:
- Prefix your own working variables with an underscore (
_qty,_payload) so they never collide with a drop or a variable a parent template set. - Never pass shopper-specific state into
{% cache %}items:. If the output differs per visitor, move it out of the cache instead.
Cart and checkout
Add to cart with a quantity guard
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.
```liquid
{% before %} {%- liquid assign _qty = current_request.params.quantity | plus: 0 if _qty < 1 assign _qty = 1 endif assign _max = current_request.params.max_quantity | plus: 0 if _max > 0 and _qty > _max assign _qty = _max endif -%} {% params quantity: _qty %} {% endbefore %} ```
How it works
| plus: 0coerces the raw string parameter to a number. A blank or non-numeric value becomes0, which the guard then lifts to1.- The upper clamp only applies when a maximum was actually posted, so a form without the field is unaffected.
{% 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.
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.
Update quantities and remove a line from one cart form
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.
```liquid
{%- form “cart”, data-submit-on-change: true, data-success: “sc.cart-updated”, data-type: “json”, remote: true %} {%- for cart_item in current_cart.items %} {%- unless cart_item.reserved_product? %} <div class="SC-CartItem"> {{ cart_item.name }} {%- if cart_item.product.can_select_quantity? %} {%- endif %} Remove </div> {%- endunless %} {%- endfor %} {%- endform %} ```
How it works
- One
cartform wraps every line. The field namecart_items[<id>][quantity]is what associates an input with a Cart Item, so the whole cart submits as a single update. - Posting
0for a line removes it. A hidden input withvalue="0"is how a “remove” control inside the form works, and it is whyminmatters on the visible stepper. cart_item.delete_pathwithdata-method="delete"is the out-of-band removal route, handled by thecart_items/destroycontroller.data-submit-on-changesubmits on each stepper change;data-successnames the event that reloads the cart components (see the reloadable cart recipe below).
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.
Apply and validate a promo code
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.
```liquid
{%- if current_store.has_promotions? %} {% form “apply-promo-code” %} {%- assign field = form.fields[“code”] %} <div class="SC-Field{% if field.errors != blank %} has-error{% endif %}{% if field.required? %} required{% endif %}"> {{ field.errors | try: “messages” }} </div> {% endform %} {%- endif %} ```
How it works
current_store.has_promotions?keeps the whole block off stores with no promotions configured, so there is no dead control to explain.- Field access is
form.fields["code"], neverform.code. The latter renders blank. field.errors | try: "messages"reads the message list without raising when the field has no errors, which is the normal case on first render.- A field exposes no
label, so supply the label text yourself and point it atfield.id.
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.
A reloadable cart component
Problem — adding, removing, or repricing a line has to update the cart summary and the header cart count without a full page load.
```liquid
{%- comment %} pages/cart.liquid {% endcomment %} {%- component “cart-header”, reload: “sc.cart-updated” %} {%- component “cart”, reload: “sc.cart-updated” %}
{%- comment %} snippets/header.liquid {% endcomment %} {%- component “cart-menu”, reload: “sc.cart-updated sc.voucher-applied sc.voucher-removed” %} ```
How it works
- Each
{% component %}renders acomponents/<name>.liquidtemplate into an independently refreshable region. 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.- The events come from the forms themselves.
data-success: "sc.cart-updated"on thecartform is what firessc.cart-updated, so the form and the components that must react to it stay decoupled. - 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.
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.
Voucher list with a PIN follow-up
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.
```liquid
{%- if current_store.has_vouchers? %} {%- for voucher in current_cart.all_vouchers %} <div class="SC-Voucher-row"> {{ voucher.code }} {{ voucher.balance | money }} {% form “remove-voucher”, voucher: voucher, data-success: “sc.voucher-removed”, data-type: “json”, remote: true %} {% endform %} </div> {%- endfor %}
{%- assign _last = current_cart.all_vouchers.last %} {% form “apply-voucher”, data-success: “sc.voucher-applied”, data-type: “json”, remote: true %} {%- if _last.state == ‘requires_pin’ %} {%- assign code_field = form.fields[“code”] %} {%- assign pin_field = form.fields[“pin”] %} {%- else %} {%- assign code_field = form.fields[“code”] %} {%- endif %} {% endform %} {%- endif %} ```
How it works
current_cart.all_voucherslists every voucher attached to the cart, applied or not, so a code awaiting a PIN is still visible.- The state machine lives on the voucher: branch on
voucher.state(applied,requires_pin) rather than tracking it yourself. - 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. data-successnames 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.
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.
Customer and account
Guard a page behind login
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.
```liquid
{% before %} {%- unless current_customer %} {% redirect to: current_store.login_path, alert: “Please sign in to view this page.” %} {%- endunless %} {% endbefore %} ```
How it works
{% redirect %}inside{% before %}stops the request before the page renders. The{% after %}and{% final %}phases are skipped too.alert:sets a flash message that survives the redirect, so the sign-in page can explain why the visitor landed there.current_store.login_pathkeeps the redirect correct on a store mounted under a path prefix, which a hardcoded/auth/sign_indoes not.
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.
Gate content on membership
Problem — a promotion, price, or content block should only appear for customers on a particular membership or price book.
```liquid
{%- liquid assign _visible = false
if current_membership != blank assign _membership_name = current_membership.name | downcase | strip if _membership_name == ‘trade’ assign _visible = true endif endif
if current_pricebook != blank assign _pricebook_name = current_pricebook.name | downcase | strip if _pricebook_name == ‘wholesale’ assign _visible = true endif endif -%}
{%- if _visible %} {{ all_content_blocks[‘trade-pricing-notice’].render }} {%- endif %} ```
How it works
current_membershipandcurrent_pricebookare resolved per request from the signed-in account, so no query is needed.- Normalize with
| downcase | stripbefore comparing. Membership and price book names are merchant-entered and pick up stray case and whitespace. - Default
_visibletofalse. An unrecognized membership then hides the gated content, which is the safe direction for a price nobody meant to publish.
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.
Write a profile field back to Salesforce
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.
```liquid
{% liquid assign params = current_request.params
after if params.birthdate != blank update current_customer, field: “birthdate”, value: params.birthdate endif if params.dietary_notes != blank update current_customer, field: “dietary_notes__c”, value: params.dietary_notes endif endafter %} ```
How it works
{% update %}runs in{% after %}, once the platform has already saved the fields it owns, so your write does not race the standard update.- 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.
{% update %}modifies existing records only. There is no insert, and the record must be reachable fromcurrent_customer.- When a value is captured before the record exists, park it and write it on the next request:
{% session birthdate: current_request.params.birthdate %}inaccounts/create, then readsession_variables.birthdateinaccounts/show, apply the update, and clear it with{% session birthdate: blank %}.
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.
List a customer’s own records safely
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.
```liquid
{%- assign _identifier = current_request.local_path | split: ‘/’ | last %} {%- assign _contact = current_customer | recordize %} {%- assign _contact_sc_id = _contact.s_c__sc_id__c %}
{%- if _identifier != blank and _contact_sc_id != blank %} {% query ‘s_c__Cart__c’ as carts, s_c__sc_id__c: _identifier, s_c__contact_id__r__s_c__sc_id__c: _contact_sc_id %}
{%- if carts.size > 0 %} {%- assign _cart = carts | first | cast: ‘Cart’ %} {% render “orders/order_summary”, source: _cart %} {%- endif %} {%- else %} {%- assign _sorted = current_customer.carts | sort: ‘created_at’ | reverse %} {%- paginate _sorted by 25 %} {%- for cart in _sorted %} {{ cart.created_at | date: “%d %b %Y” }} {%- endfor %} {% render “shared/pagination-nav”, paginate: paginate %} {%- endpaginate %} {%- endif %} ```
How it works
- The
{% query %}filters on the record identifier and on the signed-in contact, joined through the relationship syntaxs_c__contact_id__r__s_c__sc_id__c. Both conditions are combined with AND, so a guessed identifier returns nothing. | cast: 'Cart'turns the raw record into the drop the summary snippet expects, giving you the drop’s computed pricing rather than raw fields.| sort: 'created_at' | reverseruns before{% paginate %}. A drop collection such ascontact.cartscarries no ordering of its own, so paginating it unsorted slices an arbitrary set.
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.
Save items to a wishlist and respond with JSON
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.
```liquid
{%- liquid default product_id: blank
assign _wishlist = current_customer.data | try: ‘wishlist_json__c’ | unescape | deserialize if _wishlist == blank new List _wishlist endif
unless _wishlist contains product_id assign _wishlist = _wishlist | push: product_id assign _stored = _wishlist | serialize update current_customer, field: ‘wishlist_json__c’, value: _stored endunless
new Map _result assign _result = _result | set_key: ‘ok’, true assign _result = _result | set_key: ‘count’, _wishlist.size assign _body = _result | serialize -%} {% respond body: _body, status: 200, layout: false %} ```
How it works
- The list is stored as serialized JSON in one custom text field, so adding an item costs no new object and no junction record.
- Session and custom-data JSON comes back HTML-escaped.
| unescape | deserializein that order is required; deserializing the escaped string yields nothing. layout: falseis essential. Without it the entire theme layout renders into a body the fetch discards, turning a small toggle into a full page render.- To render the list later, query the stored ids in one call:
{% query 'Product2' as products, sfid: _wishlist %}, then| cast: 'Product'each record.
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.
Catalog and content
Paginated product listing
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.
```liquid
{%- if current_search.count > 0 %} {% paginate current_search.results.products by current_search.per_page, window: 3 %} <div class="SC-CardGrid"> {%- for product in current_search.results.products %} {% render “products/card”, product: product %} {%- endfor %} </div>
{%- if paginate.pages > 1 %}
<nav class="SC-Pagination">
{%- for part in paginate.parts %}
{%- if part.gap? %}
<span>…</span>
{%- elsif part.current? %}
<strong>{{ part.page }}</strong>
{%- else %}
<a href="{{ part.url }}">{{ part.page }}</a>
{%- endif %}
{%- endfor %}
</nav>
{%- endif %} {% endpaginate %} {%- else %}
No products match your selection.
{%- endif %} ```
How it works
{% paginate %}is what makes a paginated collection produce rows. Without it, a{% for %}overall_productsorcurrent_search.results.productsrenders nothing while.sizestill reports the true total, which is why the bug reads as “the page is blank but the count is right”.byis positional:by 20, neverby: 20.- Test
part.gap?beforepart.current?. A gap part carries no page or URL, and callingcurrent?on one errors. current_search.countis the total across all pages, so it is the right thing to test for the empty state..sizeinside the block is the page.
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.
Filter and sort a category listing with facets
Problem — a category page needs tag, trait, price, and availability filters that survive a keyword search and produce shareable URLs.
```liquid
{%- assign fields = current_search.fields %}
```
How it works
current_search.fields.filtersis built from the current result set, so option counts already reflect the other filters in force.- The form is a
GETtocurrent_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. - Carrying
qin 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. - Guarding on
options.size == 0keeps an empty facet from rendering as a bare heading with nothing under it.
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:
```liquid
{% liquid after if current_request.params.api == ‘html’ capture _body render “products/product_category” endcapture respond body: _body, status: 200, layout: false endif endafter %} ```
The same snippet then serves both the full page and the fragment, so the two can never drift apart.
Breadcrumb trail with structured data
Problem — a trail has to render for the shopper and emit matching BreadcrumbList JSON-LD, without maintaining the hierarchy twice.
```liquid
{%- if current_breadcrumbs %}
-
{%- for breadcrumb in current_breadcrumbs %}
- {%- if forloop.last %} {{ breadcrumb.name }} {%- else %} {{ breadcrumb.name }} {%- endif %} {%- endfor %}
{%- liquid new List _items for breadcrumb in current_breadcrumbs assign _name = breadcrumb.name | default: “” | unescape assign _url = current_request.base_url | append: breadcrumb.path new Map _item assign _item = _item | set_key: “@type”, “ListItem” assign _item = _item | set_key: “position”, forloop.index assign _item = _item | set_key: “name”, _name assign _item = _item | set_key: “item”, _url assign _items = _items | push: _item endfor
new Map _list
assign _list = _list | set_key: "@context", "https://schema.org"
assign _list = _list | set_key: "@type", "BreadcrumbList"
assign _list = _list | set_key: "itemListElement", _items -%} <script type="application/ld+json">{{ _list | serialize }}</script> {%- endif %} ```
How it works
current_breadcrumbsis resolved from the page context, so one loop drives both the visible trail and the structured data.- The last crumb is the current page. Render it as text with
aria-current="page", not as a link to itself. - Structured data requires absolute URLs, so prepend
current_request.base_urlto eachbreadcrumb.path. | unescapebefore serializing. Names hold HTML entities, and an escaped"inside JSON breaks the block for every consumer.
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.
Render a menu without paying for it twice
Problem — a header menu renders on every page, and naively reading its items costs a database call whether or not the menu has any.
```liquid
{%- liquid assign _has_menu = false if menu.data.menu_tree_json__c != blank assign _has_menu = true elsif menu.menu_items.size > 0 assign _has_menu = true endif -%}
{%- if _has_menu %} {%- require “scripts/menu.js” -%}
{%- endif %} ```
How it works
- The cheap check comes first. A custom data field on the Menu record is read from data already loaded, while
menu.menu_itemshits the database, so theelsifonly runs when the cheap answer is unavailable. {% require %}sits inside the conditional, so a page whose menu is empty never loads the menu script.menu.identifiergives both the DOM id and the JavaScript hook, so client code never has to hardcode a menu name.
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.
Performance
Defer expensive content off the first render
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.
```liquid
{% component “checkout/shipping_rates/page”, defer: true, reload: “sc.cart-updated sc.voucher-applied sc.voucher-removed” %} ```
How it works
defer: truerenders the region empty and the client fetches it automatically after page load, so the slow work never sits on the critical path.reload:still applies. The region refreshes again when the cart changes, so the deferred first fetch and the later updates use the same template.- 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
reloadedflag inside the component:
```liquid
{%- if reloaded %} {% render “products/carousel”, identifier: context.container_identifier %} {%- else %} {%- context container_identifier: container_identifier %}
{%- endif %} ```
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.
Cache a public fragment safely
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.
```liquid
{%- assign _menu_expiry = store_variables[‘cache.header.menu.expires_in’] | default: 60 %} {% cache ‘menu’, items: [current_store, menu], expires_in: _menu_expiry, race_condition_ttl: 5 %} {% render “menu/menu_items”, menu: menu %} {% endcache %} ```
How it works
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.expires_inis a backstop for changes the key cannot see. Reading it from a store variable lets support tune it without a theme deploy.race_condition_ttlstops a stampede of simultaneous rebuilds when a popular fragment expires.- The cache name is required. A blank value prints
Liquid error (line N): internalinto the page.
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 and Debugging and performance in Liquid.
Query once and filter in memory
Problem — a loop that queries inside itself issues one database call per row, so a 24-product grid becomes 24 round trips.
```liquid
{%- liquid new List _product_ids for product in products assign _product_ids = _product_ids | push: product.id endfor
new Map _stock_by_product if _product_ids.size > 0 query ‘s_c__Stock__c’ as _stock_rows, s_c__product_id__c: _product_ids for _row in _stock_rows assign _stock_by_product = _stock_by_product | set_key: _row.s_c__product_id__c, _row.s_c__quantity_available__c endfor endif -%}
{%- for product in products %} {%- assign _available = _stock_by_product[product.id] | default: 0 %} {% render “products/card”, product: product, available: _available %} {%- endfor %} ```
How it works
- The first loop collects identifiers only. No query runs inside it.
- Passing a List as a filter value produces an
INmatch, so one{% query %}covers every row. - The results are indexed into a Map keyed by the identifier the render loop already has, making each lookup free.
- The
size > 0guard matters. A query with an empty filter list is an unfiltered query, which returns the whole object.
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.
Integration and events
Call an external service without slowing the page
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.
```liquid
{% final %} {%- liquid new Map _payload assign _payload = _payload | set_key: ‘event’, ‘page_view’ assign _payload = _payload | set_key: ‘path’, current_request.path assign _payload = _payload | set_key: ‘store’, current_store.id
capture _bearer
echo 'Bearer '
echo store_variables['analytics.api_key']
endcapture
assign _bearer = _bearer | strip
new Map _headers
assign _headers = _headers | set_key: 'Authorization', _bearer -%} {% api url: 'https://analytics.example.com/v1/events', method: 'post', headers: _headers, data: _payload %} {% endapi %} {% endfinal %} ```
How it works
{% final %}runs after the HTTP response has already been sent, so nothing in it can delay the page.- Any
{% api %}call inside{% final %}is automatically asynchronous. There is noresponseobject to read, and nothing you can branch on, which is exactly why it is safe. - Credentials come from
store_variables, so no key is committed to the theme and each store can hold its own. {% api %}is a block tag. Close it with{% endapi %}even when the body is empty.
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.
Consume analytics events with process_event
Problem — platform events such as a completed purchase need to reach a tag manager exactly once, without the theme guessing when they occurred.
```liquid
{%- for event in current_events %} {% process_event event %} {% endprocess_event %} {%- endfor %} ```
How it works
{% process_event %}consumes one event fromcurrent_eventsand marks it handled, so a refresh does not fire it again.- Inside the block you get
type(the event name) andevent_data(a Map), plus any object the event carries, in practiceorderon a purchase. - Outside the block, an event’s
typeandevent_databoth return nil. That is a deliberate gate, not a fault, so all reads must sit inside{% process_event %}. | jsonrenders the Map as a JavaScript literal, ready to push.
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.
Relay a server-side event through the session
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.
```liquid
{%- comment %} controllers/carts/add.liquid {% endcomment %} {% after %} {%- liquid assign _pid = current_request.params.product_id assign _qty = current_request.params.quantity | plus: 0 if _qty < 1 assign _qty = 1 endif
assign _json = blank
for item in current_cart.items
if item.product.id == _pid
unless item.reserved_product?
assign _unit_price = item.pricing.price | times: 1.0 | divided_by: item.quantity
new Map _line
assign _line = _line | set_key: "item_id", item.product.product_code | default: item.product.id
assign _line = _line | set_key: "item_name", item.name
assign _line = _line | set_key: "price", _unit_price
assign _line = _line | set_key: "quantity", _qty
new List _items
assign _items = _items | push: _line
assign _value = _unit_price | times: _qty
new Map _ecommerce
assign _ecommerce = _ecommerce | set_key: "currency", current_store.currency_code
assign _ecommerce = _ecommerce | set_key: "value", _value
assign _ecommerce = _ecommerce | set_key: "items", _items
assign _json = _ecommerce | serialize
break
endunless
endif
endfor -%} {%- if _json != blank %}
{% session cart_add_event: _json %} {%- endif %} {% endafter %} ```
Then on the next render, push it and clear it:
```liquid
{%- assign _add_event = session_variables.cart_add_event %} {%- if _add_event != blank %} {% session cart_add_event: nil %} {%- endif %} ```
How it works
- The controller runs while the added line is still identifiable, builds the payload, and parks it in a session variable.
- 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. - A removal uses the mirror of this: build the payload in the
{% before %}phase ofcart_items/destroy, while the line still exists to read. | unescapeon the way out. Session values come back HTML-escaped, and escaped quotes inside a JavaScript object literal are a syntax error.
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.
Every 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.
Was this article helpful?
Thanks for your feedback! It helps us improve our docs.