Building A Store with Next.js + Shopify - PART 5: Fetch A Product & Fetch It's Variants Based on Selected Options

Mmesoma SaintMmesoma Saint
10 min read

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.

0
Subscribe to my newsletter

Read articles from Mmesoma Saint directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Mmesoma Saint
Mmesoma Saint