Building A Store with Next.js + Shopify - PART 4: Search and Filter Products in Shopify

Phew!

The last section was quite the work. But I am glad you got this far. Good job!

This section will focus on writing a search and filter query on your Next.js app server and then using some tweaks on the filter to get a more specific search result.

But before that, you can search and filter effectively, you need to make some tweaks in your Shopify store. Follow the procedure below:

  1. Head back to your Shopify admin page.

  2. In your app and sales section, install Search and Filter.

  3. Grant the app access to your store.

  4. On the next page that appears, go to Create Filters.

  5. Select price and all other values under options and make them filterable.

    All the values under options are only visible if your products have variants

In the end, you’ll have something like this in your filters page.

What this means is. For the products in your Shopify store, you can only filter based on the following:

  1. Price

  2. Variant options e.g., Size, Color, Material

  3. and others

So for every single query search, filter keys are fetched along side the products returned. This means that only when search query(The word being searched) is changed, do the filter get refreshed to match all the products in that result. And not when the user tries to filter the search result.

This means that we only need just one query that searches for products on query change and also on filter modification.

But for now, you are going to focus on just searching. Here is the query for just searching.

Paste this code in your <project>/app/api/query.ts file.

export const SEARCH_PRODUCTS = `
query SearchProducts($query: String!, $first: Int, $after: String) {
  search(query: $query, first: $first, after: $after, types: PRODUCT) {
    totalCount
    edges {
      node {
        ... on Product {
          id
          title
          handle
          featuredImage {
            url
          }
          priceRange {
            minVariantPrice {
              amount
            }
          }
          compareAtPriceRange {
            maxVariantPrice {
              amount
            }
          }
          options {
            name
            values
          }
          collections(first: 1) {
            nodes {
              handle
              title
            }
          }
        }
      }
      cursor
    }
    pageInfo {
      hasNextPage
    }
  }
}
`

Next, the api handler for the query above.

Paste this code in your <project>/app/api/search/route.ts file.

import { SEARCH_PRODUCTS } from '@/app/api/query'
import { MiniProductQueryResult } from '@/app/api/types'
import { cleanMiniProduct } from '@/app/api/utils'
import { shopifyFetch } from '@/lib/fetch'
import { NextRequest } from 'next/server'

const LIMIT = 4

export async function POST(Request: NextRequest) {
  const searchParams = Request.nextUrl.searchParams
  const query = searchParams.get('query')
  const after = searchParams.get('cursor')

  const variables: { first: number; query: string | null; after?: string } = {
    first: LIMIT,
    query,
  }
  if (after) variables['after'] = after

  const { status, body } = await shopifyFetch({
    query: SEARCH_PRODUCTS,
    variables,
  })

  if (status === 200) {
    const results = body.data?.search.edges
    const pageInfo = body.data?.search.pageInfo
    const len = results.length
    const total = body.data?.search.totalCount

    const cleanedResults = results.map(
      ({ node }: { node: MiniProductQueryResult }) => cleanMiniProduct(node)
    )

    return Response.json({
      status,
      body: {
        results: cleanedResults,
        total,
        pageInfo: {
          ...pageInfo,
          after: results[len - 1]?.cursor,
        },
      },
    })
  } else
    return Response.json({ status: 500, message: 'Error receiving data..' })
}

We are done with the server section for the moment. Let's move to the client-side.

We need a search page.

Paste this code in your <project>/app/search/page.tsx file.

'use client'

import {
  PiShoppingCartThin,
  PiHeartStraightThin,
  PiCaretRightThin,
  PiArrowDownThin,
  PiArrowUpThin,
} from 'react-icons/pi'
import { Button, Text } from '@/app/components/elements'
import Card from '@/app/components/product/card'
import DropDown, { DropItem } from '@/app/components/dropdown'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { MiniProduct } from '@/lib/product'
import {
  sortByDateAsc,
  sortByDateDesc,
  sortByNameAsc,
  sortByNameDesc,
  sortByPriceAsc,
  sortByPriceDesc,
} from '@/lib/sort'

