<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ baslefeber - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ baslefeber - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Fri, 03 Jul 2026 20:07:24 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/author/baslefeber/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build an AJAX Cart Drawer in Shopify (the 2026 Way) ]]>
                </title>
                <description>
                    <![CDATA[ Add a product to a Shopify store the default way and the whole page reloads. The shopper is looking at your product, they click Add to cart, and the browser throws the page away and rebuilds it. On a  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/shopify-ajax-cart-drawer/</link>
                <guid isPermaLink="false">6a47d58ad3e40bd062df32d5</guid>
                
                    <category>
                        <![CDATA[ shopify ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Shopify Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ecommerce ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ baslefeber ]]>
                </dc:creator>
                <pubDate>Fri, 03 Jul 2026 15:30:18 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/16d974f7-5940-4fef-a053-6e1790ed6107.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Add a product to a Shopify store the default way and the whole page reloads. The shopper is looking at your product, they click <strong>Add to cart</strong>, and the browser throws the page away and rebuilds it.</p>
<p>On a slow connection, that's two or three seconds of blank screen. Sometimes they even land on <code>/cart</code>, a full page away from the thing they were about to buy, and the momentum is gone.</p>
<p>A cart drawer fixes that. It's the slide-out panel that appears when someone adds an item: the cart updates, a Checkout button sits right there, and the shopper never leaves the page they were on.</p>
<p>Nearly every high-converting Shopify store has one, and you don't need an app to build it. You need two Ajax endpoints and less than a hundred lines of JavaScript.</p>
<p>The catch is that most tutorials, and most AI coding tools, build it the fragile way. They keep a running count in a JavaScript variable and update the DOM by hand. That looks fine in a demo and falls apart the first time two lines merge into one, a discount lands, or a variant sells out between the click and the checkout.</p>
<p>This guide builds it the way a senior developer does, where the server is always the source of truth. Then it shows the 2026 addition that lets your drawer speak the same language as apps and AI shopping agents.</p>
<p>If you want to code along, open a development theme you can edit and build each piece as we go. Everything here runs on a stock Online Store 2.0 theme (Horizon or Dawn) with no app installed.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a424d9ed27378c517882227/a5246d30-ff15-4759-b6e5-9f38741fef3f.gif" alt="The finished cart drawer: a shopper clicks Add to cart and a panel slides in over the storefront with no page reload" style="display:block;margin:0 auto" width="560" height="339" loading="lazy">

<p><em>The finished drawer: add to cart, no page reload, the panel slides in showing the real cart.</em></p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-youll-build">What You'll Build</a></p>
</li>
<li><p><a href="#heading-step-1-the-drawer-markup">Step 1: The Drawer Markup</a></p>
</li>
<li><p><a href="#heading-step-2-add-to-cart-without-a-reload">Step 2: Add to Cart Without a Reload</a></p>
</li>
<li><p><a href="#heading-step-3-render-the-drawer-from-the-servers-truth">Step 3: Render the Drawer from the Server's Truth</a></p>
</li>
<li><p><a href="#heading-step-4-quantity-plus-and-minus-with-event-delegation">Step 4: Quantity Plus and Minus, with Event Delegation</a></p>
</li>
<li><p><a href="#heading-step-5-remove-a-line-and-clear-the-cart">Step 5: Remove a Line, and Clear the Cart</a></p>
</li>
<li><p><a href="#heading-step-6-re-rendering-with-the-section-rendering-api-the-version-youd-ship">Step 6: Re-Rendering with the Section Rendering API (the Version You'd Ship)</a></p>
</li>
<li><p><a href="#heading-the-2026-upgrade-standard-storefront-events-and-actions">The 2026 Upgrade: Standard Storefront Events and Actions</a></p>
</li>
<li><p><a href="#heading-why-this-matters">Why This Matters</a></p>
</li>
<li><p><a href="#heading-the-complete-files">The Complete Files</a></p>
</li>
<li><p><a href="#heading-wrap-up">Wrap Up</a></p>
</li>
</ul>
<h2 id="heading-what-youll-build">What You'll Build</h2>
<p>By the end, you'll have a drawer that:</p>
<ul>
<li><p>adds to cart over Ajax with no page reload</p>
</li>
<li><p>re-reads the cart after every change and treats that response as the truth</p>
</li>
<li><p>renders its own contents and slides open</p>
</li>
<li><p>handles quantity plus/minus with a single delegated listener</p>
</li>
<li><p>removes a line and clears the cart</p>
</li>
<li><p>and, as the final upgrade, exposes itself through Shopify's new standard storefront actions so apps and AI agents can drive it</p>
</li>
</ul>
<h3 id="heading-prerequisites">Prerequisites</h3>
<ul>
<li><p>A Shopify theme you can edit (examples assume Online Store 2.0, such as Horizon or Dawn)</p>
</li>
<li><p>Comfort with <code>fetch</code> and promises</p>
</li>
<li><p>Basic Liquid</p>
</li>
<li><p>No app, no framework, no build step</p>
</li>
</ul>
<h2 id="heading-step-1-the-drawer-markup">Step 1: The Drawer Markup</h2>
<p>You'll build the drawer as a section so it renders on every page, and then render the current cart <strong>server-side with Liquid</strong> before any JavaScript runs.</p>
<p>This matters: if a shopper arrives with items already in their cart, the drawer is correct on first paint, and your JavaScript only has to update it after changes. That's progressive enhancement, and it is the first thing a homemade build skips.</p>
<p>The <code>data-*</code> attributes below are the contract between the markup and the script. Everything the JavaScript touches, it finds through one of these attributes, never through a class name or a tag position.</p>
<pre><code class="language-liquid">{%- comment -%} sections/cart-drawer.liquid {%- endcomment -%}
&lt;button type="button" class="cart-toggle" data-cart-toggle&gt;
  Cart &lt;span class="cart-count" data-cart-count&gt;{{ cart.item_count }}&lt;/span&gt;
&lt;/button&gt;

&lt;aside class="drawer" data-drawer aria-label="Cart" aria-hidden="true"&gt;
  &lt;div class="drawer__head"&gt;
    &lt;h2&gt;Your cart&lt;/h2&gt;
    &lt;button type="button" class="drawer__close" data-drawer-close aria-label="Close cart"&gt;&amp;times;&lt;/button&gt;
  &lt;/div&gt;

  &lt;div class="drawer__body"&gt;
    &lt;p class="drawer__empty" data-drawer-empty {% if cart.item_count &gt; 0 %}style="display:none"{% endif %}&gt;
      Your cart is empty.
    &lt;/p&gt;
    &lt;ul class="drawer__items" data-drawer-items&gt;
      {%- for item in cart.items -%}
        &lt;li class="drawer__item" data-line data-line-key="{{ item.key }}" data-quantity="{{ item.quantity }}"&gt;
          {{ item.image | image_url: width: 96 | image_tag: class: 'drawer__item-img', loading: 'lazy', alt: item.product.title }}
          &lt;span class="drawer__item-title"&gt;{{ item.product.title }}&lt;/span&gt;
          &lt;span class="drawer__item-price"&gt;{{ item.final_line_price | money }}&lt;/span&gt;
        &lt;/li&gt;
      {%- endfor -%}
    &lt;/ul&gt;
  &lt;/div&gt;

  &lt;div class="drawer__foot"&gt;
    &lt;div class="drawer__subtotal"&gt;
      &lt;span&gt;Subtotal&lt;/span&gt;
      &lt;span data-cart-subtotal&gt;{{ cart.total_price | money }}&lt;/span&gt;
    &lt;/div&gt;
    &lt;p class="drawer__ship"&gt;Shipping &amp;amp; taxes calculated at checkout.&lt;/p&gt;
    &lt;a href="{{ routes.cart_url }}" class="drawer__checkout"&gt;Checkout&lt;/a&gt;
  &lt;/div&gt;
&lt;/aside&gt;
&lt;div class="drawer__scrim" data-drawer-scrim&gt;&lt;/div&gt;
</code></pre>
<p>Two details worth pausing on here: the line item uses <code>item.final_line_price</code>, not <code>item.line_price</code>. Both exist on the cart, but <code>final_line_price</code> reflects line-level discounts, so it's the number the shopper will actually be charged. And each <code>&lt;li&gt;</code> carries <code>data-line-key</code> and <code>data-quantity</code>, which the quantity and remove controls will read later.</p>
<p>The add button lives on your product card or product page and carries the variant id straight from Liquid, so the right variant is added every time:</p>
<pre><code class="language-liquid">{%- comment -%} in the product card / PDP {%- endcomment -%}
&lt;button
  type="button"
  class="pdp__add"
  data-add
  data-variant-id="{{ product.selected_or_first_available_variant.id }}"
&gt;
  Add to cart
&lt;/button&gt;
</code></pre>
<h2 id="heading-step-2-add-to-cart-without-a-reload">Step 2: Add to Cart Without a Reload</h2>
<p>Here is the whole pattern in one sentence: mutate the cart, then re-read it, then render and open. Written out, that is <code>POST /cart/add.js</code> to add the variant, <code>GET /cart.js</code> to read the entire cart back, and then paint the drawer from what came back.</p>
<pre><code class="language-javascript">// assets/cart-drawer.js
document.querySelectorAll("[data-add]").forEach(function (btn) {
  btn.addEventListener("click", function () {
    var id = Number(btn.getAttribute("data-variant-id"));
    fetch("/cart/add.js", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ id: id, quantity: 1 }),
    })
      .then(function (r) { return r.json(); })
      .then(function () { return refresh(); })  // re-read /cart.js
      .then(openDrawer);                         // then slide it in
  });
});
</code></pre>
<p>Why re-read the cart when <code>/cart/add.js</code> already returned something? Because the response to <code>add.js</code> describes the line you just added, not the whole cart, and the whole cart is what the drawer shows.</p>
<p>More importantly, Shopify is the one that decides the final cart. It might merge your new line into an existing one, apply an automatic discount, or reject a sold-out variant. The only way to be sure the drawer matches reality is to ask for reality. That's what <code>refresh()</code> does, and it's the single function in this whole file that's allowed to touch the cart UI:</p>
<pre><code class="language-javascript">function refresh() {
  return fetch("/cart.js").then(function (r) { return r.json(); }).then(render);
}
</code></pre>
<p>Both endpoints here (<code>/cart/add.js</code> and <code>/cart.js</code>) are part of Shopify's <a href="https://shopify.dev/docs/api/ajax">Ajax API</a>, which is available on every storefront with no setup.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a424d9ed27378c517882227/c58705c9-daa0-4650-980b-0db4e3facaa7.png" alt="The three-step loop: the shopper clicks Add to cart, the script posts to /cart/add.js, then re-reads /cart.js, then renders and opens the drawer" style="display:block;margin:0 auto" width="3666" height="2062" loading="lazy">

