Connecting Shopify Storefront API to Nuxt 4: A practical walkthrough.

A practical guide to wiring up a Shopify Storefront API into a Nuxt 4 app, covering product listings, product detail pages, a persistent cart and redirect to Shopify checkout.

Jun 8, 202623 min read
Connecting Shopify Storefront API to Nuxt 4: A practical walkthrough

I'm going to talk through how I wired up a Shopify Storefront API into a Nuxt 4 app, covering product listings, product detail pages, a persistent cart and redirecting to Shopify checkout. I'll go through each layer in the order it builds up so it's easy to follow along.

If you'd rather just clone and go, I've put together a ready to use boilerplate at github.com/bok04/nuxt4-shopify-starter that has everything covered in this post already wired up. Drop in your credentials and you should have a working Nuxt 4 connected Shopify shop.

Setting Up Shopify Admin First

This was the most confusing part for me when I first got started so I want to cover it before anything else.

Log into your Shopify Admin and go into your store settings. From there select Apps from the sidebar. You'll see an option to click Develop apps and then Create an app. When creating the app, make sure you select Legacy App as this is what exposes the API credentials you need including the Storefront API access token.

Once the app is created you need to configure the Storefront API access scopes. Head into the API credentials tab of your app and enable the following scopes:

  • unauthenticated_read_product_listings
  • unauthenticated_read_product_inventory
  • unauthenticated_write_checkouts
  • unauthenticated_read_checkouts
  • unauthenticated_read_content

These scopes are what allow you to retrieve products and create, add, update and delete a cart. Without them your API calls will return permission errors even with a valid token.

Once saved, your Storefront API access token will be visible in the API credentials tab. Copy that as you will need it in the next step.

Prerequisites

  • A Shopify store with the Storefront API enabled (not the Admin API)
  • A public Storefront access token. As described above, you can find this in the API credentials tab of your app once it has been created
  • A Nuxt 4 project with @pinia/nuxt installed

1. Exposing Your Credentials via Runtime Config

Nuxt's runtimeConfig separates server-only secrets from values the browser needs. The Storefront API token is intentionally public. Shopify designed it that way so it goes under public:

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    public: {
      shopifyDomain: process.env.SHOPIFY_DOMAIN || '',  // e.g. your-store.myshopify.com
      shopifyToken: process.env.SHOPIFY_TOKEN || '',
    },
  },
})

Add those two values to your .env file and you're ready to make requests.

2. The Shopify GraphQL Client: useShopify

The Storefront API is GraphQL-only. Rather than repeat the fetch boilerplate in every composable, I created a single useShopify composable that wraps it:

// app/composables/useShopify.ts
export const useShopify = () => {
  const config = useRuntimeConfig()
  const storeDomain = config.public.shopifyDomain as string
  const accessToken = config.public.shopifyToken as string
  const apiVersion = '2025-01'
  const endpoint = `https://${storeDomain}/api/${apiVersion}/graphql.json`

  const sendQuery = async <T = unknown>(
    query: string,
    variables?: Record<string, unknown>
  ): Promise<T> => {
    const response = await $fetch<{ data: T; errors?: Array<{ message: string }> }>(endpoint, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Shopify-Storefront-Access-Token': accessToken,
      },
      body: JSON.stringify({ query, variables }),
    })

    if (response.errors?.length) {
      throw new Error(response.errors.map((e) => e.message).join(', '))
    }

    return response.data
  }

  return { sendQuery }
}

Every GraphQL response from Shopify comes back as { data, errors }. If errors is populated we throw immediately so callers don't have to check for partial failures themselves.

3. Fetching Products: useProducts

With sendQuery available, querying products is straightforward. I use a GraphQL fragment to keep the product shape in one place and reuse it across multiple queries:

// app/composables/useProducts.ts
const PRODUCT_FRAGMENT = `
  fragment ProductFields on Product {
    id
    handle
    title
    description
    availableForSale
    featuredImage { url altText width height }
    images(first: 10) { nodes { url altText width height } }
    priceRange {
      minVariantPrice { amount currencyCode }
      maxVariantPrice { amount currencyCode }
    }
    variants(first: 50) {
      nodes {
        id
        title
        availableForSale
        selectedOptions { name value }
        price { amount currencyCode }
        compareAtPrice { amount currencyCode }
      }
    }
  }
`

export const useProducts = () => {
  const { sendQuery } = useShopify()

  const getAllProducts = async (first = 50) => {
    const gql = `
      ${PRODUCT_FRAGMENT}
      query Products($first: Int!) {
        products(first: $first) {
          nodes { ...ProductFields }
          pageInfo { hasNextPage endCursor }
        }
      }
    `
    return sendQuery<{ products: ProductConnection }>(gql, { first })
  }

  const getProductByHandle = async (handle: string) => { /* ... */ }

  return { getAllProducts, getProductByHandle }
}