export default function Search() {
  const searchParams = useSearchParams()
  const query = searchParams.get('q')
  const [products, setProducts] = useState<MiniProduct[]>([])
  const [loading, setLoading] = useState(true)
  const [afterCursor, setAfterCursor] = useState(null)
  const [hasError, setHasError] = useState(false)
  const [hasMore, setHasMore] = useState(false)
  const [total, setTotal] = useState(0)
  const [sort, setSort] = useState('Name (asc)')

  const sortProducts = (products: MiniProduct[], sortType: string) => {
    switch (sortType) {
      case 'Name (asc)':
        setProducts(sortByNameAsc(products))
        break
      case 'Name (desc)':
        setProducts(sortByNameDesc(products))
        break
      case 'Date (asc)':
        setProducts(sortByDateAsc(products))
        break
      case 'Date (desc)':
        setProducts(sortByDateDesc(products))
        break
      case 'Price (asc)':
        setProducts(sortByPriceAsc(products))
        break
      case 'Price (desc)':
        setProducts(sortByPriceDesc(products))
        break
      default:
        setProducts(sortByNameAsc(products))
        break
    }
  }

  const load = () => {
    setLoading(true)
    setHasError(false)
    setHasMore(false)
    setTotal(0)

    const cursor = afterCursor ?? ''

    fetch(`/api/search?query=${query}&cursor=${cursor}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
    })
      .then((res) => res.json())
      .then((data) => {
        const results = data.body?.results
        const newProducts =
          products.length > 0 ? [...products, ...results] : results

        sortProducts(newProducts, sort)
        setHasMore(data.body?.pageInfo?.hasNextPage)
        setAfterCursor(data.body?.pageInfo?.after)
        setTotal(data.body?.total ?? 0)
      })
      .catch(() => setHasError(true))
      .finally(() => setLoading(false))
  }

  useEffect(() => {
    load()
  }, [])

  useEffect(() => {
    sortProducts(products, sort)
  }, [sort])

  return (
    <div className='px-7 max-w-[120rem] mx-auto'>
      <div className='flex justify-between items-center gap-10 py-4'>
        <div className=''>
          <Text size='xl'>Belsac</Text>
        </div>
        <div className='flex justify-center items-stretch w-[40%]'>
          <input
            placeholder='Search bags'
            className='grow self-stretch p-4 text-xl font-light border border-black/90 focus:outline-none placeholder:text-black/90 placeholder:text-xl'
          />
          <Button onClick={() => console.log('Search button clicked')}>
            Search
          </Button>
        </div>
        <div className='flex justify-end items-center gap-10'>
          <PiHeartStraightThin className='text-4xl' />
          <PiShoppingCartThin className='text-4xl' />
        </div>
      </div>
      <div className='py-4 mt-12'>
        <div className='flex justify-between items-center gap-10'>
          {loading ? (
            <Text size='lg'>&middot;&middot;&middot;</Text>
          ) : (
            <Text size='lg'>
              {`${total} result${total > 1 ? 's' : ''} for "${query ?? ''}"`}
            </Text>
          )}
          <DropDown title='Sort by' initialActive='Name (asc)'>
            <DropItem value='Name (asc)' onClick={() => setSort('Name (asc)')}>
              <span className='flex justify-center items-center gap-0'>
                <PiArrowUpThin className='text-4xl' />
                Name
              </span>
            </DropItem>
            <DropItem
              value='Name (desc)'
              onClick={() => setSort('Name (desc)')}
            >
              <span className='flex justify-center items-center gap-0'>
                <PiArrowDownThin className='text-4xl' />
                Name
              </span>
            </DropItem>
            <DropItem value='Date (asc)' onClick={() => setSort('Date (asc)')}>
              <span className='flex justify-center items-center gap-0'>
                <PiArrowUpThin className='text-4xl' />
                Date
              </span>
            </DropItem>
            <DropItem
              value='Date (desc)'
              onClick={() => setSort('Date (desc)')}
            >
              <span className='flex justify-center items-center gap-0'>
                <PiArrowDownThin className='text-4xl' />
                Date
              </span>
            </DropItem>
            <DropItem
              value='Price (ascending)'
              onClick={() => setSort('Price (asc)')}
            >
              <span className='flex justify-center items-center gap-0'>
                <PiArrowUpThin className='text-4xl' />
                Price
              </span>
            </DropItem>
            <DropItem
              value='Price (descending)'
              onClick={() => setSort('Price (desc)')}
            >
              <span className='flex justify-center items-center gap-0'>
                <PiArrowDownThin className='text-4xl' />
                Price
              </span>
            </DropItem>
          </DropDown>
        </div>
        <div className='grid grid-cols-4 place-content-between items-stretch gap-16 mt-8'>
          {loading ? (
            <div className='w-full col-span-full flex justify-center items-center'>
              <Text size='md'>Loading...</Text>
            </div>
          ) : hasError ? (
            <div className='w-full col-span-full flex justify-center items-center'>
              <Text size='md'>Something went wrong.</Text>
            </div>
          ) : products.length < 1 ? (
            <div className='w-full col-span-full flex justify-center items-center'>
              <Text size='md'>No products found. Search something else.</Text>
            </div>
          ) : (
            products.map((product) => (
              <Card key={product.id} product={product} full />
            ))
          )}
          <div className='col-span-full flex justify-center items-center gap-8'>
            {hasMore && (
              <Button onClick={() => load()} outline>
                <span className='flex justify-center items-center gap-4'>
                  More
                  <PiCaretRightThin className='text-4xl' />
                </span>
              </Button>
            )}
          </div>
        </div>
      </div>
    </div>
  )
}

Now, navigate to the search page with a query (q=<something>) in your browser. You should see a page similar to the home page but with only products matching the search query.

Before we move to the filter section. I want to point out some ways we can optimize and refactor the codes above.

Let's start with the header. The same header has appeared in two pages. Don't you think it's time to make it a component. Following the D.R.Y(Don't Repeat Yourself) rule of programming.

Let's fix that, shall we.

Paste this code in your <project>/app/components/header.tsx file.

import { PiHeartStraightThin, PiShoppingCartThin } from 'react-icons/pi'
import { Button, Text } from './elements'

export default function Header() {
  return (
    <div className='flex justify-between items-center gap-10 py-4'>
      <div className=''>
        <Text size='xl'>Belsac</Text>
      </div>
      <div className='flex justify-center items-stretch w-[40%]'>
        <input
          placeholder='Search bags'
          className='grow self-stretch p-4 text-xl font-light border border-black/90 focus:outline-none placeholder:text-black/90 placeholder:text-xl'
        />
        <Button onClick={() => console.log('Search button clicked')}>
          Search
        </Button>
      </div>
      <div className='flex justify-end items-center gap-10'>
        <PiHeartStraightThin className='text-4xl' />
        <PiShoppingCartThin className='text-4xl' />
      </div>
    </div>
  )
}

Also, the sort functions and components that cluster our search and home page components.

Let's do something about them. Here's the plan.

Move all the sort functions to a hook called useSort and all others to a component called Sort.

Paste this code in your <project>/app/hooks/usesort.tsx file.

import { useEffect, useState } from 'react'
import type { MiniProduct } from '@/lib/product'
import {
  sortByDateAsc,
  sortByDateDesc,
  sortByNameAsc,
  sortByNameDesc,
  sortByPriceAsc,
  sortByPriceDesc,
} from '@/lib/sort'

export default function useSort() {
  const [products, setProducts] = useState<MiniProduct[]>([])
  const [sort, setSort] = useState('Name (asc)')

  const sortProducts = (unsortedProducts: MiniProduct[], sortType: string) => {
    switch (sortType) {
      case 'Name (asc)':
        setProducts(sortByNameAsc(unsortedProducts))
        break
      case 'Name (desc)':
        setProducts(sortByNameDesc(unsortedProducts))
        break
      case 'Date (asc)':
        setProducts(sortByDateAsc(unsortedProducts))
        break
      case 'Date (desc)':
        setProducts(sortByDateDesc(unsortedProducts))
        break
      case 'Price (asc)':
        setProducts(sortByPriceAsc(unsortedProducts))
        break
      case 'Price (desc)':
        setProducts(sortByPriceDesc(unsortedProducts))
        break
      default:
        setProducts(sortByNameAsc(unsortedProducts))
        break
    }
  }

  useEffect(() => {
    sortProducts(products, sort)
  }, [sort])

  return { products, sort, setSort, sortProducts }
}

Paste this code in your <project>/app/components/sort.tsx file.

import { PiArrowDownThin, PiArrowUpThin } from 'react-icons/pi'
import DropDown, { DropItem } from './dropdown'

export default function Sort({ setSort }: { setSort: (sort: string) => void }) {
  return (
    <DropDown title='Sort by' initialActive='Name (asc)'>
      <DropItem value='Name (asc)' onClick={() => setSort('Name (asc)')}>
        <span className='flex justify-center items-center gap-0'>
          <PiArrowUpThin className='text-4xl' />
          Name
        </span>
      </DropItem>
      <DropItem value='Name (desc)' onClick={() => setSort('Name (desc)')}>
        <span className='flex justify-center items-center gap-0'>
          <PiArrowDownThin className='text-4xl' />
          Name
        </span>
      </DropItem>
      <DropItem value='Date (asc)' onClick={() => setSort('Date (asc)')}>
        <span className='flex justify-center items-center gap-0'>
          <PiArrowUpThin className='text-4xl' />
          Date
        </span>
      </DropItem>
      <DropItem value='Date (desc)' onClick={() => setSort('Date (desc)')}>
        <span className='flex justify-center items-center gap-0'>
          <PiArrowDownThin className='text-4xl' />
          Date
        </span>
      </DropItem>
      <DropItem value='Price (asc)' onClick={() => setSort('Price (asc)')}>
        <span className='flex justify-center items-center gap-0'>
          <PiArrowUpThin className='text-4xl' />
          Price
        </span>
      </DropItem>
      <DropItem value='Price (desc)' onClick={() => setSort('Price (desc)')}>
        <span className='flex justify-center items-center gap-0'>
          <PiArrowDownThin className='text-4xl' />
          Price
        </span>
      </DropItem>
    </DropDown>
  )
}

Now, update your home page and search page with this component.

Paste this code in your <project>/app/page.tsx file.

'use client'

import { PiCaretRightThin, PiCaretLeftThin } from 'react-icons/pi'
import { Button, Text } from '@/app/components/elements'
import Card from '@/app/components/product/card'
import { useEffect, useState } from 'react'
import Header from './components/header'
import useSort from './hooks/usesort'
import Sort from './components/sort'

export default function Home() {
  const [loading, setLoading] = useState(false)
  const [beforeCursor, setBeforeCursor] = useState(null)
  const [afterCursor, setAfterCursor] = useState(null)
  const [hasError, setHasError] = useState(false)
  const [hasMore, setHasMore] = useState(false)
  const [hasPrev, setHasPrev] = useState(false)
  const { products, sort, setSort, sortProducts } = useSort()

  const load = (before?: string | null, after?: string | null) => {
    setLoading(true)
    setHasError(false)
    setHasMore(false)
    setHasPrev(false)

    fetch(`/api/products?before=${before ?? before}&after=${after && after}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
    })
      .then((res) => res.json())
      .then((data) => {
        sortProducts(data.body?.results, sort)
        setHasMore(data.body?.pageInfo?.hasNextPage)
        setHasPrev(data.body?.pageInfo?.hasPreviousPage)
        setBeforeCursor(data.body?.pageInfo?.before)
        setAfterCursor(data.body?.pageInfo?.after)
      })
      .catch(() => setHasError(true))
      .finally(() => setLoading(false))
  }

  useEffect(() => {
    load(null, null)
  }, [])

  return (
    <div className='px-7 max-w-[120rem] mx-auto'>
      <Header />
      <div className='py-4 mt-12'>
        <div className='flex justify-between items-center gap-10'>
          <Text size='lg'>Featured</Text>
          <Sort setSort={setSort} />
        </div>
        <div className='grid grid-cols-4 place-content-between items-stretch gap-16 mt-8'>
          {loading ? (
            <div className='w-full col-span-full flex justify-center items-center'>
              <Text size='md'>Loading...</Text>
            </div>
          ) : hasError ? (
            <div className='w-full col-span-full flex justify-center items-center'>
              <Text size='md'>Something went wrong.</Text>
            </div>
          ) : (
            products.map((product) => (
              <Card key={product.id} product={product} full />
            ))
          )}
          <div className='col-span-full flex justify-center items-center gap-8'>
            {hasPrev && (
              <Button onClick={() => load(beforeCursor, null)} outline>
                <span className='flex justify-center items-center gap-4'>
                  <PiCaretLeftThin className='text-4xl' />
                  Prev
                </span>
              </Button>
            )}
            {hasMore && (
              <Button onClick={() => load(null, afterCursor)} outline>
                <span className='flex justify-center items-center gap-4'>
                  More
                  <PiCaretRightThin className='text-4xl' />
                </span>
              </Button>
            )}
          </div>
        </div>
      </div>
    </div>
  )
}

