Skip to content
← Back to notes

The cart-in-a-cookie pattern: less state, fewer bugs

Most headless Shopify storefronts reach for Zustand or Redux on day one. We don't — the cart is a single ID in an HTTP-only cookie, set by middleware, with the server as source of truth.

ShopifyNext.jsEngineering

Most headless Shopify storefronts manage cart state with a client-side store: Zustand, Redux, Jotai, a React Context with localStorage hydration. We don't. The cart on Trek Coffee Co. is a single ShopifycartId in an HTTP-only cookie, created by Next.js middleware at the edge, and the server is the source of truth. This post is a defense of that pattern.

What client-side cart stores actually buy you

The case for a client-side store is reasonable: optimistic updates, fast UI on add-to-cart, no round trip for the count badge. The case is not as strong as it sounds. The cart count is rendered server-side on the first paint anyway. Optimistic updates are easy with React's built-in useOptimistic. And every client-side cart store eventually has to reconcile with the server when the user opens a second tab, refreshes, or logs in from a phone — at which point you've written hydration code, conflict resolution, and a stale-cart edge case that bites you in three months.

The pattern

On the first request that needs a cart, Next.js middleware checks the request cookies forshopify_cart_id. If it's missing, middleware calls Shopify's cartCreatemutation, gets back a cart ID, and sets it as an HTTP-only cookie with a 30-day expiration. The cookie is attached to the response. Every subsequent request — page render, add-to-cart server action, cart drawer open — reads that cookie server-side and reuses the same cart.

Add-to-cart is a server action that calls cartLinesAddwith the cart ID from the cookie. The action returns the updated cart, React revalidates, the badge updates. Same for remove and update. The client never holds cart state directly. The cookie is the only piece of cart data that lives on the user's machine, and it's opaque — just a string Shopify can resolve.

What you get for free

  • Cross-tab consistency. Open the site in two tabs, add to cart in one, refresh the other — the cart matches. There is no stale local state to reconcile.
  • Cross-device persistence isn't needed.The cart lives 30 days on this device. If the user signs in, you can associate the cart with their Shopify customer; if they don't, anonymous carry-over works the way they expect.
  • SSR works. The first byte of the cart drawer is real HTML with real cart contents, because the server has the cart ID before the React tree renders.
  • Less JavaScript. No store, no hydration, no persist middleware, no custom hook. The reduction in bundle size is small but real, and the reduction in code surface is substantial.

What you give up

You give up some optimistic UI. useOptimistic wins back most of it, but if you want instant-feel cart updates on a slow connection, a client store does that better. You also give up the ability to inspect cart state in React DevTools, which is genuinely useful while debugging.

If those things matter for your shop, build the client store. We're not zealots about this. But the default in the Shopify-headless ecosystem is "reach for a state management library on day one," and that default is wrong often enough that it's worth pushing back on.

Apollo configuration that matches

With this pattern, cart and product queries use fetchPolicy: "no-cache". In a commerce context, a stale price or an out-of-stock item shown as available is a worse failure mode than a slightly slower page. Vercel's edge caches the public storefront aggressively at the route level, so the actual user-perceived latency is fine. Apollo's in-memory cache, in this architecture, is solving a problem we don't have.