In a page, this pairs naturally with Nuxt's useAsyncData so the fetch happens server-side and the result is hydrated on the client without a second request:

const { getAllProducts } = useProducts()
const { data } = await useAsyncData('shop-products', () => getAllProducts(50))

4. The Cart: useCart

Cart operations are Shopify mutations. Each one takes the current cart ID, sends the mutation, and returns the full updated cart. I use a CART_FRAGMENT that defines exactly what data comes back including lines, quantities, costs and the checkout URL, and I reuse it across every mutation so the shape of the response is always consistent:

// app/composables/useCart.ts
import type { ShopifyCart } from '~/types/shopify'

const CART_FRAGMENT = `
  fragment CartFields on Cart {
    id
    checkoutUrl
    totalQuantity
    cost {
      totalAmount { amount currencyCode }
      subtotalAmount { amount currencyCode }
      totalTaxAmount { amount currencyCode }
      totalDutyAmount { amount currencyCode }
    }
    lines(first: 50) {
      nodes {
        id
        quantity
        merchandise {
          ... on ProductVariant {
            id
            title
            product { id handle title }
            image { url altText width height }
            price { amount currencyCode }
            compareAtPrice { amount currencyCode }
            selectedOptions { name value }
          }
        }
        cost {
          totalAmount { amount currencyCode }
          subtotalAmount { amount currencyCode }
        }
      }
    }
    note
    attributes { key value }
  }
`

export const useCart = () => {
  const { sendQuery } = useShopify()

  async function fetchCart(cartId: string) {
    const gql = `
      ${CART_FRAGMENT}
      query Cart($cartId: ID!) {
        cart(id: $cartId) { ...CartFields }
      }
    `
    const result = await sendQuery<{ cart: ShopifyCart | null }>(gql, { cartId })
    return result.cart
  }

  async function createCart(variantId?: string, quantity = 1) {
    const input: Record<string, unknown> = variantId
      ? { lines: [{ merchandiseId: variantId, quantity }] }
      : {}

    const gql = `
      ${CART_FRAGMENT}
      mutation CartCreate($input: CartInput!) {
        cartCreate(input: $input) {
          cart { ...CartFields }
          userErrors { field message }
        }
      }
    `
    const result = await sendQuery<{
      cartCreate: { cart: ShopifyCart | null; userErrors: { field: string; message: string }[] }
    }>(gql, { input })

    if (result.cartCreate.userErrors.length) {
      console.error('Cart create errors:', result.cartCreate.userErrors)
    }

    return result.cartCreate.cart
  }

  async function addToCart(cartId: string, variantId: string, quantity = 1) {
    const gql = `
      ${CART_FRAGMENT}
      mutation CartLinesAdd($cartId: ID!, $lines: [CartLineInput!]!) {
        cartLinesAdd(cartId: $cartId, lines: $lines) {
          cart { ...CartFields }
          userErrors { field message }
        }
      }
    `
    const result = await sendQuery<{
      cartLinesAdd: { cart: ShopifyCart | null; userErrors: { field: string; message: string }[] }
    }>(gql, { cartId, lines: [{ merchandiseId: variantId, quantity }] })

    if (result.cartLinesAdd.userErrors.length) {
      console.error('Cart add errors:', result.cartLinesAdd.userErrors)
    }

    return result.cartLinesAdd.cart
  }

  async function updateCartLine(cartId: string, lineId: string, quantity: number) {
    const gql = `
      ${CART_FRAGMENT}
      mutation CartLinesUpdate($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
        cartLinesUpdate(cartId: $cartId, lines: $lines) {
          cart { ...CartFields }
          userErrors { field message }
        }
      }
    `
    const result = await sendQuery<{
      cartLinesUpdate: { cart: ShopifyCart | null; userErrors: { field: string; message: string }[] }
    }>(gql, { cartId, lines: [{ id: lineId, quantity }] })

    if (result.cartLinesUpdate.userErrors.length) {
      console.error('Cart update errors:', result.cartLinesUpdate.userErrors)
    }

    return result.cartLinesUpdate.cart
  }

  async function removeFromCart(cartId: string, lineId: string) {
    const gql = `
      ${CART_FRAGMENT}
      mutation CartLinesRemove($cartId: ID!, $lineIds: [ID!]!) {
        cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
          cart { ...CartFields }
          userErrors { field message }
        }
      }
    `
    const result = await sendQuery<{
      cartLinesRemove: { cart: ShopifyCart | null; userErrors: { field: string; message: string }[] }
    }>(gql, { cartId, lineIds: [lineId] })

    if (result.cartLinesRemove.userErrors.length) {
      console.error('Cart remove errors:', result.cartLinesRemove.userErrors)
    }

    return result.cartLinesRemove.cart
  }

  return { fetchCart, createCart, addToCart, updateCartLine, removeFromCart }
}