Paste this code in your <project>/app/search/page.tsx file.

'use client'

import { PiCaretDownThin } from 'react-icons/pi'
import { Button, Text } from '@/app/components/elements'
import Card from '@/app/components/product/card'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import Header from '../components/header'
import useSort from '../hooks/usesort'
import Sort from '../components/sort'

export default function Search() {
  const searchParams = useSearchParams()
  const query = searchParams.get('q')
  const [loading, setLoading] = useState(true)
  const [afterCursor, setAfterCursor] = useState(null)
  const [hasError, setHasError] = useState(false)
  const [hasMore, setHasMore] = useState(false)
  const [total, setTotal] = useState(0)
  const { products, sort, setSort, sortProducts } = useSort()

  const load = () => {
    setLoading(true)
    setHasError(false)
    setHasMore(false)
    setTotal(0)

    const cursor = afterCursor ?? ''

    fetch(`/api/search?query=${query}&cursor=${cursor}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
    })
      .then((res) => res.json())
      .then((data) => {
        const results = data.body?.results
        const newProducts =
          products.length > 0 ? [...products, ...results] : results

        sortProducts(newProducts, sort)
        setHasMore(data.body?.pageInfo?.hasNextPage)
        setAfterCursor(data.body?.pageInfo?.after)
        setTotal(data.body?.total ?? 0)
      })
      .catch(() => setHasError(true))
      .finally(() => setLoading(false))
  }

  useEffect(() => {
    load()
  }, [])

  return (
    <div className='px-7 max-w-[120rem] mx-auto'>
      <Header />
      <div className='py-4 mt-12'>
        <div className='flex justify-between items-center gap-10'>
          {loading ? (
            <Text size='lg'>&middot;&middot;&middot;</Text>
          ) : (
            <Text size='lg'>
              {`${total} result${total > 1 ? 's' : ''} for "${query ?? ''}"`}
            </Text>
          )}
          <Sort setSort={setSort} />
        </div>
        <div className='grid grid-cols-4 place-content-between items-stretch gap-16 mt-8'>
          {loading ? (
            <div className='w-full col-span-full flex justify-center items-center'>
              <Text size='md'>Loading...</Text>
            </div>
          ) : hasError ? (
            <div className='w-full col-span-full flex justify-center items-center'>
              <Text size='md'>Something went wrong.</Text>
            </div>
          ) : products.length < 1 ? (
            <div className='w-full col-span-full flex justify-center items-center'>
              <Text size='md'>No products found. Search something else.</Text>
            </div>
          ) : (
            products.map((product) => (
              <Card key={product.id} product={product} full />
            ))
          )}
          <div className='col-span-full flex justify-center items-center gap-8'>
            {hasMore && (
              <Button onClick={() => load()} outline>
                <span className='flex justify-center items-center gap-4'>
                  More
                  <PiCaretDownThin className='text-4xl' />
                </span>
              </Button>
            )}
          </div>
        </div>
      </div>
    </div>
  )
}

Phew!

That's it for the searching section. Time for filtering the search results.

For this part, we would start by building the type for product gotten from searching earlier.

Append this code in your <project>/app/api/types.ts file.

export interface SearchProductsQueryResult {
  totalCount: number
  edges: {
    node: MiniProductQueryResult
    cursor: string
  }[]
  pageInfo: {
    hasNextPage: boolean
    hasPreviousPage: boolean
  }
}

We need this type to be able to extract the filter keys. Now let's write the function that extracts the filter keys knowing the nature of the search result.

Append this code in your <project>/app/api/utils.ts file.

/**
 * Extracts the filter from the query result
 * @param queryResult The result of fetching the search products query.
 * @returns A Filter containing keys partaining to query result.
 */
export function extractFilter(queryResult: SearchProductsQueryResult): Filter {
  const { edges } = queryResult
  const brands = Array()
  let maxPrice = 0
  let optionValues = {}

  edges.forEach(({ node }) => {
    const { options, collections, priceRange } = node

    // Extract collections as brands
    const collectionTitles = collections.nodes.map((node) => node.title)
    brands.push(collectionTitles[0])

    // Extract prices
    const { minVariantPrice } = priceRange
    maxPrice = Math.max(maxPrice, parseInt(minVariantPrice.amount))

    // Extract other options
    // 1. Extract all the names with no duplicates.
    const optionNames = Array.from(
      new Set(options.map((option) => option.name))
    )
    // 2. Extract all the values attached to the extracted names and place them in an object with boolean false value
    // e.g. {'Color': {'blue': false, 'orange': false}, Size: {'small': false, 'medium': false}}
    optionValues = Object.fromEntries(
      optionNames.map((name) => [
        name,
        Object.fromEntries(
          options
            .filter((option) => option.name === name)[0]
            .values.map((value) => [value, false])
        ),
      ])
    )
  })

  return {
    price: {
      min: 0,
      max: maxPrice,
      highest: maxPrice,
    },
    brands: Object.fromEntries(brands.map((item) => [item, false])),
    ...optionValues,
  }
}

Next, we plugin this function to the query api handler so we can send the filter down to the client per request.

Paste this code in your <project>/app/api/search/route.ts file.

import { SEARCH_PRODUCTS } from '@/app/api/query'
import { MiniProductQueryResult } from '@/app/api/types'
import { cleanMiniProduct, extractFilter } from '@/app/api/utils'
import { shopifyFetch } from '@/lib/fetch'
import { NextRequest } from 'next/server'

const LIMIT = 4

export async function POST(Request: NextRequest) {
  const searchParams = Request.nextUrl.searchParams
  const query = searchParams.get('query')
  const after = searchParams.get('cursor')

  const variables: { first: number; query: string | null; after?: string } = {
    first: LIMIT,
    query,
  }
  if (after) variables['after'] = after

  const { status, body } = await shopifyFetch({
    query: SEARCH_PRODUCTS,
    variables,
  })

  if (status === 200) {
    const results = body.data?.search.edges
    const pageInfo = body.data?.search.pageInfo
    const len = results.length
    const total = body.data?.search.totalCount
    const filter = extractFilter(body.data?.search) // Plugin to search result.

    const cleanedResults = results.map(
      ({ node }: { node: MiniProductQueryResult }) => cleanMiniProduct(node)
    )

    return Response.json({
      status,
      body: {
        filter, // Send to client
        results: cleanedResults,
        total,
        pageInfo: {
          ...pageInfo,
          after: results[len - 1]?.cursor,
        },
      },
    })
  } else
    return Response.json({ status: 500, message: 'Error receiving data..' })
}

With this, the client will also receive a filter alongside other fields in the response data object. So we have to redesign the client to be able to incoporate filter to the layout.

First, we will design the component to display and handle user settings of the filter. Let's start with the type the component will be working with.

Paste this code in your <project>/lib/filter.ts file.

export interface Filter {
  [key: string]: Price | FilterSubKey
}

type Price = {
  min: number
  max: number
  highest: number
}

export type FilterSubKey = {
  [subKey: string]: boolean
}

And now for the filter component.

Paste this code in your <project>/app/components/filter.tsx file.

import { useState, ReactNode } from 'react'
import { BsCheckLg } from 'react-icons/bs'
import { PiCaretDownThin } from 'react-icons/pi'
import type { Filter, FilterSubKey } from '@/lib/filter'
import { Text } from './elements'

export default function FilterBar({
  filter,
  loadFilter,
}: {
  filter: Filter
  loadFilter: (newFilter: Filter) => void
}) {
  const setPrice = (min: number, max: number) => {
    const newFilter: Filter = {
      ...filter,
      ['price']: { highest: filter.price.highest as number, min, max },
    }
    loadFilter(newFilter)
  }

  const setFilterField = (key: string, subKey: string, value: boolean) => {
    const newFilter: Filter = {
      ...filter,
      [key]: { ...filter[key], [subKey]: value },
    }
    loadFilter(newFilter)
  }

  return (
    <div className='flex flex-col w-full justify-start h-fit gap-16'>
      <HR>
        <Accordion title='Price' defaultOpen>
          <PriceRangeButton
            minPrice={0}
            maxPrice={filter.price.highest as number}
            onRangeClick={setPrice}
          />
        </Accordion>
      </HR>
      {Object.keys(filter)
        .filter((key) => key !== 'price')
        .map((key) => (
          <HR key={key}>
            <Accordion title={key}>
              {Object.keys(filter[key]).map((subKey) => (
                <div key={subKey} className='flex flex-col gap-4'>
                  <KeySelector
                    check={(filter[key] as FilterSubKey)[subKey] as boolean}
                    setCheck={(value) => setFilterField(key, subKey, value)}
                  >
                    {subKey}
                  </KeySelector>
                </div>
              ))}
            </Accordion>
          </HR>
        ))}
    </div>
  )
}

interface KeySelectorProps {
  children: string
  check?: boolean
  setCheck?: (prev: boolean) => void
}

const KeySelector: React.FC<KeySelectorProps> = ({
  check,
  setCheck,
  children,
}) => {
  return (
    <div
      className='flex justify-start items-center gap-4'
      onClick={() => setCheck?.(!check)}
    >
      <div
        className={`w-[1.58rem] h-[1.58rem] rounded-md flex justify-center items-center border ${
          check ? 'border-black' : 'border-black/20'
        }`}
      >
        {check && <BsCheckLg className='text-4xl text-black' />}
      </div>
      <Text size='sm' faded={!check}>
        {children}
      </Text>
    </div>
  )
}

interface AccordionProps {
  title: string
  children: ReactNode[] | ReactNode
  defaultOpen?: boolean
}

const Accordion: React.FC<AccordionProps> = ({
  title,
  defaultOpen,
  children,
}) => {
  const [open, setOpen] = useState(defaultOpen)

  return (
    <div className='w-full'>
      <div
        className='flex justify-between items-center gap-8 capitalize'
        onClick={() => setOpen((prev) => !prev)}
      >
        <Text size='md'>{title}</Text>
        <PiCaretDownThin className={`${open && 'rotate-180'} text-4xl`} />
      </div>
      <div
        className={`transition-all duration-300 ease-in-out ${
          open ? 'block max-h-full' : 'max-h-0 hidden'
        } relative flex flex-col gap-3 items-start justify-start mt-3 py-5`}
      >
        {children}
      </div>
    </div>
  )
}

interface HRProps {
  dashed?: boolean
  children?: React.ReactNode
}

const HR: React.FC<HRProps> = ({ dashed, children }) => {
  return (
    <div
      className={`border-b ${
        dashed && 'border-dashed'
      } border-black/20 w-full ${children && 'pb-3'}`}
    >
      {children}
    </div>
  )
}

interface PriceRangeButtonProps {
  minPrice: number
  maxPrice: number
  onRangeClick: (min: number, max: number) => void
}

const PriceRangeButton: React.FC<PriceRangeButtonProps> = ({
  minPrice,
  maxPrice,
  onRangeClick,
}) => {
  const [selectedRange, setSelectedRange] = useState([minPrice, maxPrice])

  const handleRangeClick = (min: number, max: number) => {
    setSelectedRange([min, max])
    onRangeClick(min, max)
  }

  function createPriceRanges(numRanges = 8) {
    const stepAmount = (maxPrice - minPrice) / (numRanges - 1) // Calculate step size

    const ranges = []
    let currentPrice = minPrice

    for (let i = 0; i < numRanges; i++) {
      if (currentPrice >= maxPrice) break // maxPrice is limit.

      const maxRangePrice = Math.min(currentPrice + stepAmount, maxPrice) // Ensure max doesn't exceed maxPrice
      ranges.push({
        min: Math.ceil(currentPrice),
        max: Math.ceil(maxRangePrice),
        label: `$${Math.ceil(currentPrice)} - $${Math.ceil(maxRangePrice)}`, // Format label
      })
      currentPrice = maxRangePrice + 0.01 // Slight offset for non-overlapping ranges
    }

    return ranges
  }

  return (
    <div className='flex justify-between flex-wrap gap-4'>
      {createPriceRanges().map(({ label, min, max }) => (
        <button
          type='button'
          key={label}
          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 ${
            selectedRange[0] === min && selectedRange[1] === max
              ? 'bg-black text-white'
              : ''
          }`}
          onClick={() => handleRangeClick(min, max)}
        >
          {label}
        </button>
      ))}
    </div>
  )
}

