Product Page
For your customers to view and buy your products, you need a Product page with a variant picker, price, image, availability, etc. This example fetches a product with its options, hands the customer's selection to Shopify to resolve the matching variant, and adds a quantity input and add-to-cart button.

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 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
}
}
}
`, {
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
Shopify gives us the customer's variant selection in selectedOrFirstAvailableVariant automatically.
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 give to the selector component.
Resolving the selected variant
We keep the customers'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 it 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 responds.
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">
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 sends back is sold out, its availableForSale is false, so we can 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 from 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 example to see how we can create our own shopping cart and add products to it.