Each mutation returns { cart, userErrors }. The userErrors array contains business-level errors (e.g. a variant is out of stock) that don't come through as top-level GraphQL errors, so it's worth logging or surfacing those separately.

5. The Pinia Cart Store

useCart handles the API calls, but the store is what holds the reactive state the rest of the app reads. I use Pinia's setup-store style which gives you full control over refs, computed values and actions in a way that feels natural alongside the Composition API:

// app/stores/cart.ts
import { defineStore } from 'pinia'
import type { ShopifyCart } from '~/types/shopify'

export const useCartStore = defineStore('cart', () => {
  const cartApi = useCart()

  const cart = ref<ShopifyCart | null>(null)
  const cartId = ref<string | null>(null)
  const isUpdating = ref(false)

  const totalQuantity = computed(() => cart.value?.totalQuantity ?? 0)
  const checkoutUrl = computed(() => cart.value?.checkoutUrl ?? null)
  const lines = computed(() => cart.value?.lines.nodes ?? [])
  const itemCount = computed(() => lines.value.reduce((sum, l) => sum + l.quantity, 0))

  let initialised = false

  async function syncCartId() {
    try {
      const data = await $fetch<{ cartId: string | null }>('/api/cart-id')
      cartId.value = data.cartId
    } catch (e) {
      console.error('[cart] syncCartId failed:', e)
      cartId.value = null
    }
  }

  async function persistCartId(id: string | null) {
    try {
      await $fetch('/api/cart-id', { method: 'POST', body: { cartId: id } })
    } catch (e) {
      console.error('[cart] persistCartId failed:', e)
    }
  }

  async function setCart(updatedCart: ShopifyCart | null) {
    if (!updatedCart) {
      cart.value = null
      cartId.value = null
      await persistCartId(null)
      return
    }
    cart.value = updatedCart
    cartId.value = updatedCart.id
    await persistCartId(updatedCart.id)
  }

  async function ensureInitialised() {
    if (initialised) return
    initialised = true
    await syncCartId()
    if (cartId.value) {
      const result = await cartApi.fetchCart(cartId.value)
      if (!result) {
        cartId.value = null
        await persistCartId(null)
      }
      cart.value = result
    }
  }

  async function fetchCart() {
    await ensureInitialised()
    if (!cartId.value) return null

    const result = await cartApi.fetchCart(cartId.value)
    if (!result) {
      cartId.value = null
      await persistCartId(null)
    }
    cart.value = result
    return result
  }

  async function createCart(variantId?: string, quantity = 1) {
    isUpdating.value = true
    const result = await cartApi.createCart(variantId, quantity)
    await setCart(result)
    isUpdating.value = false
    return cart.value
  }

  async function addToCart(variantId: string, quantity = 1) {
    await ensureInitialised()
    isUpdating.value = true

    if (!cartId.value) {
      await createCart(variantId, quantity)
      isUpdating.value = false
      return cart.value
    }

    const result = await cartApi.addToCart(cartId.value, variantId, quantity)
    await setCart(result)
    isUpdating.value = false
    return cart.value
  }

  async function updateCartLine(lineId: string, quantity: number) {
    await ensureInitialised()
    if (!cartId.value) return
    if (quantity <= 0) return removeFromCart(lineId)

    isUpdating.value = true
    const result = await cartApi.updateCartLine(cartId.value, lineId, quantity)
    await setCart(result)
    isUpdating.value = false
    return cart.value
  }

  async function removeFromCart(lineId: string) {
    await ensureInitialised()
    if (!cartId.value) return

    isUpdating.value = true
    const result = await cartApi.removeFromCart(cartId.value, lineId)
    await setCart(result)
    isUpdating.value = false
    return cart.value
  }

  async function clearCart() {
    cart.value = null
    cartId.value = null
    await persistCartId(null)
  }

  return {
    cart,
    isUpdating,
    cartId,
    totalQuantity,
    checkoutUrl,
    lines,
    itemCount,
    fetchCart,
    createCart,
    addToCart,
    updateCartLine,
    removeFromCart,
    clearCart,
  }
})

A few things worth calling out:

isUpdating is set to true for the duration of any mutation. Use this to disable buttons in your UI so the user can't double-tap Add to Cart or quantity controls while a request is in-flight.

ensureInitialised is a guard that runs once per page load. It reads the cart ID from the server cookie via syncCartId, then fetches the cart from Shopify. Subsequent calls skip the work entirely thanks to the initialised flag. This means you can safely call ensureInitialised at the top of any action without worrying about duplicate requests.