export { KeySelector, Accordion, HR, PriceRangeButton }

Now that we have a functional filter component and a filter type. Let's modify the search page layout and include it. This way when the user receives the filter per request, it can be displayed.

A little explanation before we proceed...

Our filter object in the client is only refreshed when a new search query is searched by user. But our load function sends the same fetch request for both filter modification and page reload(query change). So, we need a way to tell the load function whether it's a filter change or a query change.

In general, let's update the search page to handle the effects of filter.

Paste this code in your <project>/app/search/page.tsx file.

'use client'

import { PiCaretDownThin } from 'react-icons/pi'
import { Button, Text } from '@/app/components/elements'
import Card from '@/app/components/product/card'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import Header from '../components/header'
import useSort from '../hooks/usesort'
import Sort from '../components/sort'
import { Filter } from '@/lib/filter'
import FilterBar from '../components/filter'

export default function Search() {
  const searchParams = useSearchParams()
  const query = searchParams.get('q')
  const [loading, setLoading] = useState(true)
  const [afterCursor, setAfterCursor] = useState(null)
  const [hasError, setHasError] = useState(false)
  const [hasMore, setHasMore] = useState(false)
  const [total, setTotal] = useState(0)
  const { products, sort, setSort, sortProducts } = useSort()
  const [filter, setFilter] = useState<Filter>()

  const load = (filterTriggered = false, newFilter = filter) => {
    setLoading(true)
    setHasError(false)
    setHasMore(false)
    setTotal(0)

    const cursor = afterCursor && !filterTriggered ? afterCursor : ''

    fetch(`/api/search?query=${query}&cursor=${cursor}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ filter: newFilter ?? null }),
    })
      .then((res) => res.json())
      .then((data) => {
        const results = data.body?.results
        const newProducts =
          products.length > 0 && !filterTriggered
            ? [...products, ...results]
            : results

        sortProducts(newProducts, sort)
        setHasMore(data.body?.pageInfo?.hasNextPage)
        setAfterCursor(data.body?.pageInfo?.after)
        setTotal(data.body?.total ?? 0)
        !filterTriggered && setFilter(data.body?.filter)
      })
      .catch(() => setHasError(true))
      .finally(() => setLoading(false))
  }

  useEffect(() => {
    load()
  }, [])

  return (
    <div className='px-7 max-w-[120rem] mx-auto'>
      <Header />
      <div className='py-4 mt-12'>
        <div className='grid grid-cols-4 place-content-between items-stretch gap-8'>
          <div className='flex flex-col gap-16 p-8 h-fit w-full ring ring-gray-200'>
            <Text size='lg'>Filter</Text>
            {filter && (
              <FilterBar
                filter={filter}
                loadFilter={(newFilter: Filter) => {
                  setFilter(newFilter)
                  load(true, newFilter)
                }}
              />
            )}
          </div>
          <div className='col-span-3 w-full'>
            <div className='flex justify-between items-end gap-10 pb-8'>
              {loading ? (
                <Text size='lg'>&middot;&middot;&middot;</Text>
              ) : (
                <Text size='lg'>
                  {`${total} result${total > 1 ? 's' : ''} for "${
                    query ?? ''
                  }"`}
                </Text>
              )}
              <Sort setSort={setSort} />
            </div>
            <div className='grid grid-cols-3 place-content-between items-stretch gap-16'>
              {loading ? (
                <div className='w-full col-span-full flex justify-center items-center'>
                  <Text size='md'>Loading...</Text>
                </div>
              ) : hasError ? (
                <div className='w-full col-span-full flex justify-center items-center'>
                  <Text size='md'>Something went wrong.</Text>
                </div>
              ) : products.length < 1 ? (
                <div className='w-full col-span-full flex justify-center items-center'>
                  <Text size='md'>
                    No products found. Search something else.
                  </Text>
                </div>
              ) : (
                products.map((product) => (
                  <Card key={product.id} product={product} full />
                ))
              )}
              <div className='col-span-full flex justify-center items-center gap-8'>
                {products.length > 0 && hasMore && (
                  <Button onClick={() => load()} outline>
                    <span className='flex justify-center items-center gap-4'>
                      More
                      <PiCaretDownThin className='text-4xl' />
                    </span>
                  </Button>
                )}
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  )
}