<p><em>Mutate, then re-read, then render. The same loop runs for add, for quantity changes, and for remove.</em></p>
<h2 id="heading-step-3-render-the-drawer-from-the-servers-truth">Step 3: Render the Drawer from the Server's Truth</h2>
<p><code>render(cart)</code> takes a <code>/cart.js</code> response and paints the drawer from it. Notice what it doesn't do: it never adds up a total or increments a counter. It reads <code>item_count</code> and <code>total_price</code> straight off the object Shopify handed back.</p>
<pre><code class="language-javascript">function money(cents) {
  return "$" + (cents / 100).toFixed(2);
}

function render(cart) {
  document.querySelector("[data-cart-count]").textContent = cart.item_count;
  document.querySelector("[data-cart-subtotal]").textContent = money(cart.total_price);

  var itemsEl = document.querySelector("[data-drawer-items]");
  var emptyEl = document.querySelector("[data-drawer-empty]");
  itemsEl.innerHTML = "";
  if (!cart.items.length) { emptyEl.style.display = "block"; return; }
  emptyEl.style.display = "none";

  cart.items.forEach(function (line) {
    var li = document.createElement("li");
    li.className = "drawer__item";
    li.setAttribute("data-line", "");
    li.setAttribute("data-line-key", line.key);
    li.setAttribute("data-quantity", line.quantity);
    li.innerHTML =
      '&lt;img class="drawer__item-img" src="' + (line.image || "") + '" alt=""&gt;' +
      '&lt;span class="drawer__item-title"&gt;' + line.title + "&lt;/span&gt;" +
      '&lt;span class="drawer__item-price"&gt;' + money(line.final_line_price) + "&lt;/span&gt;";
    itemsEl.appendChild(li);
  });
}
</code></pre>
<p>The one thing that trips people up is money. The Ajax API returns prices in <strong>cents</strong>. A line that costs <code>$18.99</code> comes back as <code>1899</code>. Forget to divide by 100 and you ship a <code>$1,899</code> coffee. The <code>money()</code> helper does that conversion in one place.</p>
<p>One production note: this reads <code>final_line_price</code>, which reflects line-level discounts, so it's the number the shopper is actually charged (<code>line_price</code> is the pre-discount amount). On a multi-currency store, swap the hand-rolled <code>money()</code> for Shopify's own money formatting so the symbol and decimals follow the active market.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a424d9ed27378c517882227/47f48e61-0d93-434c-914d-bf3a143214b8.gif" alt="The cart drawer open on a coffee storefront, showing one line item with its image, title and price, a subtotal, and a Checkout button" style="display:block;margin:0 auto" width="460" height="278" loading="lazy">