setCart is a private helper that handles updating both cart and cartId in sync and persisting the ID to the cookie in one go. Every action that receives an updated cart from Shopify routes through this rather than setting the refs directly.

addToCart handles the case where no cart exists yet by calling createCart first. This means you never need to worry about whether a cart has been initialised before calling addToCart as it takes care of it itself.

updateCartLine with a quantity of zero or less delegates to removeFromCart rather than sending quantity: 0 to Shopify, which would return a validation error.

clearCart is useful for post-checkout cleanup. When Shopify redirects the customer back to your site after a completed purchase you can call this to reset the cart state and clear the cookie.

6. Persisting the Cart ID with a Server Cookie

Shopify carts are server-side objects identified by an opaque ID like gid://shopify/Cart/abc123. You need to persist this ID between page loads and browser sessions.

I use an httpOnly cookie so the ID can't be read or tampered with by client-side JavaScript. Two server API routes handle reading and writing it:

// server/api/cart-id.get.ts
import { getCookie } from 'h3'

export default defineEventHandler((event) => {
  const cartId = getCookie(event, 'shopify_cart_id')
  return { cartId: cartId || null }
})
// server/api/cart-id.post.ts
import { setCookie } from 'h3'

export default defineEventHandler(async (event) => {
  const { cartId } = await readBody(event)
  if (cartId) {
    setCookie(event, 'shopify_cart_id', cartId, {
      maxAge: 60 * 60 * 24 * 30,  // 30 days
      httpOnly: true,
      secure: true,
      sameSite: 'lax',
    })
  }
  return { success: true }
})

A Nitro server plugin refreshes the cookie's maxAge on every request so active users don't lose their cart:

// server/plugins/cart.ts
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('request', (event) => {
    const cartId = getCookie(event, 'shopify_cart_id')
    if (cartId) {
      setCookie(event, 'shopify_cart_id', cartId, {
        maxAge: 60 * 60 * 24 * 30,
        httpOnly: true,
        secure: true,
        sameSite: 'lax',
      })
    }
  })
})

The store calls GET /api/cart-id on initialisation to read the ID, then POST /api/cart-id whenever the cart ID changes (created, cleared).

7. The Shop Layout and Cart Button

A quick note before diving in. The layout is entirely optional. In the boilerplate the pages sit at the root level, but if you want to wrap your shop in a dedicated layout that awaits the cart before anything renders you can do so. This means the cart count badge is populated on the server rather than after a client-side mount, which avoids any flash of an empty badge on first load:

// app/layouts/shop.vue
<script setup lang="ts">
const cartStore = useCartStore()
await cartStore.fetchCart()
</script>

The CartButton component reads itemCount from the store and shows an animated badge when items are present. Because the layout has already fetched the cart, the badge is correct from the first paint. The badge itself uses a Vue Transition so it animates in and out smoothly when the cart count changes:

// app/components/shop/CartButton.vue
<template>
  <div class="border-b border-slate-200">
    <div class="mx-auto max-w-6xl px-6 lg:px-7">
      <div class="flex justify-end py-4">
        <NuxtLink
          to="/cart"
          class="relative text-slate-400 hover:text-slate-900 transition-colors"
          aria-label="View cart"
        >
          <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
            <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM8.625 10.5a.375.375 0 11-.75 0 .375.375 0 01.75 0zm7.5 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
          </svg>
          <ClientOnly>
            <Transition name="badge">
              <span
                v-if="cartCount > 0"
                class="absolute -top-1.5 -right-1.5 inline-flex h-4 w-4 items-center justify-center rounded-full bg-slate-900 text-white text-[10px] leading-none font-medium"
              >
                {{ cartCount > 9 ? '9+' : cartCount }}
              </span>
            </Transition>
          </ClientOnly>
        </NuxtLink>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
const cartStore = useCartStore()
const cartCount = computed(() => cartStore.itemCount)

cartStore.fetchCart()
</script>

<style scoped>
.badge-enter-active,
.badge-leave-active {
  transition: opacity 0.2s ease, transform 0.2s ease;
}
.badge-enter-from,
.badge-leave-to {
  opacity: 0;
  transform: scale(0.5);
}
</style>

Notice the ClientOnly wrapper around the badge. This prevents a hydration mismatch since the cart count isn't available on the server during the initial render.

8. The Shop Page

The shop page is straightforward. It uses useAsyncData to fetch all products server-side and passes each one to a ProductCard component:

// app/pages/index.vue
<template>
  <section class="py-16">
    <div class="mx-auto max-w-6xl px-6 lg:px-7">
      <h1 class="font-roboto text-2xl text-slate-900 mb-12">Shop</h1>

      <div v-if="!products" class="text-center py-16 font-roboto text-slate-400">
        Loading…
      </div>

      <div v-else class="grid grid-cols-2 gap-x-6 gap-y-12 sm:grid-cols-3 lg:grid-cols-4">
        <ShopProductCard v-for="product in products.products.nodes" :key="product.id" :product="product" />
      </div>
    </div>
  </section>
