Why use the Ajax Cart API

Every time a customer adds something to the cart on a default Shopify theme, the page reloads. For a basic store this is fine. But the moment you want a cart drawer, a mini-cart count update in the header, a quantity stepper that responds instantly, or any kind of add-to-cart interaction that doesn't interrupt the browsing flow — you need the Ajax Cart API.

Shopify exposes a set of JSON endpoints on every store that let you read and modify the cart entirely over HTTP. No external library required. No jQuery. Just fetch(), JSON, and your own JavaScript.

Base URL convention: All Ajax Cart endpoints are at /cart/[action].js — note the .js suffix, not .json. They return JSON regardless. These endpoints work on the same origin as the storefront, so no CORS issues from theme JavaScript.

The full endpoints reference

EndpointMethodWhat it does
/cart.jsGETGet the current cart object — all items, totals, item count, attributes
/cart/add.jsPOSTAdd one or more items to the cart by variant ID
/cart/update.jsPOSTUpdate quantities for one or more line items by line index or variant ID
/cart/change.jsPOSTChange a single line item's quantity, properties, or selling plan
/cart/clear.jsPOSTRemove all items from the cart
/cart/shipping_rates.jsonGETGet available shipping rates for the current cart (requires address params)

Reading the cart

Start here — understanding what /cart.js returns tells you everything about the data model:

async function getCart() {
  const response = await fetch('/cart.js', {
    headers: { 'Content-Type': 'application/json' }
  });
  if (!response.ok) throw new Error(`Cart fetch failed: ${response.status}`);
  return response.json();
}

// Cart object shape (abbreviated):
// {
//   token: "abc123",
//   item_count: 2,
//   total_price: 4999,        ← in cents
//   items: [
//     {
//       id: 12345678,          ← variant ID
//       key: "12345678:abc",   ← unique line key
//       title: "Product Name",
//       quantity: 1,
//       price: 2499,           ← in cents
//       line_price: 2499,
//       properties: {},        ← line item properties
//       variant_title: "Red / Large",
//       product_type: "...",
//       url: "/products/...",
//       featured_image: {...}
//     }
//   ],
//   attributes: {},            ← cart attributes
//   note: null
// }

Prices are always in cents (the store's currency subunit). To display them, divide by 100 or use Shopify's money formatting — more on that below.

Adding items to the cart

The minimum required body is id (the variant ID) and quantity:

async function addToCart(variantId, quantity = 1, properties = {}) {
  const response = await fetch('/cart/add.js', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      id: variantId,
      quantity,
      properties // optional: { "Gift message": "Happy birthday!" }
    })
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.description ?? 'Could not add item to cart');
  }

  return response.json(); // returns the added line item, not the full cart
}

Note: /cart/add.js returns only the added line item object, not the full cart. If you need the updated cart total or item count after adding, call /cart.js separately — or use the sections parameter (see below).

To add multiple items in a single request:

async function addMultipleItems(items) {
  // items: [{ id: variantId, quantity: 1 }, ...]
  const response = await fetch('/cart/add.js', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ items })
  });
  if (!response.ok) throw new Error('Failed to add items');
  return response.json();
}

Updating quantities

Two endpoints handle quantity changes. Use /cart/change.js for a single line item (preferred — more explicit), or /cart/update.js to set quantities for multiple lines at once.

// Change a single line item — use the line item KEY (not variant ID)
// The key is: "variantId:hash" from cart.items[n].key
async function changeLineItem(key, quantity) {
  const response = await fetch('/cart/change.js', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ id: key, quantity })
  });
  if (!response.ok) throw new Error('Failed to update cart');
  return response.json(); // returns the full updated cart
}

// Set quantity to 0 to remove the item
const removeItem = (key) => changeLineItem(key, 0);
// Update multiple lines at once using /cart/update.js
// updates: { "key1": newQty, "key2": newQty }
async function updateCart(updates) {
  const response = await fetch('/cart/update.js', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ updates })
  });
  if (!response.ok) throw new Error('Failed to update cart');
  return response.json();
}

Cart attributes and order notes

Cart attributes let you attach arbitrary key/value pairs to the cart — these appear on the order in the Shopify admin and can be used by fulfilment workflows. Update them via /cart/update.js:

async function setCartAttributes(attributes, note = null) {
  const body = { attributes };
  if (note !== null) body.note = note;

  const response = await fetch('/cart/update.js', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body)
  });
  return response.json();
}

// Usage:
setCartAttributes({
  'gift_note': 'Happy birthday!',
  'delivery_date': '2026-06-15'
});

Re-rendering Liquid sections after cart changes

One of the most useful but underused features of the Ajax Cart API: you can ask Shopify to re-render specific theme sections server-side and return the HTML alongside the cart JSON, in a single request. This means your cart drawer HTML stays in sync with Liquid logic without building a parallel JS template system.

