Product Page
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
<script setup lang="ts">
const route = useRoute()
const router = useRouter()
const quantity = ref(1)
const { data: product } = await useStorefrontData(`product-${route.params.handle}`, `#graphql
query GetProductDetails($handle: String!, $selectedOptions: [SelectedOptionInput!]) {
product(handle: $handle) {
id
title
description
featuredImage {
...ImageFields
}
options {
...ProductOptionFields
}
selectedOrFirstAvailableVariant(selectedOptions: $selectedOptions) {
...ProductVariantFields
}
}
}
${OPTION_FRAGMENT}
${VARIANT_FRAGMENT}
${IMAGE_FRAGMENT}
`, {
variables: computed(() => ({
handle: route.params.handle,
selectedOptions: Object.entries(route.query).map(([name, value]) => ({ name, value: String(value) })),
})),
transform: data => data.product,
watch: [() => route.query],
})
const variant = computed(() => product.value?.selectedOrFirstAvailableVariant)
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 }),
})
const image = computed(() => variant.value?.image ?? product.value?.featuredImage)
const price = computed(() => {
if (!variant.value) return ''
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: variant.value.price.currencyCode,
}).format(Number(variant.value.price.amount))
})
</script>
<template>
<div class="grid gap-8 md:grid-cols-2">
<NuxtImg
provider="shopify"
v-if="image"
:src="image.url"
:alt="image.altText ?? undefined"
:width="image.width ?? undefined"
:height="image.height ?? undefined"
sizes="xs:100vw sm:50vw md:33vw"
class="rounded-md"
/>
<div class="space-y-6">
<div>
<p class="text-2xl font-bold">
{{ product?.title }}
</p>
<p class="text-xl">
{{ price }}
</p>
</div>
<p class="text-muted">
{{ product?.description }}
</p>
<VariantSelector
v-if="product"
v-model="selected"
:options="product.options"
/>
<div class="flex items-end gap-4">
<div>
<p class="mb-2 text-sm font-semibold text-muted">
Quantity
</p>
<UInputNumber
v-model="quantity"
:min="1"
class="w-32"
/>
</div>
<UButton
size="lg"
:disabled="!variant?.availableForSale"
>
{{ variant?.availableForSale ? 'Add to Cart' : 'Unavailable' }}
</UButton>
</div>
</div>
</div>
</template>
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:
<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.