</template>

<script setup lang="ts">
definePageMeta({ layout: 'shop' })

const { getAllProducts } = useProducts()

const { data: products } = await useAsyncData('shop-products', () => getAllProducts(50))
</script>

9. The Product Card Component

The ProductCard component displays each product with its image, title, price range and a link through to the full product detail page. What's shown here is a basic starting point and you can expand it to include things like tags, vendor names, sale badges, stock indicators or a quick add to cart button depending on what your store needs. The data available to you is whatever you included in the PRODUCT_FRAGMENT in useProducts.ts.

// app/components/shop/ProductCard.vue
<template>
  <div class="border border-slate-200 rounded-xl shadow-sm overflow-hidden">
    <NuxtLink :to="`/product/${product.handle}`" class="aspect-square overflow-hidden bg-slate-50 block">
      <img
        v-if="product.featuredImage"
        :src="product.featuredImage.url"
        :alt="product.featuredImage.altText || product.title"
        class="h-full w-full object-cover transition-opacity duration-300 hover:opacity-80"
        loading="lazy"
      />
      <div v-else class="flex h-full items-center justify-center text-slate-300 text-sm font-roboto">
        No image
      </div>
    </NuxtLink>

    <div class="p-5 space-y-2">
      <NuxtLink :to="`/product/${product.handle}`" class="font-roboto text-sm text-slate-900 leading-snug block">
        {{ product.title }}
      </NuxtLink>

      <p class="font-roboto text-sm text-slate-500">
        {{ formatPrice(product.priceRange.minVariantPrice.amount, product.priceRange.minVariantPrice.currencyCode) }}
        <span v-if="product.priceRange.minVariantPrice.amount !== product.priceRange.maxVariantPrice.amount">
          – {{ formatPrice(product.priceRange.maxVariantPrice.amount, product.priceRange.maxVariantPrice.currencyCode) }}
        </span>
      </p>

      <NuxtLink
        :to="`/product/${product.handle}`"
        class="inline-block rounded-full bg-slate-900 px-5 py-2 text-xs font-roboto text-white transition-colors hover:bg-slate-800"
      >
        View Product
      </NuxtLink>
    </div>
  </div>
</template>

<script setup lang="ts">
import type { ShopifyProduct } from '~/types/shopify'

defineProps<{
  product: ShopifyProduct
}>()

function formatPrice(amount: string, currency: string) {
  const symbol = currency === 'GBP' ? '£' : currency === 'USD' ? '$' : currency + ' '
  return symbol + Number(amount).toFixed(2)
}
</script>

10. The Product Detail Page

The product detail page handles variant selection, image galleries and adding the specific chosen variant to the cart. It uses the product handle from the URL to fetch the correct product via useAsyncData. Again this is a solid foundation but there's plenty of room to expand. You could add a related products section, metafields for additional product information, breadcrumb navigation or a more detailed reviews section depending on your requirements. Anything Shopify exposes through the Storefront API can be added to the PRODUCT_FRAGMENT and surfaced here.