<p><em>The drawer rendered entirely from a</em> <code>/cart.js</code> <em>response. The count, the line, and the subtotal all came from the server.</em></p>
<h2 id="heading-step-4-quantity-plus-and-minus-with-event-delegation">Step 4: Quantity Plus and Minus, with Event Delegation</h2>
<p>Every drawer lets the shopper nudge quantities. The obvious way to wire the plus and minus buttons is to loop over them and attach a click handler to each. Do that and the controls go dead after the first change.</p>
<p>Here's why: every time the cart changes you re-render the list. This replaces those <code>&lt;li&gt;</code> elements, and the handlers you attached went with the old elements. The new buttons have no listeners.</p>
<p>The fix is <strong>event delegation</strong>. Attach one listener to the parent that never gets replaced (the <code>&lt;ul&gt;</code>), and when a click bubbles up, check what was actually clicked. One handler, and it keeps working through every re-render because the element it's attached to never moves.</p>
<pre><code class="language-javascript">var itemsEl = document.querySelector("[data-drawer-items]");

itemsEl.addEventListener("click", function (e) {
  var inc = e.target.closest("[data-qty-inc]");
  var dec = e.target.closest("[data-qty-dec]");
  if (!inc &amp;&amp; !dec) return;

  var line = e.target.closest("[data-line]");
  var key = line.getAttribute("data-line-key");
  var qty = Number(line.getAttribute("data-quantity"));
  var nextQty = inc ? qty + 1 : qty - 1;

  fetch("/cart/change.js", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ id: key, quantity: nextQty }),
  })
    .then(function (r) { return r.json(); })
    .then(render);
});
</code></pre>
<p>For this version, <code>render()</code> emits the stepper controls inside each line:</p>
<pre><code class="language-javascript">li.innerHTML =
  '&lt;img class="drawer__item-img" src="' + (line.image || "") + '" alt=""&gt;' +
  '&lt;span class="drawer__item-title"&gt;' + line.title + "&lt;/span&gt;" +
  '&lt;span class="drawer__item-qty"&gt;' +
    '&lt;button class="qty-btn" data-qty-dec aria-label="Decrease"&gt;-&lt;/button&gt;' +
    '&lt;span class="qty-value"&gt;' + line.quantity + "&lt;/span&gt;" +
    '&lt;button class="qty-btn" data-qty-inc aria-label="Increase"&gt;+&lt;/button&gt;' +
  "&lt;/span&gt;" +
  '&lt;span class="drawer__item-price"&gt;' + money(line.final_line_price) + "&lt;/span&gt;";
</code></pre>
<p>One detail that's easy to get wrong: the change request sends <code>id: key</code>, the line <strong>key</strong>, not the variant id. A cart can hold the same variant on two separate lines when they carry different line-item properties (an engraving, a gift note). The key is what uniquely identifies a single line, so that's what <code>/cart/change.js</code> wants.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a424d9ed27378c517882227/f8d4e99b-a446-4a7c-b3de-70fbfdcbf184.gif" alt="The quantity stepper mid-interaction: a line item with minus and plus buttons and the quantity between them, and the subtotal updating" style="display:block;margin:0 auto" width="460" height="278" loading="lazy">

