Examples

Using the Cart

How to use the cart in your Shopify store in Nuxt
With the upcoming release of @shopify/hydrogen-vue the examples will be updated to use the new composables and components, so the current examples may become outdated.

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

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.

graphql/cart.ts
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.

app/composables/cart.ts
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 }
}
Setting a line's quantity to 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:

app/app.vue
<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.

app/components/AddToCart.vue
<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.

app/components/CartDrawer.vue
<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.

app/composables/cart.ts
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
    }
  }
}
A cart is created in one context and keeps it. Switching market after the cart exists does not re-price the existing lines. Create a new cart when the shopper changes country.
Published under the MIT License