// app/pages/product/[handle].vue
<template>
  <section class="py-16">
    <div class="mx-auto max-w-6xl px-6 lg:px-7">
      <NuxtLink to="/" class="inline-flex items-center gap-1 text-sm font-roboto text-slate-400 hover:text-slate-900 transition-colors mb-8">
        <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
          <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
        </svg>
        Back
      </NuxtLink>

      <div v-if="error" class="text-center py-16 font-roboto text-slate-400">
        Product not found.
      </div>

      <div v-else-if="!product" class="text-center py-16 font-roboto text-slate-400">
        Loading…
      </div>

      <div v-else class="grid grid-cols-1 gap-12 lg:grid-cols-2 lg:gap-16">
        <div class="space-y-3">
          <div class="aspect-square overflow-hidden rounded-2xl bg-slate-100">
            <img
              v-if="activeImage"
              :src="activeImage.url"
              :alt="activeImage.altText || product.title"
              class="h-full w-full object-cover"
            />
            <div v-else class="flex h-full items-center justify-center text-slate-300 text-sm font-roboto">
              No image
            </div>
          </div>

          <div v-if="product.images.nodes.length > 1" class="grid grid-cols-4 gap-2">
            <button
              v-for="(img, i) in product.images.nodes"
              :key="img.url"
              type="button"
              class="aspect-square overflow-hidden rounded-lg bg-slate-100 ring-1 transition-all"
              :class="activeImageIndex === i ? 'ring-slate-900 ring-offset-2' : 'ring-slate-200 hover:ring-slate-400'"
              @click="activeImageIndex = i"
            >
              <img :src="img.url" :alt="img.altText || product.title" class="h-full w-full object-cover" />
            </button>
          </div>
        </div>

        <div class="flex flex-col gap-6 lg:pt-8">
          <div>
            <h1 class="font-roboto text-3xl text-slate-900 leading-snug">{{ product.title }}</h1>
            <p class="mt-3 font-roboto text-xl text-slate-700">
              <template v-if="selectedVariant">
                {{ formatPrice(selectedVariant.price.amount, selectedVariant.price.currencyCode) }}
                <span
                  v-if="selectedVariant.compareAtPrice && Number(selectedVariant.compareAtPrice.amount) > Number(selectedVariant.price.amount)"
                  class="ml-2 text-base text-slate-400 line-through"
                >
                  {{ formatPrice(selectedVariant.compareAtPrice.amount, selectedVariant.compareAtPrice.currencyCode) }}
                </span>
              </template>
            </p>
          </div>

          <div v-for="optionName in variantOptionNames" :key="optionName" class="space-y-3">
            <p class="font-roboto text-sm text-slate-500">{{ optionName }}</p>
            <div class="flex flex-wrap gap-2">
              <button
                v-for="value in variantOptionValues(optionName)"
                :key="value"
                type="button"
                class="rounded-full border px-5 py-2 text-sm font-roboto transition-colors"
                :class="selectedOptions[optionName] === value
                  ? 'border-slate-900 bg-slate-900 text-white'
                  : 'border-slate-300 text-slate-600 hover:border-slate-500'"
                @click="selectOption(optionName, value)"
              >
                {{ value }}
              </button>
            </div>
          </div>

          <div v-if="product.description" class="font-roboto text-sm text-slate-600 leading-relaxed border-t border-slate-200 pt-6">
            {{ product.description }}
          </div>

          <button
            type="button"
            :disabled="!selectedVariant?.availableForSale || isAddingToCart"
            class="w-full rounded-full bg-slate-900 py-3.5 text-sm font-roboto text-white transition-colors hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed"
            @click="addToCart"
          >
            <span v-if="isAddingToCart">Adding…</span>
            <span v-else-if="!selectedVariant?.availableForSale">Sold Out</span>
            <span v-else>Add to Cart</span>
          </button>

          <p v-if="showAddedConfirmation" class="text-center font-roboto text-sm text-slate-500">
            Added to cart —
            <NuxtLink to="/cart" class="font-medium underline hover:text-slate-900">view cart</NuxtLink>
          </p>
        </div>
      </div>
    </div>
  </section>
</template>

<script setup lang="ts">
import type { ShopifyVariant } from '~/types/shopify'

definePageMeta({ layout: 'shop' })

const route = useRoute()
const { getProductByHandle } = useProducts()
const cartStore = useCartStore()

const { data, error } = await useAsyncData(
  `product-${route.params.handle}`,
  () => getProductByHandle(route.params.handle as string),
)

const product = computed(() => data.value?.productByHandle ?? null)
const activeImageIndex = ref(0)
const activeImage = computed(() => product.value?.images.nodes[activeImageIndex.value] ?? product.value?.featuredImage ?? null)
const selectedOptions = ref<Record<string, string>>({})
const isAddingToCart = ref(false)
const showAddedConfirmation = ref(false)

watch(product, (p) => {
  if (!p) return
  const first = p.variants.nodes[0]
  if (first) {
    for (const opt of first.selectedOptions) {
      selectedOptions.value[opt.name] = opt.value
    }
  }
}, { immediate: true })

const variantOptionNames = computed(() => {
  if (!product.value) return []
  const names = new Set<string>()
  for (const v of product.value.variants.nodes) {
    for (const o of v.selectedOptions) names.add(o.name)
  }
  return [...names]
})

function variantOptionValues(optionName: string): string[] {
  if (!product.value) return []
  const seen = new Set<string>()
  for (const v of product.value.variants.nodes) {
    const opt = v.selectedOptions.find((o) => o.name === optionName)
    if (opt) seen.add(opt.value)
  }
  return [...seen]
}

function selectOption(name: string, value: string) {
  selectedOptions.value = { ...selectedOptions.value, [name]: value }
}

const selectedVariant = computed<ShopifyVariant | null>(() => {
  if (!product.value) return null
  const match = product.value.variants.nodes.find((v) =>
    v.selectedOptions.every((o) => selectedOptions.value[o.name] === o.value),
  )
  return match ?? product.value.variants.nodes[0] ?? null
})

function formatPrice(amount: string, currency: string) {
  const symbol = currency === 'GBP' ? '£' : currency === 'USD' ? '$' : currency + ' '
  return symbol + Number(amount).toFixed(2)
}

