Cart
Goal
Build a cart that adds products, changes quantities, removes line items and shows totals. The important part is not the list rendering, but that every cart write returns the whole recalculated cart, and that useCart is a shared composable holding one cart for the entire application.
Shopware Flow
A cart write is not a local mutation. POST /checkout/cart/line-item, PATCH /checkout/cart/line-item and POST /checkout/cart/line-item/delete all respond with the complete cart, recalculated in the current sales channel context. Line item prices, delivery costs, promotions and the errors map are part of that response.
What the Store API does not do is tell you which line item changed. There is no partial update to merge, so useCart simply replaces the shared cart value with the response. That is also why an extra readCart get /checkout/cart after a write is wasted work.
Hover a type chip to inspect fields generated from the current Store API schema.
Step 1
UI: Add a product
A product card calls useAddToCart with a product ref. The component owns the quantity input and the pending flag, never a copy of the cart.
- Code
useAddToCart(product).addToCart()- State
- quantity, isInCart
- Types
- Product
Read the diagram from left to right:
- UI — the customer adds a product, changes a quantity or removes a line item.
- Composable —
useAddToCartoruseCartItemdelegates to the matchinguseCartmethod, which calls one line item operation throughapiClient.invokewith the currentsw-context-token. - Store API — the cart is recalculated in the current sales channel context and returned in full.
- Shared state —
useCartreplaces the sharedswCartvalue with that response. - Errors — any
errorsfrom the same response are merged intoswCartErrors, for a notification layer to consume once. - UI — components read
cartItems,count,subtotalandtotalPricefrom composables instead of keeping their own copy.
You do not need to call refreshCart() after a write. Use it on the initial page load, or after the customer session changes. useUser().login() and logout() do fire refreshCart() themselves, but they do not await it — only register() does — so code that renders prices straight after a session change should await refreshCart() itself.
Request Flow
| Step | Code | Store API | Type |
|---|---|---|---|
| Load the cart | refreshCart() | GET /checkout/cart | readCart response |
| Add a product | addProduct({ id, quantity }) | POST /checkout/cart/line-item | addLineItem body |
| Add a promotion code | addPromotionCode(code) | POST /checkout/cart/line-item | addLineItem body |
| Change a quantity | changeProductQuantity({ id, quantity }) | PATCH /checkout/cart/line-item | updateLineItem body |
| Remove a line item | removeItem(lineItem) | POST /checkout/cart/line-item/delete | removeLineItem body |
| Discard the cart | apiClient.invoke("deleteCart delete /checkout/cart") | DELETE /checkout/cart | deleteCart response |
deleteCart delete /checkout/cart has no composable wrapper. Call it through apiClient.invoke and then await refreshCart(), because nothing updates the shared cart for you. Await it and handle a rejection: if the delete succeeds and the refresh fails, the shared cart still holds the discarded cart, which is the stale state described under Edge Cases.
If you drop down to apiClient.invoke for a bulk quantity update, note that updateLineItem's items is a non-empty tuple ([{ id, quantity }, ...{ id, quantity }[]]), not a plain array — a mapped LineItem[] does not satisfy it. Build it as [first, ...rest] or assert the type.
Composables
Pick by scope — how much of the cart the composable is about:
| Composable | Scope | Reach for it when |
|---|---|---|
useCart | the whole cart | reading totals or writing any line item |
useCartItem | one line item | building a row component |
useAddToCart | one product | building a product card or detail page |
useCartNotification | the collected errors | showing the customer what a 2xx response complained about |
useCartErrorParamsResolver | one CartError | translating that error instead of printing it raw |
useCart is the one you reach for most:
- Read —
cart,cartItems,count,isEmpty,subtotal,totalPrice,shippingCosts,appliedPromotionCodes,isVirtualCart. - Write —
addProduct,addProducts,addPromotionCode,changeProductQuantity,removeItem,removeItemById,refreshCart. - Errors —
consumeCartErrors()returns the collected cart errors and clears them.
Four things the generated reference will not tell you:
useCartItemtakes aRef<LineItem>and derives the whole row from it —itemTotalPrice,itemStock,itemImageThumbnailUrl,isStackable,isRemovableand the rest — so a row component needs no props beyond that one ref.useAddToCarttakes aRef<Product | undefined>. Theundefinedis deliberate: it lets you call the composable at the top level of setup while the product is still loading.useCartNotificationconsumes the collected cart errors, but its two methods do not hand you the same set.codeErrorsNotification()pushes every entry as a notification, usingpushSuccessfor the codes it treats as positive — today justpromotion-discount-added— andpushErrorfor everything else.getErrorsCodes()returnsCartError[]with exactly those positive codes dropped, so an accepted promotion code yields an empty array. Both callconsumeCartErrors(), so the first one you call clears them for the other. Pick one per response, and if you render the list yourself, confirm an applied promotion fromappliedPromotionCodesrather than from the errors map.useCartErrorParamsResolverreturnsresolveCartError(error), which maps aCartErrorto amessageKeyandparamsfor your translation layer.
The composables reference is generated from source and lists every member.
Types
Use generated Store API types when you need to type line item payloads, cart responses, or lower-level API client calls:
import type { Schemas, operations } from "#shopware";
type CartResponse = operations["readCart get /checkout/cart"]["response"];
type AddLineItemBody =
operations["addLineItem post /checkout/cart/line-item"]["body"];
type CartItems = AddLineItemBody["items"];
type Cart = Schemas["Cart"];
type LineItem = Schemas["LineItem"];
type CartError = Schemas["CartError"];
type CartDelivery = Schemas["CartDelivery"];
// Cart["errors"] is a union: either a CartError[] or a keyed map whose values
// carry an extra `code` and a widened `level`. Narrow it yourself when you read
// a raw response; through useCart it is always the map, and getErrorsCodes()
// hands you a CartError[].
type CartErrors = NonNullable<Schemas["Cart"]["errors"]>;addProducts() is typed with AddLineItemBody["items"], so one array carries products, custom bundles and promotions together — but items is a union discriminated on type. A "promotion" entry takes referencedId (the code) and leaves id and quantity optional; every other type requires id and quantity.
Minimal Vue Example
<script setup lang="ts">
import { ApiClientError } from "@shopware/api-client";
import type { Schemas } from "#shopware";
const {
cartItems,
count,
subtotal,
totalPrice,
isEmpty,
refreshCart,
changeProductQuantity,
removeItemById,
} = useCart();
const { getErrorsCodes } = useCartNotification();
// Start as loading so the first render shows the loading state instead of
// flashing "Your cart is empty." before the cart has arrived.
const isLoading = ref(true);
const loadError = ref("");
const pendingItemId = ref("");
const writeError = ref("");
const cartErrors = ref<Schemas["CartError"][]>([]);
// Only one write may be in flight: every write returns the whole recalculated
// cart, so two in parallel race and the slower response overwrites the faster.
const isWriting = computed(() => pendingItemId.value !== "");
// Load on the client: a cart rendered during SSR is baked into the ISR-cached
// HTML and served to every other visitor.
const loadCart = async () => {
isLoading.value = true;
loadError.value = "";
try {
await refreshCart();
} catch (error) {
console.error(error);
loadError.value = "Your cart could not be loaded.";
} finally {
// Errors that arrived with the initial cart belong to the load, not to the
// customer's next action.
cartErrors.value = getErrorsCodes();
isLoading.value = false;
}
};
onMounted(loadCart);
const runCartWrite = async (
item: Schemas["LineItem"],
write: () => Promise<Schemas["Cart"]>,
fallbackMessage: string
) => {
if (isWriting.value) return;
writeError.value = "";
pendingItemId.value = item.id;
try {
await write();
} catch (error) {
// Keep the real error for the developer, show the customer a mapped one.
console.error(error);
writeError.value =
error instanceof ApiClientError && error.status === 403
? "Your session has expired. Please sign in again."
: fallbackMessage;
} finally {
// Consume on both paths: a 2xx response can carry errors, and a rejected
// write must not strand earlier ones in the shared state.
cartErrors.value = getErrorsCodes();
pendingItemId.value = "";
}
};
const changeLineItemQuantity = (item: Schemas["LineItem"], value: string) => {
// min="1" constrains the stepper and validation, not the value you read here:
// a cleared field still reaches this handler as "", which parseInt turns into
// NaN - hence the isInteger guard rather than a bare > 0 check.
const quantity = Number.parseInt(value);
if (!Number.isInteger(quantity) || quantity < 1) return;
if (quantity === item.quantity) return;
return runCartWrite(
item,
() => changeProductQuantity({ id: item.id, quantity }),
"The quantity could not be updated."
);
};
const removeLineItem = (item: Schemas["LineItem"]) =>
runCartWrite(
item,
() => removeItemById(item.id),
"The item could not be removed."
);
</script>
<template>
<section>
<h1>Cart</h1>
<!-- role="alert" so a failed write is announced: the control the customer
used has just been re-enabled, so focus is nowhere near this message. -->
<p v-if="writeError" role="alert">{{ writeError }}</p>
<ul v-if="cartErrors.length" role="alert">
<li v-for="error in cartErrors" :key="error.key">{{ error.message }}</li>
</ul>
<p v-if="isLoading">Loading your cart…</p>
<div v-else-if="loadError" role="alert">
<p>{{ loadError }}</p>
<button type="button" @click="loadCart">Try again</button>
</div>
<p v-else-if="isEmpty">Your cart is empty.</p>
<div v-else>
<ul>
<li v-for="item in cartItems" :key="item.id">
<h2>{{ item.label }}</h2>
<!-- aria-disabled rather than disabled: a disabled control cannot
hold focus, so a keyboard user is thrown back to the top of the
document mid-interaction. The handler enforces the guard. -->
<label v-if="item.stackable">
<span>Quantity for {{ item.label }}</span>
<input
type="number"
min="1"
:value="item.quantity"
:aria-disabled="isWriting"
:aria-busy="pendingItemId === item.id"
@change="
changeLineItemQuantity(
item,
($event.target as HTMLInputElement).value
)
"
/>
</label>
<span v-else>Quantity: {{ item.quantity }}</span>
<span>Total: {{ item.price?.totalPrice }}</span>
<button
v-if="item.removable"
type="button"
:aria-label="`Remove ${item.label} from cart`"
:aria-disabled="isWriting"
:aria-busy="pendingItemId === item.id"
@click="removeLineItem(item)"
>
Remove
</button>
</li>
</ul>
<dl aria-live="polite">
<dt>Items</dt>
<dd>{{ count }}</dd>
<dt>Subtotal</dt>
<dd>{{ subtotal }}</dd>
<dt>Total</dt>
<dd>{{ totalPrice }}</dd>
</dl>
</div>
</section>
</template>useCartItem is not used here on purpose. It takes a Ref<LineItem> and must be called at the top level of a row component's setup, not inside a click handler in the list component. Extracting each <li> into its own row component is the natural next step, and it is what lets a row own its pending state.
The errors are rendered inline rather than pushed through codeErrorsNotification(), so the example stands on its own. codeErrorsNotification() only writes into useNotifications() state — it renders nothing by itself, so it needs a notification outlet mounted somewhere above it, as vue-starter-template does with <LayoutNotifications /> in its layouts.
The example owns its initial load. vue-starter-template already calls refreshCart() once in app.vue, so inside that template drop the onMounted(loadCart) call here rather than fetching the cart twice on hydration — and start isLoading at false when you do. It is only ever cleared by loadCart, so removing the call without changing the initial value leaves the page showing "Loading your cart…" forever.
State And Session
The cart belongs to the sales channel session identified by the sw-context-token header, not to the customer, and the Store API resolves the cart for the token it receives. A guest therefore has a cart — and it is not lost when they log in. Shopware merges the guest cart into the customer's saved cart on the server, so there is nothing for the frontend to merge: you re-read the cart and the merged result is what arrives, which is why login() triggers refreshCart().
A merged cart carries a cart-merged-hint notice in its errors map, which the templates translate under errors.cart-merged-hint. It is informational, but codeErrorsNotification() treats only promotion-discount-added as a success, so it reaches the customer as an error notification unless you special-case it.
useCart is wrapped in createSharedComposable, so every call in the application returns the same instance. The cart itself lives in the swCart context value and the collected errors in swCartErrors, which is what makes a mini cart in the header and a cart page stay in sync without any prop passing or store of your own.
The sharing is client-only, and deliberately so. On the server createSharedComposable returns the plain composable rather than a cached instance, and useContext provides through injectLocal/provideLocal, which is scoped to the app instance — and Nuxt builds one app per request. Each request therefore renders with its own cart, and no state crosses between customers.
Fetch the cart on the client anyway. Load it from onMounted (or behind import.meta.client), as the example and the starter's own app.vue do. The reason is caching, not leakage. vue-starter-template applies isr to /** and opts only /checkout and /checkout/** out of it with ssr: false, so the cart page itself is safe — but a mini cart in the header renders on every catalog and CMS route, and those responses are cached and served to every other visitor. Personalized data does not belong in an ISR-cached response.
Customer-specific prices, promotions and rules change with the customer context, so the cart has to be re-read when the session changes. useUser().login() and logout() call refreshCart() internally — but neither awaits it, and only register() does. Right after await login() resolves, the shared cart is still the pre-login guest cart for one more round trip, so await refreshCart() yourself if you render prices immediately after a session change.
Edge Cases
countonly sums line items wheregoodistrue, so a promotion line item is visible incartItemsbut does not raise the item count.subtotalreadscart.price.positionPriceandtotalPricereadscart.price.totalPrice. They differ once shipping costs or promotions apply — do not compute either from the line items yourself.- A line item with
stackable: falsemust not render a quantity input, and one withremovable: falsemust not render a remove button. Both flags come from the cart response. - Adding a product that is already in the cart stacks onto the existing line item instead of creating a second one, so
cartItems.lengthdoes not change. Do not decide whether an add succeeded by counting rows — readcount. - The server can accept less than you asked for when stock runs out, so
countmay grow by less than the quantity you sent. The write still returns2xxand the reason arrives as aproduct-stock-reachedentry inerrors. consumeCartErrors()clearsswCartErrors. If two components call it for the same response, only the first one sees the errors — andcodeErrorsNotification()andgetErrorsCodes()both call it, so calling one after the other for the same response leaves the second empty.Cart["errors"]is a union at the type level: aCartError[]or a keyed map. ThroughuseCartyou always end up with the map — it merges the response withObject.assign, which turns an array into{ "0": … }. The array form only reaches you on a raw response you read yourself, and bothcodeErrorsNotification()andgetErrorsCodes()return empty when handed one, so narrow the union before passing it to either.- Cart errors are merged into
swCartErrorsand never cleared by a later clean response. Consume them after the initialrefreshCart()too, or the first write will surface load-time errors as if they belonged to that write. isVirtualCartisfalsefor an empty cart and ignores promotion line items, so use it to decide whether a shipping step is needed, not whether the cart has content.DELETE /checkout/cartleaves the shared cart value untouched. Without a followingrefreshCart()the UI keeps rendering a cart the Store API has already discarded — and the same stale state appears if the delete succeeds but the refresh rejects, so await the refresh and handle its failure.
Common Mistakes
- Do not keep a local copy of the cart or of the item count. Read them from
useCart(). - Do not call
refreshCart()after every write. The write response already is the new cart. - Do not treat a
2xxresponse as success for the customer. Check theerrorsmap, which reports stock limits, blocked shipping methods and invalid promotion codes with a2xxstatus. - Do not use
deleteCart delete /checkout/cartwithout refreshing afterwards. - Do not compute totals in the template from
unitPrice * quantity. Tax handling and promotions make that wrong in most configurations. - Do not let two cart writes run in parallel. Each one returns the whole cart, so the slower response overwrites the faster one and the UI settles on a cart that is missing a change.
- Do not trust
min="1"on a quantity input. It constrains the stepper and validation, not the value you read — a cleared field hands you"", whichNumber()turns into0andparseInt()intoNaN. Neither is a quantity, so validate before you send. - Do not render the raw
messageof an API exception. Map cart errors throughuseCartNotificationoruseCartErrorParamsResolverinstead.
Testing Checklist
- Adding a product calls
addLineItem post /checkout/cart/line-itemonce and updatescountandtotalPrice. - Adding the same product twice results in one line item with the summed quantity.
- Changing a quantity calls
updateLineItem patch /checkout/cart/line-itemand recalculatessubtotal. - Removing a line item calls
removeLineItem post /checkout/cart/line-item/deleteand empties the cart when it was the last item. - A promotion code adds a line item of type
promotionthat appears inappliedPromotionCodesbut not incount. - A response carrying an
errorsentry surfaces those errors to the customer and still renders the returned cart. - A failing request shows a UI-level error and leaves the previously rendered cart intact.
- The first render shows a loading state, not the empty-cart message:
isEmptyistruebefore the firstrefreshCart()resolves. - A rejected initial
refreshCart()shows an error instead of an empty cart. - Starting a second write while one is in flight is refused rather than queued.