Right now, your search results should reload on every change in the filter keys (whenever you click any filter option). And your entire page refreshes whenever the query in the address bar changes (page reload).

Mission accomplished. Moving right along.

Next, we need a way to convert the client filter format to a format our query can understand. This way, whenever there is a filter change, a new one is parsed and sent to the query to update the products returned.

Append this code in your <project>/app/api/utils.ts file.

/**
 * Parses the filter into a format that can be used by the query
 * @param filter The filter to be parsed.
 * @returns An array of objects that can be used by the query.
 */
export function parseFilter(filter: Filter) {
  // Parse price
  const price = {
    price: { min: filter.price.min as number, max: filter.price.max as number },
  }

  // Parse other variants
  // Required format: [{variantOption: {name: 'color', value: 'natural'}}]
  const variants = Object.keys(filter)
    .filter((key) => key !== 'price' && key !== 'brands')
    .map((key) =>
      Object.fromEntries(
        Object.keys(filter[key]).filter(subKey => (filter[key] as FilterSubKey)[subKey] === true).map((subKey) => [
          'variantOptions',
          Object.fromEntries([
            ['name', key],
            ['value', subKey],
          ])
        ])
      )
    ).filter(obj => Object.keys(obj).length > 0)

  return [price, ...variants]
}

Notice the logic key !== 'price' && key !== 'brands' means the parse logic covers for anything not price or brands but since we already have a logic for price. We need to write a function to handle filtering of brands or collections.

