Building a Cart Store with Shopify AJAX API
Create a complete cart state management solution using Zustand and Shopify's AJAX API. Handle add, update, remove, and sync operations.
The cart is the most important shared state in an e-commerce theme. In this lesson, we’ll build a production-ready cart store using Zustand and Shopify’s AJAX API.
Shopify Cart API Overview
Shopify provides several AJAX endpoints for cart operations:
| Endpoint | Method | Purpose |
|---|---|---|
/cart.js | GET | Get current cart |
/cart/add.js | POST | Add items to cart |
/cart/change.js | POST | Update item quantity |
/cart/update.js | POST | Update multiple items |
/cart/clear.js | POST | Empty the cart |
All endpoints return the updated cart object as JSON.
Cart Types
First, define types for the cart data:
export interface CartLineItem { key: string; productId: number; variantId: number; title: string; variantTitle: string | null; quantity: number; price: number; linePrice: number; finalLinePrice: number; image: string | null; url: string; handle: string; sku: string | null; vendor: string; properties: Record<string, string>;}
export interface Cart { token: string; note: string | null; attributes: Record<string, string>; itemCount: number; totalPrice: number; totalDiscount: number; originalTotalPrice: number; items: CartLineItem[]; requiresShipping: boolean; currency: string;}The Cart API Module
Create a dedicated module for API calls:
/* * Shopify Cart API Module * * Wraps Shopify's AJAX Cart API endpoints with proper error handling. * All endpoints return the updated cart object as JSON. * * Docs: https://shopify.dev/docs/api/ajax/reference/cart */import type { Cart } from '@/types';
// Shopify's built-in cart endpoints (no API key needed)const CART_ENDPOINTS = { get: '/cart.js', // GET - fetch current cart add: '/cart/add.js', // POST - add items change: '/cart/change.js', // POST - update single item quantity update: '/cart/update.js', // POST - update multiple items or cart attributes clear: '/cart/clear.js', // POST - empty the cart};
/** * Fetches the current cart */export async function getCart(): Promise<Cart> { const response = await fetch(CART_ENDPOINTS.get);
if (!response.ok) { throw new Error('Failed to fetch cart'); }
return response.json();}
/** * Adds an item to the cart * Note: Properties allow custom line item data (e.g., engravings, gift messages) */export async function addToCart( variantId: number, quantity: number, properties?: Record<string, string>): Promise<Cart> { const response = await fetch(CART_ENDPOINTS.add, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: variantId, // Shopify variant ID quantity, properties, // Optional custom properties }), });
if (!response.ok) { // Shopify returns error details in JSON (e.g., "Out of stock") const error = await response.json(); throw new Error(error.description || 'Failed to add item'); }
// /cart/add.js returns the line item, not the full cart // So we fetch the full cart to get totals and all items return getCart();}
/** * Updates a line item quantity * Use lineKey (not variant ID) to target specific line items */export async function updateLineItem(lineKey: string, quantity: number): Promise<Cart> { const response = await fetch(CART_ENDPOINTS.change, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: lineKey, // Line item key (unique per cart item) quantity, // New quantity (0 removes the item) }), });
if (!response.ok) { throw new Error('Failed to update item'); }
// /cart/change.js returns the full updated cart return response.json();}
/** * Removes a line item from the cart * Shorthand for setting quantity to 0 */export async function removeLineItem(lineKey: string): Promise<Cart> { return updateLineItem(lineKey, 0);}
/** * Updates multiple line items at once * More efficient than multiple individual calls */export async function updateCart(updates: Record<string, number>): Promise<Cart> { const response = await fetch(CART_ENDPOINTS.update, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ updates }), // { lineKey: quantity, ... } });
if (!response.ok) { throw new Error('Failed to update cart'); }
return response.json();}
/** * Clears all items from the cart */export async function clearCart(): Promise<Cart> { const response = await fetch(CART_ENDPOINTS.clear, { method: 'POST', headers: { 'Content-Type': 'application/json' }, });
if (!response.ok) { throw new Error('Failed to clear cart'); }
return response.json();}
/** * Updates the cart note (shown at checkout) */export async function updateCartNote(note: string): Promise<Cart> { const response = await fetch(CART_ENDPOINTS.update, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ note }), });
if (!response.ok) { throw new Error('Failed to update note'); }
return response.json();}The Cart Store
Now build the Zustand store:
import { create } from 'zustand';import type { Cart, CartLineItem } from '@/types';import * as cartApi from '@/api/cart';
interface CartState { // State cart: Cart | null; isLoading: boolean; isOpen: boolean; error: string | null;
// Computed (as functions) getItemCount: () => number; getTotal: () => number; getItemByKey: (key: string) => CartLineItem | undefined;
// Actions initialize: () => Promise<void>; addItem: ( variantId: number, quantity: number, properties?: Record<string, string> ) => Promise<void>; updateItem: (key: string, quantity: number) => Promise<void>; removeItem: (key: string) => Promise<void>; clearCart: () => Promise<void>; updateNote: (note: string) => Promise<void>;
// UI actions openCart: () => void; closeCart: () => void; toggleCart: () => void; clearError: () => void;}
export const useCart = create<CartState>((set, get) => ({ // Initial state from Liquid or null cart: typeof window !== 'undefined' && window.Shopify?.cart ? transformShopifyCart(window.Shopify.cart) : null, isLoading: false, isOpen: false, error: null,
// Computed values getItemCount: () => get().cart?.itemCount ?? 0, getTotal: () => get().cart?.totalPrice ?? 0, getItemByKey: (key) => get().cart?.items.find((item) => item.key === key),
// Initialize cart from server initialize: async () => { if (get().cart) return; // Already initialized
set({ isLoading: true, error: null });
try { const cart = await cartApi.getCart(); set({ cart: transformShopifyCart(cart) }); } catch (error) { set({ error: 'Failed to load cart' }); console.error('Cart initialization failed:', error); } finally { set({ isLoading: false }); } },
// Add item to cart addItem: async (variantId, quantity, properties) => { set({ isLoading: true, error: null });
try { const cart = await cartApi.addToCart(variantId, quantity, properties); set({ cart: transformShopifyCart(cart), isOpen: true, }); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to add item'; set({ error: message }); throw error; } finally { set({ isLoading: false }); } },
// Update item quantity updateItem: async (key, quantity) => { set({ isLoading: true, error: null });
try { const cart = await cartApi.updateLineItem(key, quantity); set({ cart: transformShopifyCart(cart) }); } catch (error) { set({ error: 'Failed to update item' }); throw error; } finally { set({ isLoading: false }); } },
// Remove item removeItem: async (key) => { set({ isLoading: true, error: null });
try { const cart = await cartApi.removeLineItem(key); set({ cart: transformShopifyCart(cart) }); } catch (error) { set({ error: 'Failed to remove item' }); throw error; } finally { set({ isLoading: false }); } },
// Clear all items clearCart: async () => { set({ isLoading: true, error: null });
try { const cart = await cartApi.clearCart(); set({ cart: transformShopifyCart(cart) }); } catch (error) { set({ error: 'Failed to clear cart' }); throw error; } finally { set({ isLoading: false }); } },
// Update cart note updateNote: async (note) => { set({ isLoading: true, error: null });
try { const cart = await cartApi.updateCartNote(note); set({ cart: transformShopifyCart(cart) }); } catch (error) { set({ error: 'Failed to update note' }); throw error; } finally { set({ isLoading: false }); } },
// UI actions openCart: () => set({ isOpen: true }), closeCart: () => set({ isOpen: false }), toggleCart: () => set((state) => ({ isOpen: !state.isOpen })), clearError: () => set({ error: null }),}));
/** * Transforms Shopify's snake_case cart to our camelCase types */function transformShopifyCart(shopifyCart: Record<string, unknown>): Cart { const cart = shopifyCart as Record<string, unknown>;
return { token: cart.token as string, note: cart.note as string | null, attributes: cart.attributes as Record<string, string>, itemCount: cart.item_count as number, totalPrice: cart.total_price as number, totalDiscount: cart.total_discount as number, originalTotalPrice: cart.original_total_price as number, requiresShipping: cart.requires_shipping as boolean, currency: cart.currency as string, items: (cart.items as Record<string, unknown>[]).map(transformLineItem), };}
function transformLineItem(item: Record<string, unknown>): CartLineItem { return { key: item.key as string, productId: item.product_id as number, variantId: item.variant_id as number, title: item.title as string, variantTitle: item.variant_title as string | null, quantity: item.quantity as number, price: item.price as number, linePrice: item.line_price as number, finalLinePrice: item.final_line_price as number, image: item.image as string | null, url: item.url as string, handle: item.handle as string, sku: item.sku as string | null, vendor: item.vendor as string, properties: item.properties as Record<string, string>, };}Using the Cart Store
Cart Icon in Header
import { useCart } from '@/stores/cart';
export function CartIcon() { const itemCount = useCart((state) => state.getItemCount()); const toggleCart = useCart((state) => state.toggleCart);
return ( <button className="cart-icon" onClick={toggleCart} aria-label={`Cart with ${itemCount} items`}> <svg>{/* cart icon */}</svg> {itemCount > 0 && <span className="cart-count">{itemCount}</span>} </button> );}Add to Cart Button
import { useState } from 'react';import { useCart } from '@/stores/cart';import { Button } from '@/components/ui';
interface AddToCartButtonProps { variantId: number; available: boolean; quantity?: number;}
export function AddToCartButton({ variantId, available, quantity = 1 }: AddToCartButtonProps) { const addItem = useCart((state) => state.addItem); const isLoading = useCart((state) => state.isLoading); const [justAdded, setJustAdded] = useState(false);
const handleClick = async () => { try { await addItem(variantId, quantity); setJustAdded(true); setTimeout(() => setJustAdded(false), 2000); } catch (error) { // Error is handled in store } };
if (!available) { return ( <Button disabled variant="secondary"> Sold Out </Button> ); }
return ( <Button onClick={handleClick} loading={isLoading} variant={justAdded ? 'secondary' : 'primary'}> {justAdded ? 'Added!' : 'Add to Cart'} </Button> );}Cart Drawer
import { useCart } from '@/stores/cart';import { Drawer, Button } from '@/components/ui';import { CartLineItem } from './CartLineItem';import { formatMoney } from '@/utils/money';
export function CartDrawer() { const isOpen = useCart((state) => state.isOpen); const closeCart = useCart((state) => state.closeCart); const cart = useCart((state) => state.cart); const isLoading = useCart((state) => state.isLoading); const error = useCart((state) => state.error); const clearError = useCart((state) => state.clearError);
const items = cart?.items ?? []; const isEmpty = items.length === 0;
return ( <Drawer isOpen={isOpen} onClose={closeCart} position="right" title="Your Cart"> {error && ( <div className="cart-error"> {error} <button onClick={clearError}>Dismiss</button> </div> )}
{isEmpty ? ( <div className="cart-empty"> <p>Your cart is empty</p> <Button onClick={closeCart}>Continue Shopping</Button> </div> ) : ( <> <ul className="cart-items"> {items.map((item) => ( <CartLineItem key={item.key} item={item} /> ))} </ul>
<div className="cart-footer"> <div className="cart-total"> <span>Subtotal</span> <span>{formatMoney(cart?.totalPrice ?? 0)}</span> </div>
<Button as="a" href="/checkout" variant="primary" fullWidth disabled={isLoading}> Checkout </Button> </div> </> )} </Drawer> );}Cart Line Item
import { useState } from 'react';import { useCart } from '@/stores/cart';import type { CartLineItem as CartLineItemType } from '@/types';import { formatMoney } from '@/utils/money';
interface CartLineItemProps { item: CartLineItemType;}
export function CartLineItem({ item }: CartLineItemProps) { const updateItem = useCart((state) => state.updateItem); const removeItem = useCart((state) => state.removeItem); const [isUpdating, setIsUpdating] = useState(false);
const handleQuantityChange = async (newQuantity: number) => { if (newQuantity < 1) return;
setIsUpdating(true); try { await updateItem(item.key, newQuantity); } finally { setIsUpdating(false); } };
const handleRemove = async () => { setIsUpdating(true); try { await removeItem(item.key); } finally { setIsUpdating(false); } };
return ( <li className={`cart-item ${isUpdating ? 'updating' : ''}`}> {item.image && <img src={item.image} alt={item.title} className="cart-item-image" />}
<div className="cart-item-details"> <a href={item.url} className="cart-item-title"> {item.title} </a>
{item.variantTitle && <p className="cart-item-variant">{item.variantTitle}</p>}
<p className="cart-item-price">{formatMoney(item.price)}</p> </div>
<div className="cart-item-quantity"> <button onClick={() => handleQuantityChange(item.quantity - 1)} disabled={isUpdating || item.quantity <= 1} aria-label="Decrease quantity" > − </button> <span>{item.quantity}</span> <button onClick={() => handleQuantityChange(item.quantity + 1)} disabled={isUpdating} aria-label="Increase quantity" > + </button> </div>
<button onClick={handleRemove} disabled={isUpdating} className="cart-item-remove" aria-label="Remove item" > × </button> </li> );}Sync with Global Window Object
Keep the global window.Shopify.cart in sync for non-React code:
export const useCart = create<CartState>((set, get) => ({ // ... other code
addItem: async (variantId, quantity, properties) => { set({ isLoading: true, error: null });
try { const cart = await cartApi.addToCart(variantId, quantity, properties); const transformedCart = transformShopifyCart(cart);
set({ cart: transformedCart, isOpen: true });
// Sync to window for non-React code syncToWindow(transformedCart); } catch (error) { // ... error handling } finally { set({ isLoading: false }); } },}));
function syncToWindow(cart: Cart) { if (typeof window !== 'undefined' && window.Shopify) { window.Shopify.cart = { itemCount: cart.itemCount, totalPrice: cart.totalPrice, currency: cart.currency, }; }}Key Takeaways
- Separate API from store: Keep cart API calls in a dedicated module
- Transform snake_case: Convert Shopify’s response format to camelCase
- Initialize from Liquid: Use
window.Shopify.cartfor instant hydration - Handle loading states: Each action sets
isLoadingfor UI feedback - Handle errors: Store errors in state for display to users
- Selective subscriptions: Use selectors to minimize re-renders
- Sync to window: Keep non-React code in sync if needed
In the next lesson, we’ll add optimistic updates to make the cart feel instant.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...