async function addToCartWithSections(variantId, quantity = 1) {
  const response = await fetch('/cart/add.js', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      id: variantId,
      quantity,
      sections: ['cart-drawer', 'cart-icon-bubble']
      // ↑ section IDs from your theme's sections/ folder (without .liquid)
    })
  });
  const data = await response.json();

  // data.sections is an object: { 'cart-drawer': '<html string>', ... }
  if (data.sections) {
    Object.entries(data.sections).forEach(([sectionId, html]) => {
      const target = document.getElementById(`shopify-section-${sectionId}`);
      if (target) target.innerHTML = html;
    });
  }
  return data;
}

The sections approach is the right default for cart drawers. Your section Liquid already handles edge cases, translations, and conditional rendering correctly — re-rendering it is safer and faster than replicating that logic in JavaScript. All of Dawn's cart drawer logic works this way.

Formatting prices in JavaScript

Cart prices are in cents. Shopify's Intl.NumberFormat approach is the right tool:

function formatMoney(cents, currency = 'USD') {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency,
    minimumFractionDigits: 2
  }).format(cents / 100);
}

// Or read the currency from the page (set by Liquid):
const CURRENCY = window.Shopify?.currency?.active ?? 'USD';

For themes that support multiple currencies, use window.Shopify.currency.active — Shopify sets this via a snippet. Never hardcode the currency string.

Building a cart drawer: the full pattern

Here's the complete pattern I use for cart drawers on client projects — minimal, no framework, sections-based rendering:

class CartDrawer {
  constructor() {
    this.drawer = document.getElementById('cart-drawer');
    this.overlay = document.getElementById('cart-overlay');
    this.isOpen = false;
    this.bindEvents();
  }

  bindEvents() {
    // Open on any add-to-cart form submit
    document.addEventListener('submit', (e) => {
      const form = e.target.closest('form[action="/cart/add"]');
      if (!form) return;
      e.preventDefault();
      this.handleAddToCart(form);
    });

    // Close on overlay click or Escape
    this.overlay?.addEventListener('click', () => this.close());
    document.addEventListener('keydown', (e) => {
      if (e.key === 'Escape' && this.isOpen) this.close();
    });
  }

  async handleAddToCart(form) {
    const submitBtn = form.querySelector('[type="submit"]');
    submitBtn?.setAttribute('aria-busy', 'true');

    try {
      const formData = new FormData(form);
      const response = await fetch('/cart/add.js', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          id: formData.get('id'),
          quantity: Number(formData.get('quantity')) || 1,
          sections: ['cart-drawer', 'cart-icon-bubble']
        })
      });

      if (!response.ok) {
        const err = await response.json();
        throw new Error(err.description);
      }

      const data = await response.json();
      this.renderSections(data.sections);
      this.open();
    } catch (err) {
      this.showError(err.message);
    } finally {
      submitBtn?.removeAttribute('aria-busy');
    }
  }

  renderSections(sections = {}) {
    Object.entries(sections).forEach(([id, html]) => {
      const el = document.getElementById(`shopify-section-${id}`);
      if (el) el.innerHTML = html;
    });
  }

  open() {
    this.isOpen = true;
    this.drawer?.setAttribute('aria-hidden', 'false');
    this.overlay?.removeAttribute('hidden');
    document.body.style.overflow = 'hidden';
    this.drawer?.querySelector('[data-close]')?.focus();
  }

  close() {
    this.isOpen = false;
    this.drawer?.setAttribute('aria-hidden', 'true');
    this.overlay?.setAttribute('hidden', '');
    document.body.style.overflow = '';
  }

  showError(message) {
    const errorEl = document.getElementById('cart-error');
    if (errorEl) {
      errorEl.textContent = message;
      errorEl.removeAttribute('hidden');
      setTimeout(() => errorEl.setAttribute('hidden', ''), 4000);
    }
  }
}

document.addEventListener('DOMContentLoaded', () => {
  window.cartDrawer = new CartDrawer();
});

Error handling you actually need

The two errors that will definitely happen in production:

  • Item sold out422 Unprocessable Entity with { "status": 422, "message": "Cart Error", "description": "All 3 of Variant Name are in your cart." }. Always show error.description to the user, never the generic message.
  • Variant unavailable — same 422 status, different description. The variant exists but isn't purchasable.

Network failures are rarer but possible. A try/catch around every fetch is non-optional for production code. Show a human-readable message and don't silently fail.

Preventing duplicate submissions

Add-to-cart buttons need to be disabled during the in-flight request. The aria-busy approach above is accessible and CSS-styleable:

/* Visually indicate loading state */
.btn-add-to-cart[aria-busy="true"] {
  opacity: 0.6;
  pointer-events: none;
  cursor: wait;
}

/* Optional: show a spinner */
.btn-add-to-cart[aria-busy="true"]::after {
  content: '';
  display: inline-block;
  width: 14px; height: 14px;
  border: 2px solid currentColor;
  border-top-color: transparent;
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
  margin-left: 8px;
  vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }

A note on the Storefront API vs Ajax Cart API

The Ajax Cart API works on the same origin as the storefront — no authentication token required. It operates on the session cart (the same cart the customer sees at /cart). The Storefront API's cart mutations work differently, are token-authenticated, and are primarily for headless builds where you manage cart state outside the Shopify session model. For standard theme development, the Ajax Cart API is the right tool and is simpler to work with.