This is because the Shopify filter field doesn't support collections filtering.

Append this code in your <project>/app/api/utils.ts file.

/**
 * Filter products whose collections contain at least on of the filter collections.
 * @param result The result of fetching the search products query.
 * @param filterCollections The collections to filter by.
 * @returns The filtered products.
 */
export function filterProductByCollection(
  result: { node: MiniProductQueryResult }[],
  filterCollections: string[]
) {
  return result.reduce((acc, { node }) => {
    const {
      collections: { nodes },
    } = node
    const isPassed = nodes.some(({ title }) =>
      filterCollections.includes(title)
    )

    if (isPassed) return [...acc, cleanMiniProduct(node)]
    return acc
  }, Array())
}

Next, plug it in through the api route handler.

Paste this code in your <project>/app/api/search/route.ts file.

import { SEARCH_PRODUCTS } from '@/app/api/query'
import { MiniProductQueryResult } from '@/app/api/types'
import {
  cleanMiniProduct,
  extractFilter,
  filterProductByCollection,
  parseFilter,
} from '@/app/api/utils'
import { shopifyFetch } from '@/lib/fetch'
import { NextRequest } from 'next/server'

type VariablesType = {
  first: number
  query: string | null
  after?: string
  filters?: any
}
const LIMIT = 6

