Recipes

Product Page

How to create a product page for your Shopify store in Nuxt

A product page has to do a little more than a card. The shopper picks a variant, some mix of size and colour, and the price, image and availability all follow whatever they land on. This recipe fetches a product with its options, hands the shopper's picks to Shopify to resolve the matching variant, and puts a quantity input next to it.

Men's T-shirt

CA$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

You need a product with a couple of options set up in your store. Make sure the Nuxt Shopify module is configured and your store is set up.

Fetching the product

The field doing the work here is selectedOrFirstAvailableVariant. You hand it the options the shopper has picked so far and Shopify hands back the variant that matches, or the first available one when nothing is picked yet, which is exactly what you want on first load. So instead of pulling down the whole variant list and matching things ourselves, we let Shopify do the resolving:

product(handle: $handle) {
  title
  description

  featuredImage {
    ...ImageFields
  }

  options {
    ...ProductOptionFields
  }

  selectedOrFirstAvailableVariant(selectedOptions: $selectedOptions) {
    ...ProductVariantFields
  }
}

The selectedOptions variable is just a list of { name, value } pairs, one per option, typed as [SelectedOptionInput!]. The fields sit in three small fragments (shown in the code tab): ProductOptionFields for the choices, ProductVariantFields for the resolved variant, and ImageFields, which the product image and the variant image share. The option fragment doubles as the ProductOptionFieldsFragment type we hand to the selector component.

Resolving the selected variant

We keep the shopper's choice in the URL, so a picked variant is shareable and survives a refresh, the same way the cursors work on the collection page. Each option is one query parameter, so the selected options are really just route.query turned into the shape the API wants, and watch refetches whenever that changes:

variables: computed(() => ({
  handle: route.params.handle,
  selectedOptions: Object.entries(route.query).map(([name, value]) => ({ name, value: String(value) })),
})),
watch: [() => route.query],

The variant is then just a read off whatever Shopify sends back:

const variant = computed(() => product.value?.selectedOrFirstAvailableVariant)

The selector still needs to know what's currently picked so it can highlight it. That's the same route.query, except it's empty on first load, so we fall back to whatever variant Shopify resolved. Writing to it pushes the new selection onto the URL, which trips the watch and pulls the matching variant back:

const selected = computed<Record<string, string>>({
  get: () => Object.keys(route.query).length
    ? { ...route.query } as Record<string, string>
    : Object.fromEntries(variant.value?.selectedOptions.map(option => [option.name, option.value]) ?? []),
  set: options => router.push({ query: options }),
})

Because variant reads off the latest response, the image and price update the moment it lands.

The variant selector

The selector itself is small. It takes the product's options and writes the chosen value for each one back through v-model, so the page keeps owning the selection:

app/components/VariantSelector.vue
<script setup lang="ts">
import type { ProductOptionFieldsFragment } from '#shopify/storefront'

defineProps<{
  options: ProductOptionFieldsFragment[]
}>()

const selected = defineModel<Record<string, string>>({ required: true })
</script>

<template>
  <div class="space-y-4">
    <div
      v-for="option in options"
      :key="option.id"
    >
      <p class="mb-2 text-sm font-semibold text-muted">
        {{ option.name }}
      </p>

      <div class="flex flex-wrap gap-2">
        <UButton
          v-for="value in option.optionValues"
          :key="value.id"
          :variant="selected[option.name] === value.name ? 'solid' : 'outline'"
          color="neutral"
          size="sm"
          @click="selected = { ...selected, [option.name]: value.name }"
        >
          {{ value.name }}
        </UButton>
      </div>
    </div>
  </div>
</template>

When the variant Shopify hands back is sold out, its availableForSale is false, which is the cue to disable the buy button.

Quantity and add to cart

Quantity is a plain number input with a minimum of one, and the button reads its label and disabled state straight off the variant's availability:

<UInputNumber
  v-model="quantity"
  :min="1"
/>

<UButton
  size="lg"
  :disabled="!variant?.availableForSale"
>
  {{ variant?.availableForSale ? 'Add to Cart' : 'Unavailable' }}
</UButton>

The "Add to Cart" button doesn't do anything for now. Check out the cart recipe to see how we can create our own shopping cart and add products to it.

Copyright © 2026