HextaUI

Product Search

A storefront search field with live suggestions, categories and product results. Handles typos, slow or failing searches, recent searches and keyboard-only use.

Open in new tab

Product Search is the search field in a store header. On a desktop it opens a dropdown under the field, and on a phone a full-screen sheet with the keyboard up. Before anything is typed it shows recent searches, trending searches and a row of popular products.

As the shopper types, it shows up to eight query suggestions with the predicted part in bold, category matches such as “linen in Bedding”, a few products with a thumbnail and price, and a final “Search for” row. Arrowing onto a suggestion copies it into the field so it can be edited, Escape puts back what was typed, and Tab completes the suggestion.

onSearch(query, { signal }) is called after a short debounce. Older requests are aborted, answers are cached so backspacing is instant, and the previous results stay on screen while the next ones load. The spinner only appears when a search takes longer than 150ms. A failed search shows its error message with a Try again row, and a search with no results offers “Did you mean” and popular categories.

Recent searches are kept in localStorage under recentStorageKey, or controlled with recent and onRecentChange. SearchResultsHeader is the matching heading for a results page, “Results for “vase” (4)”, with a Did you mean line. The fuzzy helpers in search.ts, fuzzySearch, completeQuery and correctQuery, power the demo and work for small catalogs on their own.

  1. Get HextaUI Pro

    One payment unlocks every block, including future ones. See pricing.

  2. Add the Pro registry to components.json

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  3. Add your token

    Create a token on your account page and put it in .env.local as HEXTAUI_PRO_TOKEN.

  4. Add the block

    pnpm dlx shadcn@latest add @hextaui-pro/product-search

Call the Storefront API’s predictiveSearch with the signal ProductSearch passes in, and map queries, collections and products to SearchResults. Prices are already Money, so they go straight through.

"use client"

import { ProductSearch } from "@/components/blocks/product-search/product-search"
import type { SearchFn } from "@/components/blocks/product-search/search"

const endpoint = "https://your-shop.myshopify.com/api/2025-07/graphql.json"

const query = `query Predictive($q: String!) {
  predictiveSearch(query: $q, limit: 6, types: [QUERY, COLLECTION, PRODUCT]) {
    queries { text }
    collections { id title handle }
    products {
      id title handle
      featuredImage { url altText }
      priceRange { minVariantPrice { amount currencyCode } }
      compareAtPriceRange { minVariantPrice { amount currencyCode } }
    }
  }
}`

type Money = { amount: string; currencyCode: string }

type PredictiveSearch = {
  queries: { text: string }[]
  collections: { id: string; title: string; handle: string }[]
  products: {
    id: string
    title: string
    handle: string
    featuredImage: { url: string; altText: string | null } | null
    priceRange: { minVariantPrice: Money }
    compareAtPriceRange: { minVariantPrice: Money }
  }[]
}

const search: SearchFn = async (q, { signal }) => {
  const response = await fetch(endpoint, {
    method: "POST",
    signal,
    headers: {
      "Content-Type": "application/json",
      "X-Shopify-Storefront-Access-Token": process.env.NEXT_PUBLIC_SHOPIFY_TOKEN ?? "",
    },
    body: JSON.stringify({ query, variables: { q } }),
  })
  if (!response.ok) throw new Error("Search is unavailable right now. Try again in a moment.")
  const { data } = (await response.json()) as {
    data: { predictiveSearch: PredictiveSearch }
  }
  const result = data.predictiveSearch
  return {
    suggestions: result.queries.map((item) => item.text),
    collections: result.collections.map((item) => ({
      id: item.id,
      title: item.title,
      href: `/collections/${item.handle}`,
    })),
    products: result.products.map((item) => ({
      id: item.id,
      title: item.title,
      href: `/products/${item.handle}`,
      image: item.featuredImage
        ? { src: `${item.featuredImage.url}&width=160`, alt: item.featuredImage.altText ?? "" }
        : undefined,
      price: item.priceRange.minVariantPrice,
      compareAt: item.compareAtPriceRange.minVariantPrice,
    })),
  }
}