<p><em>One delegated listener drives every line's stepper, now and after every re-render.</em></p>
<h2 id="heading-step-5-remove-a-line-and-clear-the-cart">Step 5: Remove a Line, and Clear the Cart</h2>
<p>New developers might go looking for <code>/cart/remove.js</code>, but it doesn't exist. In Shopify's cart API, removing a line <strong>is</strong> changing its quantity to zero on the same <code>/cart/change.js</code> route you just used for the stepper. Clearing the whole cart has its own endpoint, <code>/cart/clear.js</code>, which takes no body.</p>
<pre><code class="language-javascript">// Remove: one delegated listener, quantity 0 deletes the line.
itemsEl.addEventListener("click", function (e) {
  var remove = e.target.closest("[data-remove]");
  if (!remove) return;
  var key = e.target.closest("[data-line]").getAttribute("data-line-key");
  fetch("/cart/change.js", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ id: key, quantity: 0 }),
  })
    .then(function (r) { return r.json(); })
    .then(render);
});

// Clear: empty the whole cart, no body.
document.querySelector("[data-cart-clear]").addEventListener("click", function () {
  fetch("/cart/clear.js", { method: "POST" })
    .then(function (r) { return r.json(); })
    .then(render);
});
</code></pre>
<p>Both re-render from the server response, same as everything else. That's the discipline that keeps a removed item from lingering in the count: you don't splice the <code>&lt;li&gt;</code> out of the DOM and hope. You tell the server, then draw what the server says.</p>
<h2 id="heading-step-6-re-rendering-with-the-section-rendering-api-the-version-youd-ship">Step 6: Re-Rendering with the Section Rendering API (the Version You'd Ship)</h2>
<p>Everything so far rebuilds the drawer's HTML in JavaScript. It works, but look at what it costs: your drawer markup now lives in two places, once in the Liquid from Step 1 and once in the template strings inside <code>render()</code>. Change the design and you have to change both, in sync, forever.</p>
<p>Shopify's answer is <strong>bundled section rendering</strong>. You ask the cart endpoint to return the re-rendered section HTML in the same request that changes the cart. The markup lives only in Liquid, and the server hands you the finished HTML to drop in.</p>
<p>You opt in by adding a <code>sections</code> parameter to the cart request:</p>
<pre><code class="language-javascript">fetch("/cart/add.js", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    id: variantId,
    quantity: 1,
    sections: "cart-drawer",              // section id(s), comma-separated
    sections_url: window.location.pathname // optional render context
  }),
})
  .then(function (r) { return r.json(); })
  .then(function (cart) {
    // Shopify returns rendered HTML under `sections`, keyed by id.
    var html = cart.sections["cart-drawer"];
    if (html) {
      document.querySelector("[data-drawer]").innerHTML =
        new DOMParser().parseFromString(html, "text/html")
          .querySelector("[data-drawer]").innerHTML;
    }
    openDrawer();
  });
</code></pre>
<p>What's worth knowing about it, all from the <a href="https://shopify.dev/docs/api/ajax/reference/cart">Ajax Cart API reference</a> and the <a href="https://shopify.dev/docs/api/ajax/section-rendering">Section Rendering API docs</a>:</p>
<ul>
<li><p>Bundled section rendering is available on <code>/cart/add</code>, <code>/cart/change</code>, <code>/cart/clear</code>, and <code>/cart/update</code>.</p>
</li>
<li><p><code>sections</code> is a comma-separated list (or array) of section IDs, up to five.</p>
</li>
<li><p><code>sections_url</code> must begin with <code>/</code>. If you omit it, sections render in the context of the current page (based on the <code>Referer</code> header).</p>
</li>
<li><p>The rendered HTML comes back under the <code>sections</code> key of the JSON response, keyed by section ID.</p>
</li>
<li><p>A section that fails to render, including one that doesn't exist, comes back as <code>null</code> with an HTTP 200. Always guard for null.</p>
</li>
<li><p>The section ID is <code>section.id</code> in Liquid, or the <code>id="shopify-section-[id]"</code> on the wrapper. For a section rendered by filename (for example <code>{% section 'cart-drawer' %}</code> in <code>theme.liquid</code>), the ID is just <code>cart-drawer</code>. Inside a JSON template it gets a dynamic ID like <code>template--123__cart-drawer</code>, so check the wrapper before you hardcode the key.</p>
</li>
</ul>
<p>The plain Section Rendering API (a <code>GET</code> with <code>?sections=</code> or <code>?section_id=</code> appended to any page URL) is the same idea for non-cart updates like paginated search or infinite scroll. Shopify's own guidance is: for anything driven by a cart change, prefer bundled section rendering over a separate call, because it saves a round trip.</p>
<p>This isn't a toy pattern. Shopify's own Horizon theme drives its cart exactly this way: its cart components send a <code>sections</code> list on their <code>/cart/change.js</code> calls and re-render from <code>response.sections</code>, reading each section's id from a data attribute rather than hardcoding it. That's the production version of the caveat above: never assume the id is a bare <code>cart-drawer</code>. Read it off the rendered wrapper instead.</p>
<h2 id="heading-the-2026-upgrade-standard-storefront-events-and-actions">The 2026 Upgrade: Standard Storefront Events and Actions</h2>
<p>Everything above is timeless Shopify. Now the new part.</p>
<p>On June 17, 2026, as part of the Spring '26 Edition, Shopify shipped <a href="https://shopify.dev/changelog/standard-storefront-events-and-actions">standard storefront events and actions</a>, and they're generally available.</p>
<p>The idea is that themes now <strong>emit</strong> a standardized set of DOM events (all namespaced <code>shopify:</code>) and <strong>expose</strong> a standardized set of actions on <code>Shopify.actions</code>. An app or an AI shopping agent can now interact with any storefront through one contract, instead of reverse-engineering each theme's private JavaScript.</p>
<p>To feel why that matters, think about how an upsell app used to detect an add-to-cart on a theme it had never seen.</p>
<p>It had four bad options: monkey-patch <code>fetch</code> to sniff calls to <code>/cart/add</code>, listen for a theme-specific custom event whose name changed from theme to theme, poll <code>/cart.js</code> on a timer and diff it, or scrape the DOM for a cart-count node by selector. Every one of them breaks when the merchant reskins or switches themes.</p>
<p>This is also the exact code an AI assistant tends to generate when you ask it to "run something after add to cart," because that brittle pattern dominates its training data.</p>
<p>The problem was never that the developer was careless. There was simply no stable interface to target.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a424d9ed27378c517882227/59634003-a020-4b57-8d12-7ddcfde97f7f.png" alt="On the left, four brittle ways an app used to detect a cart change: patching fetch, listening for a theme-specific event, polling cart.js, and scraping the DOM. On the right, one standard contract: subscribe to shopify:cart:lines-update and call Shopify.actions" style="display:block;margin:0 auto" width="3666" height="2062" loading="lazy">

