The Shopify Storefront API's cart model is designed for headless commerce: create a cart, add and remove lines, update quantities, apply discount codes, and bind the cart to a buyer identity — all without touching a cookie-based session. When the buyer is ready to check out, redirect them to the checkoutUrl on the Cart object and Shopify handles the rest. This tutorial walks through every cart mutation in detail, explains how to manage the cart token on the client side, and covers edge cases like duplicate line item merging and expired cart recovery.
What You'll Build
A TypeScript cart service that wraps the four core Storefront API cart mutations — cartCreate, cartLinesAdd, cartLinesUpdate, and cartBuyerIdentityUpdate — plus a helper to read the cart back and a client-side state management pattern using localStorage to persist the cart ID across page loads.
Before You Start
You need a Shopify store with the Storefront API enabled and a Storefront API access token (create one in the store admin under Apps > Develop apps, or use the token from your Hydrogen app's .env file). The Storefront API endpoint is https://{shop}.myshopify.com/api/2024-01/graphql.json — calls must include the X-Shopify-Storefront-Access-Token header. The token is safe to expose in client-side code (it is not a secret), but keep it in an environment variable for easy rotation.
Set up a lightweight GraphQL client
You don't need a heavy client like Apollo for the Storefront API — a small wrapper around fetch is sufficient. Hydrogen uses @shopify/storefront-api-client — a thin package that sets the right headers and handles network errors. Install it with npm install @shopify/storefront-api-client and instantiate it once.
// lib/storefront.ts
import { createStorefrontApiClient } from '@shopify/storefront-api-client';
export const storefrontClient = createStorefrontApiClient({
storeDomain: process.env.NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN!,
apiVersion: '2024-01',
publicAccessToken: process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_TOKEN!,
});Expected outcome: A singleton client you import into all cart service functions.
Create a cart with cartCreate
Call cartCreate the first time a user adds something to their cart. The mutation accepts an optional lines array — you can create the cart and add the first item in a single round trip. Each line requires a merchandiseId (a product variant GID) and a quantity. You can also pass attributes (key-value pairs) on individual lines for custom engraving text, gift notes, etc.
Store the returned cart.id in localStorage immediately. The cart ID is a GID like gid://shopify/Cart/abc123 and is stable across API versions — you can retrieve the cart by ID for up to 10 days after the last cart activity.
// lib/cart/createCart.ts
import { storefrontClient } from '../storefront';
const CREATE_CART_MUTATION = `#graphql
mutation CartCreate($lines: [CartLineInput!], $country: CountryCode)
@inContext(country: $country) {
cartCreate(input: { lines: $lines }) {
cart {
id
checkoutUrl
totalQuantity
cost {
subtotalAmount { amount currencyCode }
totalAmount { amount currencyCode }
}
lines(first: 100) {
edges {
node {
id
quantity
merchandise {
... on ProductVariant {
id
title
product { title }
price { amount currencyCode }
}
}
}
}
}
}
userErrors { field message code }
}
}
`;
export async function createCart(
lines: Array<{ merchandiseId: string; quantity: number }>,
countryCode = 'US',
) {
const { data, errors } = await storefrontClient.request(CREATE_CART_MUTATION, {
variables: { lines, country: countryCode },
});
if (errors) throw new Error(JSON.stringify(errors));
const cart = data?.cartCreate?.cart;
if (cart) localStorage.setItem('shopify_cart_id', cart.id);
return cart;
}Expected outcome: A cart is created with the first line item. The cart ID is stored in localStorage for subsequent operations.
Add lines with cartLinesAdd
After the cart exists, use cartLinesAdd to add more items. Pass the cart ID and an array of CartLineInput objects. If you add a variant that is already in the cart, Shopify does not automatically merge it with the existing line — it creates a second line with that variant. To merge, you must first check whether the variant already has a line in the cart and use cartLinesUpdate to increment its quantity instead.
// lib/cart/addLines.ts
import { storefrontClient } from '../storefront';
const ADD_LINES_MUTATION = `#graphql
mutation CartLinesAdd($cartId: ID!, $lines: [CartLineInput!]!) {
cartLinesAdd(cartId: $cartId, lines: $lines) {
cart {
id
totalQuantity
lines(first: 100) {
edges { node { id quantity merchandise { ... on ProductVariant { id title } } } }
}
}
userErrors { field message code }
}
}
`;
export async function addCartLines(
cartId: string,
lines: Array<{ merchandiseId: string; quantity: number; attributes?: Array<{ key: string; value: string }> }>,
) {
const { data, errors } = await storefrontClient.request(ADD_LINES_MUTATION, {
variables: { cartId, lines },
});
if (errors) throw new Error(JSON.stringify(errors));
return data?.cartLinesAdd?.cart;
}Expected outcome: New line items appear in the cart. Watch for `userErrors` — a common one is `MERCHANDISE_NOT_APPLICABLE` when the variant is sold out or unavailable.
Update quantities with cartLinesUpdate
To change the quantity of an existing line (including removing it by setting quantity to 0), use cartLinesUpdate. Each update object requires the line id (not the variant ID — use the CartLine.id from the cart query) and the new quantity. Setting quantity: 0 removes the line from the cart. You can update multiple lines in a single mutation call.
// lib/cart/updateLines.ts
import { storefrontClient } from '../storefront';
const UPDATE_LINES_MUTATION = `#graphql
mutation CartLinesUpdate($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
cartLinesUpdate(cartId: $cartId, lines: $lines) {
cart {
id
totalQuantity
cost { totalAmount { amount currencyCode } }
lines(first: 100) {
edges { node { id quantity } }
}
}
userErrors { field message }
}
}
`;
export async function updateCartLines(
cartId: string,
lines: Array<{ id: string; quantity: number }>,
) {
const { data, errors } = await storefrontClient.request(UPDATE_LINES_MUTATION, {
variables: { cartId, lines },
});
if (errors) throw new Error(JSON.stringify(errors));
return data?.cartLinesUpdate?.cart;
}Expected outcome: The cart reflects the updated quantities. Lines with quantity 0 are removed from the cart.lines response.
Bind a buyer with cartBuyerIdentityUpdate
When a customer logs in, associate their Shopify account with the cart using cartBuyerIdentityUpdate. Pass a customerAccessToken obtained from the customerAccessTokenCreate mutation. This unlocks personalised pricing (B2B catalogs, customer-tagged discounts), pre-populated address at checkout, and loyalty points. You can also pass countryCode here to switch the cart's currency context without creating a new cart.
Note: if your store uses Shopify Markets, pass countryCode in the @inContext directive on your query in addition to the buyer identity — the directive context controls which market's prices are returned, while the buyer identity field is persisted on the cart for checkout.
// lib/cart/updateBuyerIdentity.ts
import { storefrontClient } from '../storefront';
const UPDATE_BUYER_MUTATION = `#graphql
mutation CartBuyerIdentityUpdate($cartId: ID!, $buyerIdentity: CartBuyerIdentityInput!) {
cartBuyerIdentityUpdate(cartId: $cartId, buyerIdentity: $buyerIdentity) {
cart {
id
buyerIdentity {
email
customer { displayName }
countryCode
}
cost {
subtotalAmount { amount currencyCode }
}
}
userErrors { field message }
}
}
`;
export async function updateBuyerIdentity(
cartId: string,
customerAccessToken: string,
countryCode?: string,
) {
const buyerIdentity: Record<string, unknown> = { customerAccessToken };
if (countryCode) buyerIdentity.countryCode = countryCode;
const { data, errors } = await storefrontClient.request(UPDATE_BUYER_MUTATION, {
variables: { cartId, buyerIdentity },
});
if (errors) throw new Error(JSON.stringify(errors));
return data?.cartBuyerIdentityUpdate?.cart;
}Expected outcome: The cart is now linked to the customer account. The checkout URL will pre-populate the customer's address and show B2B pricing if applicable.
What's Next
Consider adding cartDiscountCodesUpdate to support promotional codes, and cartAttributesUpdate to attach order-level custom fields (order notes, gift wrapping flags). For a production Hydrogen app, encapsulate all cart mutations in Remix action functions and use the useOptimisticCart hook from @shopify/hydrogen to provide instant UI feedback while the mutation is in-flight.
