Guides · February 15, 2023
Cart & Checkout Flows with the Shopify Storefront API
Building cart and checkout with the Shopify Storefront API means owning the cart object end to end and handing off to Shopify's hosted checkout at the last possible moment. Here is the full flow, in GraphQL, with the edge cases that actually break in production.
By Polo Themes
The short answer: a headless storefront built on the Shopify Storefront API owns the entire pre-purchase experience — product browsing, the cart, applied discounts, and shipping estimates — using the cart GraphQL object as the single source of truth, then redirects to Shopify's hosted `checkoutUrl` for payment. You do not, and should not, try to rebuild Shopify's checkout yourself; the API is deliberately structured so you keep control right up until the moment money changes hands, then hand off to infrastructure Shopify has spent a decade hardening against fraud, tax edge cases, and PCI scope.
This matters more than it used to. Shopify deprecated the old Checkout API in 2024 in favor of the Cart API plus hosted checkout, and that change quietly simplified a lot of what used to be custom plumbing. If you are building a Next.js storefront, a custom design-system-driven frontend, or evaluating whether to go headless at all, understanding exactly where the API's responsibility ends and Shopify's hosted checkout begins is the single most important architectural decision you will make.
The Two-Object Mental Model: Cart vs. Checkout
Shopify's Storefront API used to expose a `checkout` object that your frontend built up field by field — shipping address, email, line items, discount codes — before completing the purchase entirely through the API. That model is gone. Since the 2024-04 API version, the checkout mutations are deprecated, and Shopify pushes everyone toward a cleaner split:
- Cart — a GraphQL object you fully control via the Storefront API. It holds line items, buyer identity, applied discount codes, delivery address (for estimating shipping), attributes, and notes. You create it, mutate it, and read it as many times as you want, for as long as the shopper is browsing.
- Checkout — not an API object at all anymore, but a *hosted, Shopify-owned page* your cart resolves into via `cart.checkoutUrl`. Shopify renders it, handles payment collection, applies tax and duty calculation, runs fraud analysis, and completes the order.
The practical implication: your frontend code never touches a credit card number, never calls a "complete checkout" mutation, and never needs PCI DSS SAQ A-EP scope beyond redirecting to a URL. That is a meaningful reduction in liability and engineering surface area compared to rolling your own payment UI, and it is one of the strongest arguments for staying on Shopify's commerce backend even when you go fully headless on the frontend.
Step 1: Create a Cart
Everything starts with `cartCreate`. You typically call this the moment a shopper adds their first item, not on page load — creating an empty cart per visitor on every page view wastes API calls and clutters Shopify's cart storage.
A minimal creation mutation looks like this: send a `cartCreate` mutation with an `input` containing a `lines` array of `{ merchandiseId, quantity }` pairs, where `merchandiseId` is the GID of a product variant, not the product itself. Request back `cart { id, checkoutUrl, totalQuantity, cost { totalAmount { amount, currencyCode } }, lines(first: 50) { nodes { id, quantity, merchandise { ... on ProductVariant { id, title, product { title, handle } } } } } }`. The response's `cart.id` is what you persist client-side — almost always in a cookie or `localStorage` — so subsequent visits reuse the same cart instead of creating a new one every session.
One detail that trips up a lot of first-time implementations: cart IDs are not permanent. Shopify cart objects expire after a period of inactivity (historically around 10 days, though Shopify does not publish a hard contractual guarantee on this window). Your frontend needs a fallback path — attempt `cartFetch` (via `cart(id: ...)` query) on load, and if it returns null or errors, silently fall back to `cartCreate` and overwrite the stored ID. Skipping this fallback is the single most common cause of "add to cart does nothing" bug reports in production headless storefronts, months after launch, once the first wave of carts ages out.
Step 2: Mutating the Cart
Once a cart exists, everything else is a mutation against its ID. The core set you will use constantly:
- cartLinesAdd — add one or more variants to an existing cart. Batch multiple additions into a single mutation call rather than looping one-at-a-time; each round trip costs latency and API rate-limit budget.
- cartLinesUpdate — change quantity on an existing line, by line ID (not variant ID — fetch the line's `id` from the cart query first).
- cartLinesRemove — remove one or more lines by line ID. Setting quantity to zero via `cartLinesUpdate` is not equivalent and will error; use the dedicated remove mutation.
- cartDiscountCodesUpdate — apply or clear discount codes. This *replaces* the full list of applied codes, so if you support multiple stacked codes you must re-send the entire set, not just the new one.
- cartBuyerIdentityUpdate — attach email, phone, or a country/province delivery address so the cart can return accurate shipping estimates and, for logged-in shoppers, associate the cart with a customer account token.
- cartAttributesUpdate / cartNoteUpdate — attach custom key-value metadata or a free-text note, useful for gift messages, referral tracking, or passing UTM data through to the order.
Every one of these mutations returns the full updated `cart` object plus a `userErrors` array. Always request `userErrors { field, message }` and check it before trusting the response — GraphQL will still return HTTP 200 with a populated cart even when the mutation logically failed (for example, adding a quantity that exceeds available inventory), so a naive "check for HTTP error" pattern will silently ship broken state to your UI.
Step 3: Reading Cost, Tax, and Shipping Estimates
The `cart.cost` field is where a lot of subtlety lives. It exposes `subtotalAmount`, `totalAmount`, `totalTaxAmount`, and `totalDutyAmount`, but the accuracy of tax and duty figures depends entirely on whether you've supplied a `buyerIdentity` delivery address. Without one, Shopify estimates based on the shop's default market and the numbers you show pre-checkout can differ from what the shopper actually pays on the hosted checkout page.
If your storefront design shows a running order summary in a cart drawer — the common pattern — set expectations honestly: label it as an estimate until an address is known, and only claim exact tax figures once `cartBuyerIdentityUpdate` has a real delivery address attached. This is a genuinely common source of support tickets ("your site said $42, checkout says $45") and it is a copy problem as much as an engineering one.
Step 4: Handing Off to Checkout
When the shopper is ready to pay, you do exactly one thing: redirect the browser to `cart.checkoutUrl`. That is the entire "checkout" implementation on your end. The URL is a signed, cart-scoped link into Shopify's hosted checkout, which:
- Renders using the shop's checkout branding settings (configured in Shopify admin, largely independent of your headless frontend's design system).
- Handles address entry, shipping method selection, tax and duty calculation against real carrier and jurisdiction data, and payment collection.
- Supports Shop Pay, accelerated checkouts, and installment providers automatically if the merchant has them enabled — none of which you need to integrate yourself.
- Completes the order and, on success, can redirect back to a `thank_you` page on your domain if you've configured a custom post-purchase redirect in the Shopify admin (Shopify Plus) or rely on the default Shopify-hosted order status page otherwise.
A subtlety worth internalizing early: once the shopper lands on `checkoutUrl`, your cart object and the checkout session are no longer the same thing. If they abandon checkout and return to your site, your stored cart ID is still valid and still reflects their cart *as it was before checkout* — Shopify does not write back partial checkout progress (like an entered address) into the cart object. Design your "returning from an abandoned checkout" state with that in mind rather than assuming continuity.
Handling Variant Selection and Inventory Correctly
A recurring source of bugs in custom storefronts is conflating a product with its variants. The cart only ever holds variant IDs. When a shopper picks options (size, color, lens type — whatever the product's option set is), your frontend needs to resolve that combination to a single `ProductVariant` GID via the `selectedOptions` matching logic before calling `cartLinesAdd`. Query the product with `variants(first: 250) { nodes { id, selectedOptions { name, value }, availableForSale, quantityAvailable } }` and match client-side, or use the newer `variantBySelectedOptions` query field if your API version supports it — it does the matching server-side and is less error-prone than hand-rolled option-combination logic.
Always check `availableForSale` before allowing an add-to-cart action, and separately handle the case where a variant is technically for sale but has `quantityAvailable` below the requested quantity — Shopify's `cartLinesAdd` will return a `userErrors` entry for oversell attempts on inventory-tracked variants, and your UI should translate that into a clear "only N left" message rather than a generic error toast.
Authenticated Customers and Cart Persistence
If your storefront supports customer accounts, associate the cart with the logged-in shopper by passing a customer access token (or, on the newer Customer Account API, an OAuth-derived token) through `cartBuyerIdentityUpdate`'s `customerAccessToken` field. This lets Shopify pre-fill known addresses at checkout and, more importantly, lets you merge a guest cart into an account cart at login time rather than losing it — a pattern worth building deliberately, since Shopify does not merge carts automatically on your behalf.
A reasonable merge strategy: on successful login, read the items in the guest cart, and if the shopper's account has no existing active cart, either reassign buyer identity onto the guest cart or create a fresh cart seeded from the guest cart's lines, then discard the old cart ID from storage. Either approach works; what matters is deciding on one deliberately instead of letting whichever cart object happens to load last silently win.
Rate Limits and Caching
The Storefront API uses a leaky-bucket cost-based rate limit rather than a flat requests-per-second cap — every query and mutation carries a calculated cost, and each response includes an `extensions.cost` block showing what you spent and what's left in the bucket. Cart mutations are usually cheap individually, but a storefront that re-fetches the full cart object (with a deeply nested `lines` selection) after every single quantity click, on every keystroke of a debounce-free stepper, will burn through budget faster than expected under real traffic. Debounce quantity-stepper mutations, and consider optimistic UI updates client-side while the mutation is in flight rather than round-tripping and blocking on every click.
Cart reads are inherently un-cacheable at the CDN level — they are shopper-specific and mutate constantly — so don't try to route cart queries through the same static-generation or ISR caching layer you'd use for product and collection pages. Keep cart fetches as client-side, uncached network calls, and reserve server-side rendering and caching for the product catalog data that actually is stable across visitors.
Where This Fits in a Headless Architecture
The cart-then-hosted-checkout split is part of why headless commerce on Shopify is a genuinely practical choice today, not just a theoretical one. You get full control over the browsing and cart experience — which is where a custom design system, a bespoke Next.js frontend, or a component-driven build actually pays off in conversion — while Shopify keeps ownership of the parts (payment, tax, fraud, PCI scope) that are expensive and risky to reinvent. This is a meaningfully different trade-off than fully custom commerce stacks built on something like Medusa, where you also own the checkout and payment orchestration layer yourself; the Shopify model trades some checkout-page design freedom for a much smaller surface area to build and secure.
If you're evaluating design assets for a Shopify-backed headless build, it's still worth studying how a well-built native Shopify theme handles the same cart-and-option problems — variant selection UX, option-group clarity, and cart-drawer patterns transfer directly into a headless build even when the rendering layer is completely different. Our Shopify theme catalog is a reasonable place to see those patterns applied across different product categories, and our Figma UI kits are useful references when you're designing the equivalent headless components from scratch rather than starting from a theme's Liquid templates.
A Minimal End-to-End Flow, Summarized
- On first add-to-cart, check for a stored cart ID; if none exists or it fails to resolve, call cartCreate with the initial line item and persist the returned `cart.id`.
- On every subsequent add, resolve the chosen options to a variant GID (via client-side matching or `variantBySelectedOptions`), check `availableForSale` and `quantityAvailable`, then call cartLinesAdd.
- Render the cart drawer or page from the cart object's `lines` and `cost` fields directly — treat the API response as the source of truth rather than maintaining a separate local cart state that can drift out of sync.
- Apply discount codes via cartDiscountCodesUpdate, attach buyer identity (email, address, customer token) via cartBuyerIdentityUpdate as soon as it's known, and always surface `userErrors` in the UI.
- On checkout intent, redirect to `cart.checkoutUrl` and let Shopify's hosted checkout handle the rest.
Frequently Asked Questions
Can I build a fully custom checkout page instead of redirecting to Shopify's hosted checkout?
Not through the standard Storefront API on most plans — the checkout mutations that used to allow this are deprecated, and Shopify pushes all storefronts toward the hosted `checkoutUrl` flow. Shopify Plus merchants have access to Checkout Extensibility for customizing the hosted checkout's branding, fields, and post-purchase flow, but that is customization within Shopify's checkout, not a replacement for it.
What happens if a cart ID expires while a shopper still has items in localStorage?
Your `cart(id: ...)` query will return null or an error for the expired ID. Handle this by falling back to `cartCreate` transparently — the shopper's line items are lost at that point unless you separately cache them client-side to re-add to the fresh cart, which is worth doing for a smoother recovery experience.
Does the cart object handle subscriptions or pre-orders?
Subscription purchase intent is typically carried through cart line attributes or selling plan associations (`sellingPlanId` on a cart line, if the merchant has subscription apps configured with selling plans), rather than being a native cart-level concept. Pre-orders are usually just inventory-policy configuration on the variant (allowing overselling) combined with a note in your UI rather than a distinct API object.
Is the Storefront API the same thing as the Admin API?
No, and mixing them up is a common early mistake. The Storefront API is public-facing, token-scoped to what a shopper should be able to read and mutate (products, carts, their own customer data), and safe to call from a browser. The Admin API is privileged, server-side only, and covers store management — orders, inventory adjustments, fulfillment — that should never be reachable from client-side code.