<p><em>Four fragile per-theme tactics collapse into one integration against a contract that survives a reskin.</em></p>
<p>Crucially, this layer sits <strong>on top of</strong> the drawer you just built. It doesn't replace the Ajax API. The docs even show the theme-side event wrapping the same <code>/cart/add.js</code> and <code>/cart.js</code> calls, and Shopify shipped a helper whose only job is to convert a <code>/cart.js</code> response into the event's payload shape. So none of your work is wasted. You're about to give it a public door.</p>
<h3 id="heading-the-events-the-theme-tells-the-world-what-happened">The Events: the Theme Tells the World What Happened</h3>
<p>Each event uses the <code>shopify:</code> namespace, follows a <code>category:action</code> naming pattern, dispatches from the most specific element (a product card, the cart, a collection container), and bubbles to <code>document</code>. The payloads follow the Storefront GraphQL API shape, with camelCase fields.</p>
<table>
<thead>
<tr>
<th>Event</th>
<th>Fires when</th>
</tr>
</thead>
<tbody><tr>
<td><code>shopify:page:view</code></td>
<td>Every page load</td>
</tr>
<tr>
<td><code>shopify:product:view</code></td>
<td>A product becomes visible</td>
</tr>
<tr>
<td><code>shopify:product:select</code></td>
<td>Buyer changes variant selection</td>
</tr>
<tr>
<td><code>shopify:cart:view</code></td>
<td>Cart becomes visible</td>
</tr>
<tr>
<td><code>shopify:cart:lines-update</code></td>
<td>Cart lines added, updated, or removed</td>
</tr>
<tr>
<td><code>shopify:cart:note-update</code></td>
<td>Cart note changes</td>
</tr>
<tr>
<td><code>shopify:cart:discount-update</code></td>
<td>Discount codes applied or removed</td>
</tr>
<tr>
<td><code>shopify:cart:error</code></td>
<td>A cart mutation fails</td>
</tr>
<tr>
<td><code>shopify:collection:view</code></td>
<td>Collection page loads</td>
</tr>
<tr>
<td><code>shopify:collection:update</code></td>
<td>Collection filters or sort change</td>
</tr>
<tr>
<td><code>shopify:search:update</code></td>
<td>Search filters or sort change</td>
</tr>
</tbody></table>
<p>An app subscribes with the ordinary DOM API. No SDK:</p>
<pre><code class="language-javascript">document.addEventListener('shopify:cart:lines-update', (event) =&gt; {
  console.log(event.action, event.lines);
  event.promise?.then(({ cart }) =&gt; {
    console.log(cart.cost.totalAmount.amount);
  });
});
</code></pre>
<p>The cart, note, discount, product-select, collection-update, and search-update events carry a <code>promise</code> field for their async result. That lets a listener show a loading or optimistic state immediately, then read the settled <code>{ cart }</code> when the operation resolves.</p>
<h3 id="heading-emitting-an-event-from-your-theme">Emitting an Event From Your Theme</h3>
<p>The events library is hosted on the Shopify CDN. You load it through an import map (for module themes like Horizon) or assign it to a global (for Dawn-style themes).</p>
<p>A theme fires an event by constructing the event class and calling <code>dispatchEvent()</code>. Read this carefully and you'll see that it wraps the exact same <code>/cart/add.js</code> and <code>/cart.js</code> calls from Step 2:</p>
<pre><code class="language-javascript">import { CartLinesUpdateEvent, CartErrorEvent } from '@theme/standard-events';

const deferred = CartLinesUpdateEvent.createPromise();
element.dispatchEvent(new CartLinesUpdateEvent({
  action: 'add',
  context: 'product',
  lines: [{ merchandiseId: variantId, quantity: 1 }],
  promise: deferred.promise,
}));