async function addToCart() {
  if (!selectedVariant.value) return
  isAddingToCart.value = true
  await cartStore.addToCart(selectedVariant.value.id, 1)
  isAddingToCart.value = false
  showAddedConfirmation.value = true
  setTimeout(() => { showAddedConfirmation.value = false }, 4000)
}

useSeoMeta({
  title: computed(() => product.value ? `${product.value.title} — Shopify Store` : 'Product — Shopify Store'),
  description: computed(() => product.value?.description ?? ''),
})
</script>

11. The Cart Page

The cart page lists all current cart lines with quantity controls and a remove button for each item. It uses ClientOnly to wrap the cart content to avoid SSR hydration mismatches with the cart state. A skeleton loading state is shown as the fallback while the client side hydrates:

// app/pages/cart.vue
<template>
  <section class="py-16">
    <div class="mx-auto max-w-6xl px-6 lg:px-7">
      <div class="mx-auto max-w-3xl">
        <h1 class="font-roboto text-2xl text-slate-900 mb-12">Cart</h1>

        <ClientOnly>
          <template #fallback>
            <div class="space-y-4">
              <div v-for="i in 2" :key="i" class="flex gap-4 p-4 border-b border-slate-200">
                <div class="h-24 w-24 shrink-0 rounded-lg bg-slate-200 animate-pulse" />
                <div class="flex flex-1 flex-col justify-between gap-3 py-1">
                  <div class="space-y-2">
                    <div class="h-3.5 w-2/3 rounded bg-slate-200 animate-pulse" />
                    <div class="h-3 w-1/3 rounded bg-slate-200 animate-pulse" />
                  </div>
                  <div class="flex items-center justify-between">
                    <div class="h-7 w-24 rounded bg-slate-200 animate-pulse" />
                    <div class="h-3.5 w-12 rounded bg-slate-200 animate-pulse" />
                  </div>
                </div>
              </div>
              <div class="border-t border-slate-200 pt-6 space-y-3">
                <div class="flex justify-between">
                  <div class="h-3.5 w-16 rounded bg-slate-200 animate-pulse" />
                  <div class="h-3.5 w-20 rounded bg-slate-200 animate-pulse" />
                </div>
                <div class="h-12 w-full rounded-lg bg-slate-900 animate-pulse" />
              </div>
            </div>
          </template>

          <div v-if="!cartStore.lines.length" class="text-center py-16">
            <p class="font-roboto text-slate-500 mb-6">Your cart is empty.</p>
            <NuxtLink to="/" class="inline-block rounded-full bg-slate-900 px-8 py-3 text-sm font-roboto text-white transition-colors hover:bg-slate-800">
              Browse Products
            </NuxtLink>
          </div>

          <div v-else class="divide-y divide-slate-200">
            <div v-for="line in cartStore.lines" :key="line.id" class="flex gap-4 py-6">
              <div class="h-24 w-24 shrink-0 overflow-hidden rounded-lg bg-slate-100">
                <img
                  v-if="line.merchandise.image"
                  :src="line.merchandise.image.url"
                  :alt="line.merchandise.image.altText || line.merchandise.title"
                  class="h-full w-full object-cover"
                />
                <div v-else class="flex h-full items-center justify-center text-slate-300 text-xs font-roboto">
                  No image
                </div>
              </div>

              <div class="flex flex-1 flex-col justify-between gap-2 min-w-0">
                <div>
                  <p class="font-roboto text-sm font-medium text-slate-900">{{ line.merchandise.product.title }}</p>
                  <p v-if="line.merchandise.title !== 'Default Title'" class="font-roboto text-xs text-slate-500 mt-0.5">
                    {{ line.merchandise.title }}
                  </p>
                </div>

                <div class="flex items-center justify-between gap-4">
                  <div class="flex items-center border border-slate-300 rounded-full overflow-hidden">
                    <button
                      type="button"
                      class="px-3 py-1 text-sm font-roboto text-slate-600 transition-colors hover:bg-slate-100 disabled:opacity-30"
                      :disabled="line.quantity <= 1 || cartStore.isUpdating"
                      @click="cartStore.updateCartLine(line.id, line.quantity - 1)"
                    >
                    </button>
                    <span class="px-3 py-1 text-sm font-roboto text-slate-900 tabular-nums min-w-[2rem] text-center">{{ line.quantity }}</span>
                    <button
                      type="button"
                      class="px-3 py-1 text-sm font-roboto text-slate-600 transition-colors hover:bg-slate-100 disabled:opacity-30"
                      :disabled="cartStore.isUpdating"
                      @click="cartStore.updateCartLine(line.id, line.quantity + 1)"
                    >
                      +
                    </button>
                  </div>

                  <div class="flex items-center gap-4">
                    <p class="font-roboto text-sm text-slate-900 tabular-nums whitespace-nowrap">
                      {{ formatPrice(line.cost.totalAmount.amount, line.cost.totalAmount.currencyCode) }}
                    </p>
                    <button
                      type="button"
                      class="text-slate-300 hover:text-slate-500 transition-colors"
                      :disabled="cartStore.isUpdating"
                      @click="cartStore.removeFromCart(line.id)"
                    >
                      <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
                        <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
                      </svg>
                    </button>
                  </div>
                </div>
              </div>
            </div>

            <div class="pt-6 space-y-4">
              <div class="flex justify-between font-roboto text-sm">
                <span class="text-slate-500">Subtotal</span>
                <span class="text-slate-900 tabular-nums font-medium">
                  {{ formatPrice(cartStore.cart!.cost.subtotalAmount.amount, cartStore.cart!.cost.subtotalAmount.currencyCode) }}
                </span>
              </div>

              <a
                :href="checkoutUrl"
                class="block w-full rounded-full bg-slate-900 py-3.5 text-center text-sm font-roboto text-white transition-colors hover:bg-slate-800"
              >
                Checkout
              </a>
            </div>
          </div>
        </ClientOnly>
      </div>
    </div>
  </section>