export async function POST(Request: NextRequest) {
  const { filter } = await Request.json()
  const searchParams = Request.nextUrl.searchParams
  const query = searchParams.get('query')
  const after = searchParams.get('cursor')

  const variables: VariablesType = {
    first: LIMIT,
    query,
  }
  if (after) variables['after'] = after
  if (filter) variables['filters'] = parseFilter(filter)

  const { status, body } = await shopifyFetch({
    query: SEARCH_PRODUCTS,
    variables,
  })

  if (status === 200) {
    const results = body.data?.search.edges
    const pageInfo = body.data?.search.pageInfo
    const len = results.length
    const total = body.data?.search.totalCount
    const filterKeys = extractFilter(body.data?.search)

    const cleanedResults = results.map(
      ({ node }: { node: MiniProductQueryResult }) => cleanMiniProduct(node)
    )

    // Filter by collections/brands
    let brands = Array()
    if (filter) {
      brands = Object.keys(filter['brands']).filter(
        (subKey) => filter['brands'][subKey] === true
      )
    }
    const filterResults =
      brands.length > 0
        ? filterProductByCollection(results, brands)
        : cleanedResults

    return Response.json({
      status,
      body: {
        total,
        filter: filterKeys,
        results: filterResults,
        pageInfo: {
          ...pageInfo,
          after: results[len - 1]?.cursor,
        },
      },
    })
  } else
    return Response.json({ status: 500, message: 'Error receiving data..' })
}

Lastly, let's edit the query, so it searches for products with our filter in mind.

Change your SEARCH_PRODUCTS query to the query below.

export const SEARCH_PRODUCTS = `
query SearchProducts($query: String!, $first: Int, $after: String, $filters: [ProductFilter!]) {
  search(query: $query, first: $first, after: $after, types: PRODUCT, productFilters: $filters) {
    totalCount
    edges {
      node {
        ... on Product {
          id
          title
          handle
          featuredImage {
            url
          }
          priceRange {
            minVariantPrice {
              amount
            }
          }
          compareAtPriceRange {
            maxVariantPrice {
              amount
            }
          }
          options {
            name
            values
          }
          collections(first: 1) {
            nodes {
              handle
              title
            }
          }
        }
      }
      cursor
    }
    pageInfo {
      hasNextPage
    }
  }
}
`

