Using the Cart
A Shopify cart is server state with a client-side handle. You create one, Shopify hands you back a cart ID, and you keep that ID around so the same cart survives a reload. Everything else, i.e. line items, totals, the checkout URL, etc. come back with every mutation, so the cart you render is always whatever Shopify sends back.
This example builds a useCart composable that owns the ID cookie, the shared cart state and the four mutations you
need, then wires it into an add-to-cart button and a cart drawer.

Men's T-shirt
$40.00
Crafted from organic cotton, this classic T-shirt features a relaxed fit, crew neckline and timeless look. Enjoy the breathable comfort of 100% organic cotton.
Size
Color
Quantity
<script setup lang="ts">
const { lines, quantity, total, checkoutUrl, update, remove, loading } = useCart()
const open = ref(false)
const description = computed(() => `${quantity} item${quantity === 1 ? '' : 's'}`)
</script>
<template>
<USlideover
v-model:open="open"
title="Cart"
:description="description"
>
<UButton
icon="i-lucide-shopping-cart"
color="neutral"
variant="outline"
>
Cart ({{ quantity }})
</UButton>
<template #body>
<p
v-if="!lines.length"
class="text-muted"
>
Your cart is empty.
</p>
<ul
v-else
class="divide-y divide-default"
>
<li
v-for="line in lines"
:key="line.id"
class="flex gap-4 py-4 first:pt-0"
>
<NuxtImg
v-if="line.merchandise.image"
:src="line.merchandise.image.url"
:alt="line.merchandise.image.altText ?? line.merchandise.product.title"
width="88"
height="88"
class="size-22 shrink-0 rounded-sm object-cover"
/>
<div class="flex flex-1 flex-col gap-2">
<div>
<p class="font-semibold">
{{ line.merchandise.product.title }}
</p>
<p class="text-sm text-muted">
{{ line.merchandise.title }}
</p>
</div>
<UInputNumber
:model-value="line.quantity"
:min="0"
size="sm"
class="w-28"
:disabled="loading"
@update:model-value="update(line.id, Number($event))"
/>
</div>
<UButton
icon="i-lucide-x"
color="neutral"
variant="ghost"
size="sm"
aria-label="Remove from cart"
:disabled="loading"
@click="remove(line.id)"
/>
</li>
</ul>
</template>
<template #footer>
<div class="flex w-full flex-col gap-3">
<div
v-if="total"
class="flex items-center justify-between"
>
<span class="text-muted">Total</span>
<span class="text-lg font-semibold">{{ total.amount }} {{ total.currencyCode }}</span>
</div>
<UButton
v-if="checkoutUrl"
:to="checkoutUrl"
external
block
size="lg"
>
Checkout
</UButton>
</div>
</template>
</USlideover>
</template>
Fragments
Every cart mutation returns the same shape, so put it in a fragment once. Fragments in your fragment directory are
auto-imported, so ...CartFields resolves without an import.
export const CART_LINE_FRAGMENT = `#graphql
fragment CartLineFields on CartLine {
id
quantity
cost {
amountPerQuantity {
amount
currencyCode
}
}
merchandise {
... on ProductVariant {
id
title
image {
url
altText
}
product {
id
handle
title
vendor
}
}
}
}
`
export const CART_FRAGMENT = `#graphql
fragment CartFields on Cart {
id
checkoutUrl
totalQuantity
lines(first: 250) {
edges {
node {
...CartLineFields
}
}
}
cost {
totalAmount {
amount
currencyCode
}
}
}
`
export const CART_USER_ERROR_FRAGMENT = `#graphql
fragment CartUserErrorFields on CartUserError {
code
field
message
}
`
Cart composable
The cart ID goes in a cookie so it outlives a reload, and flattenConnection turns the edges/node nesting into
a plain array.
export const useCart = () => {
const storefront = useStorefront()
const cart = useState<CartFieldsFragment | undefined>('cart', () => undefined)
const loading = useState('cart-loading', () => false)
const id = useCookie<string | undefined>('cart-id', {
path: '/',
maxAge: 60 * 60 * 24 * 10,
sameSite: 'lax',
secure: !import.meta.dev,
})
const lines = computed(() => flattenConnection(cart.value?.lines))
const quantity = computed(() => cart.value?.totalQuantity ?? 0)
const total = computed(() => cart.value?.cost.totalAmount)
const checkoutUrl = computed(() => cart.value?.checkoutUrl)
const run = async <T>(operation: () => Promise<T>) => {
loading.value = true
try {
return await operation()
}
finally {
loading.value = false
}
}
const get = async () => {
if (!id.value) return
return run(async () => {
const { data } = await storefront.request(`#graphql
query GetCart($id: ID!) {
cart(id: $id) {
...CartFields
}
}
`, { variables: { id: id.value! } })
if (!data?.cart) id.value = undefined
cart.value = data?.cart ?? undefined
})
}
const create = async (merchandiseId: string, quantity = 1) => run(async () => {
const { data } = await storefront.request(`#graphql
mutation CreateCart($lines: [CartLineInput!]) {
cartCreate(input: { lines: $lines }) {
cart {
...CartFields
}
userErrors {
...CartUserErrorFields
}
}
}
`, { variables: { lines: [{ merchandiseId, quantity }] } })
id.value = data?.cartCreate?.cart?.id
cart.value = data?.cartCreate?.cart ?? undefined
})
const add = async (merchandiseId: string, quantity = 1) => {
if (!id.value) return create(merchandiseId, quantity)
return run(async () => {
const { data } = await storefront.request(`#graphql
mutation AddToCart($cartId: ID!, $lines: [CartLineInput!]!) {
cartLinesAdd(cartId: $cartId, lines: $lines) {
cart {
...CartFields
}
userErrors {
...CartUserErrorFields
}
}
}
`, { variables: { cartId: id.value!, lines: [{ merchandiseId, quantity }] } })
cart.value = data?.cartLinesAdd?.cart ?? undefined
})
}
const update = async (lineId: string, quantity: number) => run(async () => {
const { data } = await storefront.request(`#graphql
mutation UpdateCart($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
cartLinesUpdate(cartId: $cartId, lines: $lines) {
cart {
...CartFields
}
userErrors {
...CartUserErrorFields
}
}
}
`, { variables: { cartId: id.value!, lines: [{ id: lineId, quantity }] } })
cart.value = data?.cartLinesUpdate?.cart ?? undefined
})
const remove = async (lineId: string) => run(async () => {
const { data } = await storefront.request(`#graphql
mutation RemoveFromCart($cartId: ID!, $lineIds: [ID!]!) {
cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
cart {
...CartFields
}
userErrors {
...CartUserErrorFields
}
}
}
`, { variables: { cartId: id.value!, lineIds: [lineId] } })
cart.value = data?.cartLinesRemove?.cart ?? undefined
})
return { cart, lines, quantity, total, checkoutUrl, loading, get, add, update, remove }
}
0 through update removes it, so remove is a convenience rather than a
requirement.Loading the cart
The cart ID lives in a cookie, so the server can read it and render a populated cart on the first response. Call
get() once from a plugin, or from your layout:
<script setup lang="ts">
const { get } = useCart()
await useAsyncData('cart', () => get())
</script>
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
Adding to the cart
The ID you add is the variant ID, not the product ID. See selectedOrFirstAvailableVariant.id from the
product page example.
<script setup lang="ts">
const props = defineProps<{
variantId: string
available: boolean
}>()
const { add, loading } = useCart()
const quantity = ref(1)
</script>
<template>
<form @submit.prevent="add(props.variantId, quantity)">
<input v-model.number="quantity" type="number" min="1" >
<button type="submit" :disabled="!props.available || loading">
{{ props.available ? 'Add to cart' : 'Sold out' }}
</button>
</form>
</template>
Rendering the cart
The drawer is a USlideover, so the overlay, the focus trap and escape-to-close come for free and the trigger
button is just its default slot. Setting a line to 0 through the quantity input removes it, which is why the
input has :min="0" while the one on the product page starts at 1.
checkoutUrl is a Shopify-hosted URL, so the checkout button is an ordinary link out of your app.
<script setup lang="ts">
const { lines, quantity, total, checkoutUrl, update, remove, loading } = useCart()
const open = ref(false)
</script>
<template>
<USlideover
v-model:open="open"
title="Cart"
:description="`${quantity} item${quantity === 1 ? '' : 's'}`"
>
<UButton
icon="i-lucide-shopping-cart"
color="neutral"
variant="outline"
>
Cart ({{ quantity }})
</UButton>
<template #body>
<p
v-if="!lines.length"
class="text-muted"
>
Your cart is empty.
</p>
<ul
v-else
class="divide-y divide-default"
>
<li
v-for="line in lines"
:key="line.id"
class="flex gap-4 py-4 first:pt-0"
>
<NuxtImg
v-if="line.merchandise.image"
:src="line.merchandise.image.url"
:alt="line.merchandise.image.altText ?? line.merchandise.product.title"
width="88"
height="88"
class="size-22 shrink-0 rounded-sm object-cover"
/>
<div class="flex flex-1 flex-col gap-2">
<div>
<p class="font-semibold">
{{ line.merchandise.product.title }}
</p>
<p class="text-sm text-muted">
{{ line.merchandise.title }}
</p>
</div>
<UInputNumber
:model-value="line.quantity"
:min="0"
size="sm"
class="w-28"
:disabled="loading"
@update:model-value="update(line.id, Number($event))"
/>
</div>
<UButton
icon="i-lucide-x"
color="neutral"
variant="ghost"
size="sm"
aria-label="Remove from cart"
:disabled="loading"
@click="remove(line.id)"
/>
</li>
</ul>
</template>
<template #footer>
<div class="flex w-full flex-col gap-3">
<div
v-if="total"
class="flex items-center justify-between"
>
<span class="text-muted">Total</span>
<span class="text-lg font-semibold">{{ total.amount }} {{ total.currencyCode }}</span>
</div>
<UButton
v-if="checkoutUrl"
:to="checkoutUrl"
external
block
size="lg"
>
Checkout
</UButton>
</div>
</template>
</USlideover>
</template>
Analytics
If you have analytics enabled, hand the cart over whenever it changes. The module diffs
successive carts and publishes product_added_to_cart and the other cart events for you. Never publish by
hand, or they will be counted twice.
const analytics = useShopifyAnalytics()
watch(cart, value => analytics.setCart(
value ? { ...value, lines: flattenConnection(value.lines) } : null,
), { immediate: true })
Localization
Cart prices follow the buyer context, so add @inContext to every cart operation once you serve more than one
market. The directive takes the same language and country variables as the rest of the queries:
mutation AddToCart($cartId: ID!, $lines: [CartLineInput!]!, $language: LanguageCode, $country: CountryCode)
@inContext(language: $language, country: $country) {
cartLinesAdd(cartId: $cartId, lines: $lines) {
cart {
...CartFields
}
}
}