export function StoreSearch() {
  return (
    <ProductSearch
      onSearch={search}
      onSubmit={(q) => window.location.assign(`/search?q=${encodeURIComponent(q)}`)}
      recentStorageKey="store:recent-searches"
      trending={["linen bedding", "ring vase"]}
    />
  )
}

Algolia

One multi-query request fetches query suggestions and products together. Algolia’s typo tolerance and facet counts fill the suggestions, the “in Bedding” rows and the result count.

"use client"

import { money } from "../price/money"
import { ProductSearch } from "@/components/blocks/product-search/product-search"
import type { SearchFn } from "@/components/blocks/product-search/search"

const appId = "YOUR_APP_ID"
const searchKey = "YOUR_SEARCH_ONLY_KEY"

type ProductHit = {
  objectID: string
  title: string
  handle: string
  image: string
  price: number
  collection: string
}

type SuggestionHit = { query: string }

type Response = {
  results: [
    { hits: SuggestionHit[] },
    { hits: ProductHit[]; nbHits: number; facets?: { collection?: Record<string, number> } },
  ]
}

const search: SearchFn = async (query, { signal }) => {
  const response = await fetch(`https://${appId}-dsn.algolia.net/1/indexes/*/queries`, {
    method: "POST",
    signal,
    headers: {
      "X-Algolia-Application-Id": appId,
      "X-Algolia-API-Key": searchKey,
    },
    body: JSON.stringify({
      requests: [
        { indexName: "products_query_suggestions", query, hitsPerPage: 8 },
        { indexName: "products", query, hitsPerPage: 4, facets: ["collection"], typoTolerance: true },
      ],
    }),
  })
  if (!response.ok) throw new Error("Search is unavailable right now. Try again in a moment.")
  const { results } = (await response.json()) as Response
  const [suggestions, products] = results
  return {
    suggestions: suggestions.hits.map((hit) => hit.query),
    collections: Object.entries(products.facets?.collection ?? {})
      .slice(0, 2)
      .map(([title, count]) => ({ id: title, title, count })),
    products: products.hits.map((hit) => ({
      id: hit.objectID,
      title: hit.title,
      href: `/products/${hit.handle}`,
      image: { src: hit.image },
      price: money(hit.price, "USD"),
    })),
    total: products.nbHits,
  }
}

export function AlgoliaSearch() {
  return <ProductSearch onSearch={search} recentStorageKey="recent-searches" />
}

Next.js search page

Send Enter to /search?q= with the router, and seed the field from the URL so the results page echoes the query. Products and categories open their own routes.

"use client"

import { useRouter, useSearchParams } from "next/navigation"

import { ProductSearch } from "@/components/blocks/product-search/product-search"
import type { SearchFn } from "@/components/blocks/product-search/search"

const search: SearchFn = async (query, { signal }) => {
  const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal })
  if (!response.ok) throw new Error("Search is unavailable right now. Try again in a moment.")
  return response.json()
}

export function HeaderSearch() {
  const router = useRouter()
  const params = useSearchParams()

  return (
    <ProductSearch
      key={params.get("q") ?? ""}
      defaultValue={params.get("q") ?? ""}
      onSearch={search}
      onSubmit={(query) => router.push(`/search?q=${encodeURIComponent(query)}`)}
      onSelectProduct={(product) => product.href && router.push(product.href)}
      onSelectCollection={(collection, query) =>
        router.push(`${collection.href}?q=${encodeURIComponent(query)}`)
      }
      recentStorageKey="recent-searches"
    />
  )
}

Anatomy

The parts you compose, from the outside in.

PartDescription
ProductSearchThe field, the ⌘K hint and the dropdown or full-screen sheet.
SearchResultsHeaderThe results page heading with the count and a spelling suggestion.
fuzzySearch, completeQuery, correctQueryTypo-tolerant matching, query completion and spelling fixes for small catalogs.
splitCompletion, addRecent, removeRecentSplit a suggestion into typed and predicted parts, and edit a recent searches list.

