Building A Store with Next.js + Shopify - PART 5: Fetch A Product & Fetch It's Variants Based on Selected Options
Table of contents
Welcome, and congratulations on making it to this part of the tutorial.
Great job!
In this part, we are going to make all the product cards a link to a page where the user gets to see all the information about the product selected.
Are you ready?
This part requires two queries
The product query that fetches the full product on page load and then there is another query that fetches a variant of a product based on some selected options in that product.
These queries are straightforward. I promise.
Let’s start with the query that fetches the full product.
Paste this code in your app/api/query.ts
file.
export const RETRIEVE_A_PRODUCT = `
query Product($handle: String!) {
product (handle: $handle){
id
handle
title
descriptionHtml
description
images (first: 10) {
nodes {
url
width
height
altText
}
}
options {
name
values
}
priceRange {
minVariantPrice {
amount
}
}
compareAtPriceRange {
maxVariantPrice {
amount
}
}
}
}
`
As usual, the result from this query will not be pleasant for components to use.
So, we need to write our good old cleaner function for the full product. And to do that we need to tell the function what the RETRIEVE_A_PRODUCT query result will look like.
This means we need a type for this query's response.
Append this code in your <project>/app/api/types.ts
file.
export interface RetrieveProductQueryResult {
id: string
handle: string
title: string
descriptionHtml: string
description: string
images: {
nodes: {
url: string
width: number
height: number
altText: string
}[]
}
options: {
name: string
values: string[]
}[]
priceRange: {
minVariantPrice: {
amount: string
}
}
compareAtPriceRange: {
maxVariantPrice: {
amount: string
}
}
}
Now for the cleaner function for whatever this query returns.
Append this code in your app/api/utils.ts
file.
/**
* Cleans up a product returned from query.
* @param queryResult The full product result from querying shopify for a product.
* @returns A cleaner version of the returned product that can be used by components
*/
export function cleanProduct(queryResult: RetrieveProductQueryResult) {
const {
id,
title,
handle,
description,
descriptionHtml,
images,
options,
priceRange,
compareAtPriceRange,
} = queryResult
return {
id,
title,
handle,
description,
descriptionHtml,
options,
images: images.nodes,
price: priceRange ? Number(priceRange.minVariantPrice.amount) : null,
discount: compareAtPriceRange
? Number(compareAtPriceRange.maxVariantPrice.amount)
: null,
}
}
Remember, we had written a similar one earlier for the mini product query's response.
Now for the tricky part.
Since the intention behind the design of the product page is for it's content to be available before page load in client.
What to do?
This deserves a server component. A server component where the query will be fetched and not an API route handler as we did per usual. So we will split our product page into the major server component and then any client operations would be moved to a side client component.
Paste this in your <project>/app/collection/[cid]/product/[pid]/page.tsx
file
import { RETRIEVE_A_PRODUCT } from '@/app/api/query'
import { cleanProduct } from '@/app/api/utils'
import { Text } from '@/app/components/elements'
import Header from '@/app/components/header'
import ProductSlider from '@/app/components/product/slider'
import { shopifyFetch } from '@/lib/fetch'
import { PiCaretRightThin } from 'react-icons/pi'
import DetailsPanel from './details'
const getProduct = async (handle: string) => {
const variables = {
handle,
}
const { status, body } = await shopifyFetch({
query: RETRIEVE_A_PRODUCT,
variables,
})
if (status === 200) {
return cleanProduct(body.data?.product)
}
}
export default async function Product({
params,
}: {
params: { cid: string; pid: string }
}) {
const { cid, pid } = params
const product = await getProduct(pid)
return (
<div className='px-7 max-w-[120rem] mx-auto'>
<Header />
<div className='py-4 mt-12'>
<div className='flex justify-start items-center gap-5'>
<Text size='xs' faded>
Home
</Text>
<PiCaretRightThin className='text-2xl' />
<Text size='xs' faded>
{cid.split('-').join(' ')}
</Text>
<PiCaretRightThin className='text-2xl' />
<Text size='xs' faded>
{product?.title ?? ''}
</Text>
</div>
<div className='grid grid-cols-2 place-content-between items-stretch gap-8 py-6'>
<div className='col-span-1'>
<ProductSlider images={product?.images ?? []} />
</div>
<div className='col-span-1'>
<DetailsPanel
title={product?.title ?? '...'}
price={product?.price ?? 0}
discount={product?.discount ?? 0}
options={product?.options ?? []}
description={product?.description ?? '...'}
/>
</div>
</div>
</div>
</div>
)
}
Here is the definition for our little ProductSlider
component.
Paste this in your <project>/app/component/product/slider.tsx
file
'use client'
import React, { useState } from 'react'
interface ProductImage {
url: string
altText: string
}
const ProductSlider: React.FC<{ images: ProductImage[] }> = ({ images }) => {
const [activeImageIndex, setActiveImageIndex] = useState(0)
const handleImageClick = (index: number) => {
setActiveImageIndex(index)
}
return (
<div className='container mx-auto flex max-h-[35rem]'>
<div className='max-h-full overflow-hidden'>
<div className='flex flex-col w-full h-full gap-4 overflow-y-auto'>
{images.map((image, index) => (
<button
key={index}
type='button'
className={`shrink-0 relative w-full h-1/5 mr-4 overflow-hidden cursor-pointer border-2 hover:border-black/40 ${
activeImageIndex === index
? 'border-black'
: 'border-transparent'
}`}
onClick={() => handleImageClick(index)}
>
<img
src={image.url}
alt={image.altText}
className='w-full h-32 object-cover'
/>
</button>
))}
</div>
</div>
<div className='relative grow'>
<img
src={images[activeImageIndex].url}
alt={images[activeImageIndex].altText}
className='w-full h-full object-cover'
/>
</div>
</div>
)
}
export default ProductSlider
That's the first and major part. That is the server component.
Now, let's write the client component. This client component would be responsible for fetching the variants later.
Paste this in your <project>/app/collection/[cid]/product/[pid]/details.tsx
file
'use client'
import { Button, OptionBox, Text } from '@/app/components/elements'
import { HR } from '@/app/components/filter'
import { formatMoney } from '@/lib/product'
interface DetailsPanelProps {
title: string
price: number
discount: number
options: { name: string; values: string[] }[]
description: string
}
export default function DetailsPanel({
title,
price,
discount,
options,
description,
}: DetailsPanelProps) {
return (
<>
<HR>
<div className='flex flex-col gap-5 m-3 mb-1'>
<Text size='xl'>{title}</Text>
<div className='flex justify-start items-center gap-3'>
<Text size='lg'>{formatMoney(price)}</Text>
<span className='line-through decoration-from-font'>
<Text size='sm'>{formatMoney(discount)}</Text>
</span>
</div>
</div>
</HR>
<HR>
{options.map((option) => (
<div key={option.name} className='flex flex-col gap-5 m-3 mb-1'>
<Text size='md'>{`${option.name}s`}</Text>
<div className='flex flex gap-2'>
{option.values.map((value) => (
<OptionBox
key={value}
active={false}
onClick={() => {
return
}}
>
{value}
</OptionBox>
))}
</div>
</div>
))}
</HR>
<HR>
<div>
<div className='flex flex-col gap-5 m-3 mb-1'>
<Text size='md'>Description</Text>
<Text size='sm' copy>
{description}
</Text>
</div>
</div>
</HR>
<div className='flex justify-center items-center gap-8 m-3'>
<Button onClick={() => console.log('Product bought!!')}>Buy</Button>
<Button onClick={() => console.log('Added to cart')} outline>
Add to cart
</Button>
</div>
</>
)
}
Here is the definition for the little OptionBox
component in the above code.
Append this in your <project>/app/components/elements
file
export function OptionBox({
children,
active,
onClick,
}: {
children: string
active: boolean
onClick: () => void
}) {
return (
<button
type='button'
key={children}
className={`px-4 py-2 border border-black/20 rounded-md hover:bg-gray-100 hover:text-black focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black ${
active ? 'bg-black text-white' : ''
}`}
onClick={onClick}
>
{children}
</button>
)
}
If you indeed paid any attention at the beginning of this section.
I said something about two queries. Or didn’t I?
Well, there is one more query remaining. And it all depends on the selected options.
From the code above, you can see that if there are at all options from the product then, some are selected by default. Now what the next query will do is to query for features of this variant that are specific to variants such as Price and amount in stock.
Here is the query for variants
Paste this query in your app/api/query.ts
file.
export const GET_VARIANT_BY_SELECTED_OPTIONS = `
query VariantByOptions($handle:String!, $selectedOptions: [SelectedOptionInput!]!) {
product (handle: $handle) {
handle
variantBySelectedOptions (selectedOptions: $selectedOptions) {
id
sku
price {
amount
}
compareAtPrice {
amount
}
quantityAvailable
}
}
}
`
As usual, we need a type for the query result and a cleaner function for the query result and here is our type.
Paste this query in your app/api/types.ts
file.
export interface GetVariantQueryResult {
handle: string
variantBySelectedOptions: {
id: string
sku: string
price: {
amount: string
}
compareAtPrice: {
amount: string
}
quantityAvailable: number
}
}
And the cleaner function.
Paste this query in your app/api/utils.ts
file.
/**
* Converts a query result variant into a usable object.
* @param queryResult The version of the product based on some key options
* @returns A version that can be used by components
*/
export function cleanProductVariant(queryResult: GetVariantQueryResult) {
const {variantBySelectedOptions: variant} = queryResult
return {
id: variant.id,
sku: variant.sku,
price: variant.price ? Number(variant.price.amount) : null,
discount: variant.compareAtPrice
? Number(variant.compareAtPrice.amount)
: null,
quantityAvailable: variant.quantityAvailable,
}
}
Everything is almost set. You need to setup the API route handler for this query to be accessible as an API from the client (details.tsx).
Paste this query in your app/api/products/variant/route.ts
file.
import { shopifyFetch } from '@/lib/fetch'
import { NextRequest } from 'next/server'
import { GET_VARIANT_BY_SELECTED_OPTIONS } from '../../query'
import { cleanProductVariant } from '../../utils'
export async function GET(Request: NextRequest) {
const { selectedOptions } = await Request.json()
const searchParams = Request.nextUrl.searchParams
const handle = searchParams.get('handle')
const variables = {
handle,
selectedOptions,
}
const { status, body } = await shopifyFetch({
query: GET_VARIANT_BY_SELECTED_OPTIONS,
variables,
})
if (status === 200) {
const product = cleanProductVariant(body.data?.product)
return Response.json({ status: 200, body: product })
} else {
return Response.json({ status: 500, message: 'Error receiving data' })
}
}
Now let’s edit the product page to make changes based on the values returned by the variant. Below is an updated version of the code.
Paste this code in your app/[pid]/product/details.tsx
'use client'
import { useEffect, useState } from 'react'
import { Button, MiniBox, OptionBox, Text } from '@/app/components/elements'
import { HR } from '@/app/components/filter'
import { formatMoney } from '@/lib/product'
interface DetailsPanelProps {
title: string
handle: string
price: number
discount: number
options: { name: string; values: string[] }[]
description: string
}
interface Variant {
id: string
sku: string
price: number
discount: number
quantityAvailable: number
}
type SelectedOptions = { name: string; value: string }[]
const extractDefaultOption = (
options: { name: string; values: string[] }[]
): SelectedOptions => {
// Extract the first value of every item in the array and store them in this format.
// [{name: "Color", value: "Bllue"}...]
return options.map((option) => ({
name: option.name,
value: option.values[0],
}))
}
export default function DetailsPanel({
title,
handle,
price,
discount,
options,
description,
}: DetailsPanelProps) {
const [amount, setAmount] = useState<number>(1)
const [variant, setVariant] = useState<Variant>()
const [loading, setLoading] = useState<boolean>(true)
const [selectedOptions, setSelectedOptions] = useState<SelectedOptions>(
extractDefaultOption(options)
)
const setOptionsValues = (name: string, value: string) => {
const newSelectedOptions = selectedOptions.map((option) => {
if (option.name === name) {
return { ...option, value }
}
return option
})
setSelectedOptions(newSelectedOptions)
}
const inSelectedOptions = (name: string, value: string) => {
return selectedOptions.some(
(option) => option.name === name && option.value === value
)
}
useEffect(() => {
setLoading(true)
fetch(`/api/products/variant?handle=${handle}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ selectedOptions }),
})
.then((res) => res.json())
.then((data) => setVariant(data?.body))
.catch((e) => console.log('An error occurred!', e))
.finally(() => setLoading(false))
}, [selectedOptions])
return (
<>
<HR>
<div className='flex flex-col gap-5 m-6 mb-4'>
<Text size='xl'>{title}</Text>
<div className='flex justify-start items-center gap-3'>
<Text size='lg'>
{loading ? '...' : formatMoney(variant?.price ?? price)}
</Text>
<span className='line-through decoration-from-font'>
<Text size='sm'>
{loading ? '...' : formatMoney(variant?.discount ?? discount)}
</Text>
</span>
</div>
</div>
</HR>
<HR>
{options.map((option) => (
<div key={option.name} className='flex flex-col gap-5 m-6 mb-4'>
<Text size='md'>{`${option.name}s`}</Text>
<div className='flex flex gap-4'>
{option.values.map((value) => (
<OptionBox
key={value}
active={inSelectedOptions(option.name, value)}
onClick={() => setOptionsValues(option.name, value)}
>
{value}
</OptionBox>
))}
</div>
</div>
))}
</HR>
<HR>
<div className='flex flex-col justify-start items-start gap-8 m-6 mb-4'>
<div className='flex flex-col gap-4'>
<Text size='md'>Quantity</Text>
<Text size='sm'>{`Only ${
loading ? '...' : variant?.quantityAvailable ?? 0
} item${
variant?.quantityAvailable ?? 0 > 1 ? 's' : ''
} left`}</Text>
<div className='flex justify-start items-center gap-4'>
<MiniBox
onClick={() => amount > 1 && setAmount((prev) => prev - 1)}
>
-
</MiniBox>
<Text size='md'>
{loading
? '...'
: Math.min(
amount,
variant?.quantityAvailable ?? 0
).toString()}
</Text>
<MiniBox
onClick={() =>
amount < (variant?.quantityAvailable ?? 0) &&
setAmount((prev) => prev + 1)
}
>
+
</MiniBox>
</div>
</div>
</div>
</HR>
<HR>
<div className='flex flex-col justify-start items-start gap-8 m-6 mb-4'>
<div className='flex flex-col gap-4'>
<Text size='md'>Total</Text>
<Text size='lg'>
{loading
? '...'
: formatMoney((variant?.price ?? price) * amount)}
</Text>
</div>
<div className='flex justify-start items-center gap-8'>
<Button
onClick={() => console.log('Product bought!!')}
disabled={amount < 1 || loading}
>
Buy
</Button>
<Button
onClick={() => console.log('Added to cart')}
disabled={amount < 1 || loading}
outline
>
Add to cart
</Button>
</div>
</div>
</HR>
<div>
<div className='flex flex-col gap-5 m-6 mb-4'>
<Text size='md'>Description</Text>
<Text size='sm' copy>
{description}
</Text>
</div>
</div>
</>
)
}
Here is the definition for the little MiniBox
component in the above code. Along with the updated version of our Button
component.
Append this in your <project>/app/components/elements
file
export function MiniBox({
children,
onClick,
}: {
children: React.ReactNode
onClick: () => void
}) {
return (
<button
type='button'
className='w-12 h-12 flex justify-center items-center border border-black/20 hover:bg-gray-100 hover:text-black focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black'
onClick={onClick}
>
{children}
</button>
)
}
export function Button({
outline,
children,
onClick,
disabled,
}: {
outline?: boolean
children: React.ReactNode
onClick: () => void
disabled?: boolean
}) {
const outlineStyles = `${
outline
? 'bg-transparent hover:bg-gray-300 disabled:border-black/30'
: 'bg-black/90 hover:bg-black/50 disabled:bg-black/30'
}`
const outlineTextStyles = `${outline ? 'text-black/90' : 'text-white'}`
return (
<button
type='button'
disabled={disabled}
className={`py-4 px-10 shadow-md border border-black/90 leading-none text-2xl font-light ${outlineStyles} ${outlineTextStyles}`}
onClick={onClick}
>
{children}
</button>
)
}
Very straightforward wasn’t it?
That's a wrap for this part of the tutorial. Phew!
You will find the full code for this section in this GitHub Link.
Congratulations you just created a product page in which certain values have a dependency on certain selected options.
This would come in handy when you design the cart in the next section.
See you there.
Subscribe to my newsletter
Read articles from Mmesoma Saint directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by