Hydrogen’s cart handler is a server-side object on context.cart that owns every Storefront API cart mutation for you. You configure it once in your app context with a way to read and write the cart ID cookie, then call cart.addLines(), cart.updateLines() and the rest from a single /cart action route.
That single route is the part that reads oddly if you have come from a theme build, where any component can fire its own fetch at /cart/add.js. Hydrogen funnels the lot through one action because of where the cart ID cookie has to be written.
What does the cart handler actually do?
createCartHandler wraps the Storefront API cart mutations and hides the two things that make carts tedious to write by hand.
First, the cart might not exist yet. addLines, updateAttributes, updateBuyerIdentity, updateDiscountCodes, updateNote and setMetafields all run cartCreate for you when getCartId() returns undefined. Second, every method resolves to the same shape, {cart, errors, warnings}, so your route code never has to remember whether the payload field was cartLinesAdd or cartLinesRemove.
The method list as of the 2026-04 reference: get, getCartId, setCartId, create, addLines, updateLines, removeLines, updateDiscountCodes, addGiftCardCodes, removeGiftCardCodes, updateGiftCardCodes, updateBuyerIdentity, updateNote, updateAttributes, updateSelectedDeliveryOption, addDeliveryAddresses, updateDeliveryAddresses, removeDeliveryAddresses, replaceDeliveryAddresses, setMetafields and deleteMetafield.
If you want the raw mutations sitting underneath all that, we went through them in the Storefront API cart walkthrough. The handler is those, minus the boilerplate.
How do you set it up?
In a current Hydrogen project you never call createCartHandler yourself. createHydrogenContext does it, and you pass the cart options through:
// app/lib/context.ts
import {
createHydrogenContext,
cartGetIdDefault,
cartSetIdDefault,
} from '@shopify/hydrogen';
import {AppSession} from '~/lib/session';
import {CART_QUERY_FRAGMENT} from '~/lib/fragments';
export async function createAppLoadContext(
request: Request,
env: Env,
executionContext: ExecutionContext,
) {
const waitUntil = executionContext.waitUntil.bind(executionContext);
const [cache, session] = await Promise.all([
caches.open('hydrogen'),
AppSession.init(request, [env.SESSION_SECRET]),
]);
const hydrogenContext = createHydrogenContext({
env,
request,
cache,
waitUntil,
session,
i18n: {language: 'EN', country: 'GB'},
cart: {
queryFragment: CART_QUERY_FRAGMENT,
getId: cartGetIdDefault(request.headers),
setId: cartSetIdDefault({maxage: 60 * 60 * 24 * 365}),
},
});
return {...hydrogenContext};
}
Both defaults are about ten lines of source each, and the shape they agree on matters the moment you consider replacing them. cartGetIdDefault parses the Cookie header, reads a cookie named exactly cart, and returns gid://shopify/Cart/${cookies.cart}. cartSetIdDefault goes the other way: it takes the full GID, keeps only the segment after the last slash, and writes that back with path: '/' plus whatever cookie options you handed it. The cookie therefore holds a bare ID while the handler works in GIDs. Store the whole GID in the cookie yourself and the next read hands the Storefront API gid://shopify/Cart/gid://shopify/Cart/c1-abc.
Pass no options and you get a session cookie, so the basket dies when the shopper closes the tab. maxage is in seconds; the skeleton uses a year.
Why does every mutation go through one /cart route?
Because setCartId returns a Headers object, and headers only reach the browser on the response that carries them. A first-time shopper adding a line implicitly creates a cart, and that brand new ID has to be written to a cookie in the same response or it is lost. One action route makes that easy to guarantee.
// app/routes/cart.tsx
import {data, type HeadersFunction} from 'react-router';
import type {Route} from './+types/cart';
import {CartForm, type CartQueryDataReturn} from '@shopify/hydrogen';
export const headers: HeadersFunction = ({actionHeaders}) => actionHeaders;
export async function action({request, context}: Route.ActionArgs) {
const {cart} = context;
const formData = await request.formData();
const {action, inputs} = CartForm.getFormInput(formData);
if (!action) throw new Error('No action provided');
let status = 200;
let result: CartQueryDataReturn;
switch (action) {
case CartForm.ACTIONS.LinesAdd:
result = await cart.addLines(inputs.lines);
break;
case CartForm.ACTIONS.LinesUpdate:
result = await cart.updateLines(inputs.lines);
break;
case CartForm.ACTIONS.LinesRemove:
result = await cart.removeLines(inputs.lineIds);
break;
default:
throw new Error(`${action} cart action is not defined`);
}
const cartId = result?.cart?.id;
const responseHeaders = cartId ? cart.setCartId(cartId) : new Headers();
const redirectTo = formData.get('redirectTo') ?? null;
if (typeof redirectTo === 'string') {
status = 303;
responseHeaders.set('Location', redirectTo);
}
return data(
{cart: result.cart, errors: result.errors, warnings: result.warnings},
{status, headers: responseHeaders},
);
}
export async function loader({context}: Route.LoaderArgs) {
return await context.cart.get();
}
Two details there are easy to drop. The export const headers line hands the action’s headers to the document response, which is what keeps the Set-Cookie alive on a submission that is not going through a fetcher. And the return is data() from react-router, not json(): Hydrogen moved onto React Router 7 in version 2025.5.0, and the skeleton now runs react-router 7.16.0, where json is deprecated. Hydrogen tutorials written before that move still show it.
What does CartForm send?
CartForm is a wrapper around a React Router fetcher form. Give it a route, one of the CartForm.ACTIONS constants and an inputs object, and it serialises all of that into a single form field that CartForm.getFormInput unpacks on the server.
// app/components/AddToCartButton.tsx
import {type FetcherWithComponents} from 'react-router';
import {CartForm, type OptimisticCartLineInput} from '@shopify/hydrogen';
export function AddToCartButton({
children,
disabled,
lines,
}: {
children: React.ReactNode;
disabled?: boolean;
lines: Array<OptimisticCartLineInput>;
}) {
return (
<CartForm route="/cart" inputs={{lines}} action={CartForm.ACTIONS.LinesAdd}>
{(fetcher: FetcherWithComponents<any>) => (
<button type="submit" disabled={disabled ?? fetcher.state !== 'idle'}>
{children}
</button>
)}
</CartForm>
);
}
The render-prop form hands you the fetcher, which is where the pending state comes from without writing any of your own. fetcherKey exists for when two forms on a page need to share a submission, or need to stop sharing one.
The constants go well past lines: Create, AttributesUpdateInput, BuyerIdentityUpdate, DiscountCodesUpdate, GiftCardCodesAdd, GiftCardCodesRemove, NoteUpdate, SelectedDeliveryOptionsUpdate, MetafieldsSet, MetafieldsDelete, four DeliveryAddresses variants, and a Custom{string} escape hatch for actions you invent.
How do you make the cart feel instant?
useOptimisticCart reads the in-flight fetchers, applies their pending mutations to the cart you already have, and returns a cart carrying isOptimistic: true on the object and on each unconfirmed line.
import {useOptimisticCart} from '@shopify/hydrogen';
export function CartLines({cart: originalCart}) {
const cart = useOptimisticCart(originalCart);
return (
<ul>
{(cart?.lines?.nodes ?? []).map((line) => (
<li key={line.id}>
{line.merchandise.title}
<RemoveButton lineId={line.id} disabled={!!line.isOptimistic} />
</li>
))}
</ul>
);
}
That disabled is not decoration. A line that so far only exists in the browser has no server-side line ID, so a remove aimed at it produces [h2:error:useOptimisticCart] Tried to remove an optimistic line that has not been added to the cart yet.
The other message you will meet is No selected variant was passed in the cart action. The hook can only render a line whose shape it knows, so the input has to carry the variant next to the ID:
lines={[{merchandiseId: selectedVariant.id, quantity: 1, selectedVariant}]}
Only LinesAdd, LinesUpdate and LinesRemove get optimistic treatment. A discount code or a buyer identity change waits for the server, which is the right call: neither has a client-side answer you could trust.
When should you customise the handler?
There are three points of customisation, and they differ mainly in how much of Shopify’s own logic you take on.
cartQueryFragment changes what cart.get() returns. cartMutateFragment changes what the mutations return, and it is deliberately leaner by default, because sending a forty-field cart back on every quantity nudge is payload nobody reads. If your mini-cart needs checkoutUrl the instant an add resolves, that is the fragment to widen.
customMethods is the one to be careful with. Shopify’s customisation guide puts the warning plainly: the default methods are where the create-cart logic lives, so overriding addLines or updateAttributes means writing that part yourself. Adding a method alongside the defaults costs nothing; replacing one means owning its behaviour from then on. The example worth copying is a combined add-and-remove built from cartLinesAddDefault and cartLinesRemoveDefault, for a variant swap that should count as one action rather than two.
What I would do
Keep the skeleton’s cart route roughly as it ships and spend the effort on the fragments instead. The default cart query is generous, and on a storefront whose header shows a count and nothing more, most of those fields are fetched on every navigation and thrown away. The cart is personal to the shopper, so this is not a query you can hide behind the sub-request caching we covered in the Hydrogen caching write-up. Trim cartQueryFragment to what you actually render, keep cartMutateFragment tighter still.
Leave customMethods alone until something forces your hand. Bundles and gift-with-purchase are genuine reasons to reach for it, but reaching early gives up the create-cart handling you were given for free and buys nothing back.
Whoooop builds headless Shopify storefronts on Hydrogen and Oxygen, cart plumbing and caching included. Our Shopify development page covers how we approach that work.