try {
  const response = await fetch(window.Shopify.routes.root + 'cart/add.js', { method: 'POST', body, headers });
  if (!response.ok) throw new Error('Add to cart failed');
  const ajaxCart = await fetch(window.Shopify.routes.root + 'cart.js').then(r =&gt; r.json());
  deferred.resolve({
    cart: CartLinesUpdateEvent.createCartFromAjaxResponse(ajaxCart),
  });
} catch (e) {
  element.dispatchEvent(new CartErrorEvent({ error: e.message, code: 'SERVICE_UNAVAILABLE' }));
  deferred.reject(e);
}
</code></pre>
<p>That static <code>createCartFromAjaxResponse(ajaxCart)</code> is the bridge between the classic Ajax drawer and the new event contract. It converts your <code>/cart.js</code> response into the Storefront-API-shaped payload the events expect, so the drawer you already have plugs straight in.</p>
<h3 id="heading-the-actions-the-world-asks-the-theme-to-do-something">The Actions: the World Asks the Theme to Do Something</h3>
<p>Actions are async functions on <code>Shopify.actions</code>, injected on every Liquid storefront with no script tag of your own:</p>
<pre><code class="language-javascript">// Add, update, or remove lines. Also handles note + discountCodes.
const { cart, userErrors, warnings } = await Shopify.actions.updateCart({
  lines: [
    { merchandiseId: "gid://shopify/ProductVariant/123", quantity: 1 }, // add
    { id: "gid://shopify/CartLine/456", quantity: 5 },                  // update
    { id: "gid://shopify/CartLine/789", quantity: 0 },                  // remove
  ],
});
// Returns: Promise&lt;{ cart, userErrors?, warnings? }&gt;

await Shopify.actions.openCart();                 // Promise&lt;void&gt;
const { cart } = await Shopify.actions.getCart(); // reads current cart
</code></pre>
<p>The defaults work on a stock theme with no changes: <code>updateCart</code> writes to the Storefront API and refreshes in place, falling back to a full page reload. <code>openCart</code> opens a <code>&lt;cart-drawer-component&gt;</code> or <code>&lt;cart-drawer&gt;</code> element if one exists, otherwise it redirects to <code>/cart</code>. <code>getCart</code> reads the current cart. And when a configured action succeeds, the runtime <strong>auto-emits</strong> the matching event, so an app that calls <code>updateCart</code> never has to also dispatch <code>shopify:cart:lines-update</code>.</p>
<h3 id="heading-overriding-an-action-to-drive-your-drawer">Overriding an Action to Drive Your Drawer</h3>
<p>This is the payoff. Because a theme can override an action's default, you can intercept <code>openCart</code> and <code>updateCart</code> so that any app's call routes through the drawer you already built, instead of triggering a page reload. The app doesn't need to know your markup. It calls the standard action, and your override decides what the UI does.</p>
<p>Register the override inside a <code>DOMContentLoaded</code> listener placed <strong>above</strong> <code>{{ content_for_header }}</code> in your layout, so it runs before any app code:</p>
<pre><code class="language-javascript">document.addEventListener('DOMContentLoaded', () =&gt; {
  Shopify.actions.updateCart.configure({
    eventTarget: (meta) =&gt; {
      if (meta.type === 'shopify:cart:note-update') return document.querySelector('cart-note');
      if (meta.type === 'shopify:cart:discount-update') return document.querySelector('cart-discount');
      if (meta.type === 'shopify:cart:lines-update' &amp;&amp; meta.action === 'add') {
        return document.querySelector('product-form');
      }
      return document.querySelector('cart-items');
    },
    async handler(defaultHandler, payload, options) {
      const result = await defaultHandler();
      customUpdateUI(result); // your render() + openDrawer() from earlier
      return result;
    },
  });
});
</code></pre>
<p><code>openCart</code> has a simpler override:</p>
<pre><code class="language-javascript">Shopify.actions.openCart.configure({
  handler() { document.querySelector('cart-drawer')?.open(); },
});
</code></pre>
<p>A few rules that will save you time:</p>
<ul>
<li><p><code>eventTarget</code> is required for <code>updateCart</code>. It decides which element the auto-emitted events dispatch from.</p>
</li>
<li><p><code>getCart</code> is intentionally <strong>not</strong> configurable. Calling <code>configure()</code> on it is a TypeScript error and a runtime <code>TypeError</code>.</p>
</li>
<li><p><code>isDefault()</code> tells you whether the theme has overridden an action yet.</p>
</li>
<li><p><code>updateCart</code> resolves with <code>{ cart, userErrors?, warnings?, detail? }</code> and rejects only when it couldn't run at all (a network failure or a malformed payload). A <code>userErrors</code> array means the mutation was rejected (codes like <code>INVALID</code>, <code>MAXIMUM_EXCEEDED</code>). A <code>warnings</code> array means it succeeded with caveats (<code>MERCHANDISE_OUT_OF_STOCK</code>, <code>DISCOUNT_NOT_FOUND</code>). Check both before you trust <code>cart</code>.</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/6a424d9ed27378c517882227/1401861d-5800-40b7-ad04-7887625986ec.png" alt="An app or AI agent calls a standard action, a theme override routes it through the existing cart-drawer logic, and the drawer opens in place with no reload" style="display:block;margin:0 auto" width="3666" height="2062" loading="lazy">