And we are done.

Notice that our search doesn't work by inputting text in the search bar. Let's fix that.

Paste this code in your <project>/app/components/header.tsx file.

import { PiHeartStraightThin, PiShoppingCartThin } from 'react-icons/pi'
import { useRouter } from 'next/navigation'
import { Button, Text } from './elements'
import { useState } from 'react'

export default function Header({ defaultText }: { defaultText?: string }) {
  const [searchText, setSearchText] = useState(defaultText)
  const router = useRouter()

  return (
    <div className='flex justify-between items-center gap-10 py-4'>
      <div className=''>
        <Text size='xl'>Belsac</Text>
      </div>
      <div className='flex justify-center items-stretch w-[40%]'>
        <input
          value={searchText}
          onChange={(e) => setSearchText(e.target.value)}
          placeholder='Search bags'
          className='grow self-stretch p-4 text-xl font-light border border-black/90 focus:outline-none placeholder:text-black/90 placeholder:text-xl'
        />
        <Button onClick={() => router.push(`/search?q=${searchText}`)}>
          Search
        </Button>
      </div>
      <div className='flex justify-end items-center gap-10'>
        <PiHeartStraightThin className='text-4xl' />
        <PiShoppingCartThin className='text-4xl' />
      </div>
    </div>
  )
}

Now, update the search page.

Paste this code in your <project>/app/search/page.tsx file.

'use client'

import { PiCaretDownThin } from 'react-icons/pi'
import { Button, Text } from '@/app/components/elements'
import Card from '@/app/components/product/card'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import Header from '../components/header'
import useSort from '../hooks/usesort'
import Sort from '../components/sort'
import { Filter } from '@/lib/filter'
import FilterBar from '../components/filter'

export default function Search() {
  const searchParams = useSearchParams()
  const query = searchParams.get('q')
  const [loading, setLoading] = useState(true)
  const [afterCursor, setAfterCursor] = useState(null)
  const [hasError, setHasError] = useState(false)
  const [hasMore, setHasMore] = useState(false)
  const [total, setTotal] = useState(0)
  const { products, sort, setSort, sortProducts } = useSort()
  const [filter, setFilter] = useState<Filter>()

  const load = (
    filterTriggered?: boolean,
    newFilter?: Filter,
    cursor = afterCursor && !filterTriggered ? afterCursor : ''
  ) => {
    setLoading(true)
    setHasError(false)
    setHasMore(false)
    setTotal(0)

    fetch(`/api/search?query=${query}&cursor=${cursor}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ filter: newFilter ?? null }),
    })
      .then((res) => res.json())
      .then((data) => {
        const results = data.body?.results
        const newProducts =
          products.length > 0 && !filterTriggered && cursor === afterCursor
            ? [...products, ...results]
            : results

        sortProducts(newProducts, sort)
        setHasMore(data.body?.pageInfo?.hasNextPage)
        setAfterCursor(data.body?.pageInfo?.after)
        setTotal(data.body?.total ?? 0)
        !filterTriggered && setFilter(data.body?.filter)
      })
      .catch(() => setHasError(true))
      .finally(() => setLoading(false))
  }

  useEffect(() => {
    // Reload the entire page.
    load(false, undefined, '')
  }, [query])

  return (
    <div className='px-7 max-w-[120rem] mx-auto'>
      <Header defaultText={query ?? undefined} />
      <div className='py-4 mt-12'>
        <div className='grid grid-cols-4 place-content-between items-stretch gap-8'>
          <div className='flex flex-col gap-16 p-8 h-fit w-full ring ring-gray-200'>
            <Text size='lg'>Filter</Text>
            {filter && (
              <FilterBar
                filter={filter}
                loadFilter={(newFilter: Filter) => {
                  setFilter(newFilter)
                  load(true, newFilter)
                }}
              />
            )}
          </div>
          <div className='col-span-3 w-full'>
            <div className='flex justify-between items-end gap-10 pb-8'>
              {loading ? (
                <Text size='lg'>&middot;&middot;&middot;</Text>
              ) : (
                <Text size='lg'>
                  {`${total} result${total > 1 ? 's' : ''} for "${
                    query ?? ''
                  }"`}
                </Text>
              )}
              <Sort setSort={setSort} />
            </div>
            <div className='grid grid-cols-3 place-content-between items-stretch gap-16'>
              {loading ? (
                <div className='w-full col-span-full flex justify-center items-center'>
                  <Text size='md'>Loading...</Text>
                </div>
              ) : hasError ? (
                <div className='w-full col-span-full flex justify-center items-center'>
                  <Text size='md'>Something went wrong.</Text>
                </div>
              ) : products.length < 1 ? (
                <div className='w-full col-span-full flex justify-center items-center'>
                  <Text size='md'>
                    No products found. Search something else.
                  </Text>
                </div>
              ) : (
                products.map((product) => (
                  <Card key={product.id} product={product} full />
                ))
              )}
              <div className='col-span-full flex justify-center items-center gap-8'>
                {products.length > 0 && hasMore && (
                  <Button onClick={() => load()} outline>
                    <span className='flex justify-center items-center gap-4'>
                      More
                      <PiCaretDownThin className='text-4xl' />
                    </span>
                  </Button>
                )}
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  )
}

That is it.

You have successfully add the search and filter feature to your Shopify storefront.

Congratulations on completing this part of the tutorial.

Here is the full code for this section in this GitHub repo.

That's a wrap for this part. In the next part you will learn how to create a dynamic product page that updates the page with information based on variant selected.

See you there.