ProductSearch

Also takes div props.

PropTypeDefault
onSearchFetches results. Abort when signal fires. A thrown error’s message is shown in the dropdown.
(query, { signal }) => Promise<SearchResults>–
onSubmitCalled when the shopper searches for text or picks a suggestion, recent or trending search.
(query: string) => void–
onSelectProductCalled when a product is picked. Without it, the product’s href opens.
(product, query) => void–
onSelectCollectionCalled when a category is picked. Without it, the category’s href opens.
(collection, query) => void–
valueThe query, when controlled.
string–
defaultValueThe starting query.
string""
onValueChangeCalled as the shopper types.
(query: string) => void–
openWhether the dropdown or sheet is open, when controlled.
boolean–
onOpenChangeCalled when it opens or closes.
(open: boolean) => void–
recentStorageKeyKeep recent searches in localStorage under this key.
string–
recentRecent searches, when controlled.
string[]–
defaultRecentRecent searches to show when nothing is stored.
string[][]
onRecentChangeCalled when a search is added, removed or cleared.
(recent: string[]) => void–
maxRecentHow many recent searches to keep.
number5
trendingTrending searches for the empty state.
string[][]
popularProductsProducts for the empty state. Shows four, or three on a phone.
SearchProduct[][]
popularCollectionsCategories to offer when nothing matches.
SearchCollection[][]
maxSuggestionsQuery suggestions to show.
number8, or 5 on a phone
maxProductsProduct results to show.
number4, or 3 on a phone
debounceMilliseconds to wait after typing before searching.
number120
shortcutOpen with ⌘K, Ctrl+K and /.
booleantrue
alignAlign the dropdown with the field.
"start" | "center" | "end""start"
localeLocale for prices and counts.
Intl.LocalesArgument"en-US"
labelsWords for headings, rows, hints and announcements.
Partial<ProductSearchLabels>–
PropTypeDefault
suggestionsQuery completions, best first. Only suggest queries that have results.
string[]–
collectionsCategories that match, shown as “query in Bedding”.
{ id; title; href?; count? }[]–
productsProduct results. price is Money or { min, max }.
{ id; title; href?; image?; price?; compareAt?; subtitle? }[]–
totalThe full result count for the live announcement. Defaults to products.length.
number–
correctionThe corrected query when you matched a typo. Shown as “Showing results for”, or as “Did you mean” when nothing matched.
string–

SearchResultsHeader

Also takes div props.

PropTypeDefault
queryThe query shown in the heading.
string–
countThe result count. Hidden while null.
number | null–
suggestionA spelling suggestion shown as “Did you mean”.
string | null–
correctedFromThe original query when the results are for a correction, shown as “Search instead for”.
string | null–
onSuggestionSelectCalled when either link is clicked.
(query: string) => void–
headingLevelThe heading level.
1 | 2 | 31
KeyAction
Meta+KControl+KFocus the field and open search.
/Open search, unless you are typing in another field.
ArrowDownArrowUpMove through every row. Suggestions are copied into the field as you go.
EnterOpen the highlighted row, or search for what was typed.
TabComplete the highlighted or top suggestion and keep typing.
Shift+DeleteRemove the highlighted recent search.
EscapePut back what was typed, then close, then clear the field.
  • The field is a combobox with a listbox of grouped options. aria-activedescendant follows the highlighted row, so focus never leaves the field.
  • Result counts such as “6 results” are announced once each search settles, never per keystroke. A failed search is announced as an alert.
  • Closing the phone sheet returns focus to the search button. The field uses 16px text on touch screens so iOS doesn’t zoom.
  • Highlighted rows get an outline in forced colors mode, and the spinner, fades and arrow nudge are reduced or removed with reduced motion.

Built with

The free HextaUI components Product Search is made from. Each one installs on its own.

Code

6 files, added to components/blocks/product-search.