<p><em>The app calls the standard action and knows nothing about your markup. Your override decides the UI.</em></p>
<h3 id="heading-verifying-it">Verifying it</h3>
<p>Run <code>shopify theme dev</code> and the CLI loads a development build of the events runtime that validates payloads and logs a warning when a field is malformed or missing.</p>
<p>Those checks are stripped in production. Add the <code>--standard-events-inspector</code> flag and it injects a floating debug panel into your local pages with two tabs: <strong>Events</strong>, which shows every emitted standard event live with its full payload, and <strong>Actions</strong>, which lets you dispatch actions by hand and inspect the result. When you're wiring up payloads, trust the inspector over any tutorial, including this one.</p>
<h2 id="heading-why-this-matters">Why This Matters</h2>
<p>Two ideas in this build outlast the specific code, and they're the difference between a drawer that works and one you can maintain.</p>
<h3 id="heading-event-delegation">Event Delegation</h3>
<p>One listener on a parent that never gets replaced, reading <code>e.target.closest(...)</code>, handles every child element: the ones on the page now and the ones you have not rendered yet. Bind a handler per button instead and it dies the instant the list re-renders, which in a cart drawer is constantly.</p>
<p>Delegation is also, not by coincidence, the pattern AI tools most reliably get wrong, because per-element binding is what shows up most in their training data. Knowing to reach for delegation is exactly the kind of judgment that doesn't come from the syntax.</p>
<h3 id="heading-the-section-rendering-api">The Section Rendering API</h3>
<p>Instead of keeping your drawer's markup in two places and praying they stay in sync, you let the server render the section and hand you the HTML. Your markup lives in one file, and it stays correct when a merchant edits the section in the theme editor.</p>
<p>The trade is a slightly larger response and a parse step, in exchange for never maintaining the same markup twice.</p>
<p>And under all of it, one rule: after every mutation, re-read the cart (or the rendered section) and paint from the server's response. The local counter is the bug. Everything else in this article is a variation on trusting the server.</p>
<h2 id="heading-the-complete-files">The Complete Files</h2>
<p>For anyone who scrolled straight here, this is the whole thing: the section markup, the JavaScript, and the CSS. The JavaScript combines add, quantity, remove, and clear, all re-rendering from the server response.</p>
<p><code>sections/cart-drawer.liquid</code>:</p>
<pre><code class="language-liquid">&lt;button type="button" class="cart-toggle" data-cart-toggle&gt;
  Cart &lt;span class="cart-count" data-cart-count&gt;{{ cart.item_count }}&lt;/span&gt;
&lt;/button&gt;

&lt;aside class="drawer" data-drawer aria-label="Cart" aria-hidden="true"&gt;
  &lt;div class="drawer__head"&gt;
    &lt;h2&gt;Your cart&lt;/h2&gt;
    &lt;button type="button" class="drawer__close" data-drawer-close aria-label="Close cart"&gt;&amp;times;&lt;/button&gt;
  &lt;/div&gt;

  &lt;div class="drawer__body"&gt;
    &lt;p class="drawer__empty" data-drawer-empty {% if cart.item_count &gt; 0 %}style="display:none"{% endif %}&gt;
      Your cart is empty.
    &lt;/p&gt;
    &lt;ul class="drawer__items" data-drawer-items&gt;
      {%- for item in cart.items -%}
        &lt;li class="drawer__item" data-line data-line-key="{{ item.key }}" data-quantity="{{ item.quantity }}"&gt;
          {{ item.image | image_url: width: 96 | image_tag: class: 'drawer__item-img', loading: 'lazy', alt: item.product.title }}
          &lt;span class="drawer__item-title"&gt;{{ item.product.title }}&lt;/span&gt;
          &lt;span class="drawer__item-qty"&gt;
            &lt;button type="button" class="qty-btn" data-qty-dec aria-label="Decrease"&gt;-&lt;/button&gt;
            &lt;span class="qty-value"&gt;{{ item.quantity }}&lt;/span&gt;
            &lt;button type="button" class="qty-btn" data-qty-inc aria-label="Increase"&gt;+&lt;/button&gt;
          &lt;/span&gt;
          &lt;span class="drawer__item-price"&gt;{{ item.final_line_price | money }}&lt;/span&gt;
          &lt;button type="button" class="drawer__remove" data-remove aria-label="Remove"&gt;Remove&lt;/button&gt;
        &lt;/li&gt;
      {%- endfor -%}
    &lt;/ul&gt;
  &lt;/div&gt;

  &lt;div class="drawer__foot"&gt;
    &lt;div class="drawer__subtotal"&gt;
      &lt;span&gt;Subtotal&lt;/span&gt;
      &lt;span data-cart-subtotal&gt;{{ cart.total_price | money }}&lt;/span&gt;
    &lt;/div&gt;
    &lt;button type="button" class="drawer__clear" data-cart-clear&gt;Clear cart&lt;/button&gt;
    &lt;a href="{{ routes.cart_url }}" class="drawer__checkout"&gt;Checkout&lt;/a&gt;
  &lt;/div&gt;
&lt;/aside&gt;
&lt;div class="drawer__scrim" data-drawer-scrim&gt;&lt;/div&gt;