</template>

<script setup lang="ts">
definePageMeta({ layout: 'shop' })

const cartStore = useCartStore()
const config = useRuntimeConfig()

const checkoutUrl = computed(() => {
  const base = cartStore.checkoutUrl
  if (!base) return undefined
  const returnTo = `${config.public.siteUrl}/`
  return `${base}?return_to=${encodeURIComponent(returnTo)}`
})

function formatPrice(amount: string, currency: string) {
  const symbol = currency === 'GBP' ? '£' : currency === 'USD' ? '$' : currency + ' '
  return symbol + Number(amount).toFixed(2)
}
</script>

12. Redirecting to Shopify Checkout

Add siteUrl to your runtimeConfig so it's available in the computed:

// nuxt.config.ts
runtimeConfig: {
  public: {
    siteUrl: process.env.SITE_URL || 'http://localhost:3000',
    shopifyDomain: process.env.SHOPIFY_DOMAIN || '',
    shopifyToken: process.env.SHOPIFY_TOKEN || '',
  },
}

A couple of things to be aware of:

The return_to URL must be on the same domain as your Shopify store. Since you're building a headless frontend on a custom domain, make sure that domain is added in Shopify Admin under Settings > Domains, otherwise Shopify may strip the parameter and fall back to its default behaviour.

One thing worth clarifying here because it confused me at first. When you add your domain in Settings > Domains for this purpose, you don't need to change any DNS settings or point your domain at Shopify. All you're doing is whitelisting it as a valid return_to destination. Shopify just needs to know it's a trusted URL. No DNS changes required.

During local development Shopify will likely ignore return_to when it's a localhost URL since it validates against known domains. The button will still work and redirect to checkout correctly, it just won't return to your specific URL afterwards. If you want to test the full flow locally you'd need to use something like ngrok to give your local dev server a public HTTPS URL and add that temporarily in Shopify Admin under Settings > Domains.

Worth knowing: Shopify's handling of return_to can be inconsistent on non-Plus stores. If it doesn't behave as expected, the more reliable alternative is to set the return URL directly in Shopify Admin under Settings > Checkout. Scroll down to the Order status page section and there's a dedicated field for this that doesn't require any code changes at all. For a standard Shopify plan that Admin settings approach is probably worth trying first before the return_to parameter route.

When the customer completes checkout and returns, the cart cookie is still set so fetchCart will restore their session. You can call clearCart at this point to reset the cart state.

Summary

LayerFileResponsibility
GraphQL clientuseShopify.tsSend queries/mutations, throw on errors
Product queriesuseProducts.tsFetch product listings and detail
Cart mutationsuseCart.tsCreate, add, update, remove, fetch cart
Statestores/cart.tsReactive cart state, init guard, persistence
Cookie APIserver/api/cart-id.*Read/write httpOnly cart ID cookie
Cookie refreshserver/plugins/cart.tsRoll the cookie expiry on every request
Layoutlayouts/shop.vueAwait cart before shop pages render
Cart buttoncomponents/shop/CartButton.vueCart icon with animated item count badge
Shop pagepages/index.vueProduct grid using ProductCard
Product cardcomponents/shop/ProductCard.vueProduct listing with add to cart
Product detailpages/product/[handle].vueVariant selection, gallery, add to cart
Cart pagepages/cart.vueLine items, quantity controls, checkout

The full boilerplate with all of the above already wired up is available at github.com/bok04/nuxt4-shopify-starter. Clone it, add your credentials and you have a working Shopify storefront in Nuxt 4 ready to customise.