{% schema %}
{ "name": "Cart drawer" }
{% endschema %}
</code></pre>
<p><code>assets/cart-drawer.js</code>:</p>
<pre><code class="language-javascript">(function () {
  var countEl = document.querySelector("[data-cart-count]");
  var itemsEl = document.querySelector("[data-drawer-items]");
  var emptyEl = document.querySelector("[data-drawer-empty]");
  var subtotalEl = document.querySelector("[data-cart-subtotal]");
  var drawer = document.querySelector("[data-drawer]");
  var scrim = document.querySelector("[data-drawer-scrim]");
  var clearBtn = document.querySelector("[data-cart-clear]");

  // --- Add to cart: mutate, re-read, render, open ---
  document.querySelectorAll("[data-add]").forEach(function (btn) {
    btn.addEventListener("click", function () {
      var id = Number(btn.getAttribute("data-variant-id"));
      fetch("/cart/add.js", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ id: id, quantity: 1 }),
      })
        .then(function (r) { return r.json(); })
        .then(function () { return refresh(); })
        .then(openDrawer);
    });
  });

  // --- Quantity +/- : one delegated listener on the stable list ---
  itemsEl.addEventListener("click", function (e) {
    var inc = e.target.closest("[data-qty-inc]");
    var dec = e.target.closest("[data-qty-dec]");
    if (!inc &amp;&amp; !dec) return;
    var line = e.target.closest("[data-line]");
    var key = line.getAttribute("data-line-key");
    var qty = Number(line.getAttribute("data-quantity"));
    var nextQty = inc ? qty + 1 : qty - 1;
    fetch("/cart/change.js", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ id: key, quantity: nextQty }),
    }).then(function (r) { return r.json(); }).then(render);
  });

  // --- Remove a line: quantity 0 (there is no /cart/remove.js) ---
  itemsEl.addEventListener("click", function (e) {
    var remove = e.target.closest("[data-remove]");
    if (!remove) return;
    var key = e.target.closest("[data-line]").getAttribute("data-line-key");
    fetch("/cart/change.js", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ id: key, quantity: 0 }),
    }).then(function (r) { return r.json(); }).then(render);
  });

  // --- Clear the whole cart ---
  clearBtn.addEventListener("click", function () {
    fetch("/cart/clear.js", { method: "POST" })
      .then(function (r) { return r.json(); })
      .then(render);
  });

  // --- The one function that paints the drawer from the cart ---
  function money(cents) { return "$" + (cents / 100).toFixed(2); }

  function render(cart) {
    countEl.textContent = cart.item_count;
    subtotalEl.textContent = money(cart.total_price);
    itemsEl.innerHTML = "";
    if (!cart.items.length) { emptyEl.style.display = "block"; return; }
    emptyEl.style.display = "none";
    cart.items.forEach(function (line) {
      var li = document.createElement("li");
      li.className = "drawer__item";
      li.setAttribute("data-line", "");
      li.setAttribute("data-line-key", line.key);
      li.setAttribute("data-quantity", line.quantity);
      li.innerHTML =
        '&lt;img class="drawer__item-img" src="' + (line.image || "") + '" alt=""&gt;' +
        '&lt;span class="drawer__item-title"&gt;' + line.title + "&lt;/span&gt;" +
        '&lt;span class="drawer__item-qty"&gt;' +
          '&lt;button type="button" class="qty-btn" data-qty-dec aria-label="Decrease"&gt;-&lt;/button&gt;' +
          '&lt;span class="qty-value"&gt;' + line.quantity + "&lt;/span&gt;" +
          '&lt;button type="button" class="qty-btn" data-qty-inc aria-label="Increase"&gt;+&lt;/button&gt;' +
        "&lt;/span&gt;" +
        '&lt;span class="drawer__item-price"&gt;' + money(line.final_line_price) + "&lt;/span&gt;" +
        '&lt;button type="button" class="drawer__remove" data-remove aria-label="Remove"&gt;Remove&lt;/button&gt;';
      itemsEl.appendChild(li);
    });
  }

  function refresh() {
    return fetch("/cart.js").then(function (r) { return r.json(); }).then(render);
  }
  function openDrawer() { drawer.classList.add("is-open"); scrim.classList.add("is-open"); }
  function closeDrawer() { drawer.classList.remove("is-open"); scrim.classList.remove("is-open"); }

  document.querySelector("[data-cart-toggle]").addEventListener("click", function () { refresh().then(openDrawer); });
  document.querySelector("[data-drawer-close]").addEventListener("click", closeDrawer);
  scrim.addEventListener("click", closeDrawer);

  refresh(); // paint on load
})();
</code></pre>
<p><code>assets/cart-drawer.css</code>:</p>
<pre><code class="language-css">.drawer {
  position: fixed;
  inset: 0 0 0 auto;
  width: min(420px, 100%);
  background: #fff;
  transform: translateX(100%);
  transition: transform 0.3s ease;
  display: flex;
  flex-direction: column;
}
.drawer.is-open { transform: translateX(0); }
.drawer__scrim {
  position: fixed; inset: 0;
  background: rgba(30, 18, 6, 0.45);
  opacity: 0; pointer-events: none;
  transition: opacity 0.3s ease;
}
.drawer__scrim.is-open { opacity: 1; pointer-events: auto; }
</code></pre>
<h2 id="heading-wrap-up">Wrap Up</h2>
<p>You now have a cart drawer that adds, updates, removes, and clears without a reload, that never lets its UI drift from the real cart, and that, with an action override, presents a clean public interface to the entire app ecosystem, humans and AI agents alike.</p>
<p>The Ajax foundation is the same it has been for years. The 2026 layer sits on top of it, so the drawer you built today is ready for whatever calls it tomorrow.</p>
<p><em>If you want to build this exact drawer interactively, writing the JavaScript and watching the real storefront react, you can do it for free at</em> <a href="https://learnshopify.dev/learn/cart-drawer-component?utm_source=freecodecamp&amp;utm_medium=referral&amp;utm_campaign=ajax-cart-drawer-2026"><em>learnshopify.dev</em></a><em>.</em></p>
<img src="https://cdn.hashnode.com/uploads/covers/6a424d9ed27378c517882227/7bd7cf3b-389d-413c-b2e6-c287ffcafbc3.png" alt="learnshopify.dev landings page, interactive platform to learn shopify development" style="display:block;margin:0 auto" width="2041" height="1269" loading="lazy"> ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
