Build with Headband
React SDK
Hooks and ready-made components for a search UI in React. Debouncing, facets, pagination and search state are handled for you.
Installation
npm install @headband/reactOptional: run npx @headband/cli init to generate a config and starter component. The generated config reads from NEXT_PUBLIC_HEADBAND_KEY, so the CLI only accepts scoped hb_src_ search keys. Keep hb_adm_ admin keys server-side for indexing, settings, and sync jobs.
Styles with Tailwind v4
The components' default styles are Tailwind classes inside the package. Tailwind v4, the Next.js default, does not scan node_modules, so without one more line the components render unstyled. Add an @source line to the CSS file that imports Tailwind. The path is relative to that file.
@import "tailwindcss";
@source "../node_modules/@headband/react";For src/app/globals.css, use "../../node_modules/@headband/react". Without Tailwind, pass styled={false} and style the components yourself.
Before you build
Each engine needs different settings before filters, facets and sorting work. Read Engine compatibility for the engine your index is on. The search key in the browser can read every field you index, so leave private fields out of your documents. See What a search key can read.
Which version
Install 0.2.1 or later; the latest is 0.3.0. The experiment helpers (assignExperiment, recordExperimentEvent), the filter helpers, the mode prop and client.warm() need 0.2.0 or later.
Skip 0.2.0 on a page with facet lists
RefinementList or useRefinementList renders without end and never searches. 0.1.1 does not have this bug, and 0.2.1 fixes it.HeadbandProvider
Wrap your search UI in a HeadbandProvider. It creates a client, manages search state, and provides context to all child hooks and components.
import {
headband,
HeadbandProvider,
} from "@headband/react";
const client = headband(
"https://api.headband.dev",
"hb_src_YOUR_SEARCH_KEY"
);
function App() {
return (
<HeadbandProvider
client={client}
index="products"
>
{/* Search components go here */}
</HeadbandProvider>
);
}Hooks
useSearch()
Core hook exposing the full search state and all actions. Use the specialized hooks below for most cases.
const {
query,
results,
isLoading,
error,
setQuery,
setPage,
refresh,
} = useSearch();useSearchBox()
Binds to the search input. Returns the current query plus setters.
const { query, setQuery, clear, warm } =
useSearchBox();
// query: string
// - Current query
// setQuery(q: string)
// - Update query (resets pagination,
// triggers debounced search)
// clear()
// - Reset query to ""
// warm()
// - Wake the index's engine. Call it
// when your own input gets focus.SearchBox wakes the index's engine when it gets focus, so the first search does not wait for a sleeping engine. Turn it off with warmOnFocus={false}. For any other input, call client.warmOnFocus(indexes) from its focus handler. It sends at most one warm per 10 seconds for the same indexes, never throws, and is never counted as a search.
useHits<T>()
Returns the current search hits with loading and error state. Supports generics for typed hits.
const { hits, isLoading, error } =
useHits<Product>();
// hits: Hit<T>[]
// - Each hit has document fields
// + _formatted + __data
// isLoading: boolean
// error: Error | nulluseRefinementList(props)
Provides facet filtering for a given attribute. Automatically registers the facet with the provider. The attribute must be filterable on the index first, or every search fails. Ticking a value keeps the other values and their counts in the list (0.2.1 and later), using one more search per facet with a ticked value.
const {
items,
refine,
canToggle,
isLoading,
} = useRefinementList({
attribute: "category",
// max facet values (default: 20)
limit: 20,
// "count" or "alpha" (default: "count")
sortBy: "count",
});
// items: {
// value: string,
// count: number,
// isRefined: boolean,
// }[]
// refine(value: string)
// - Toggle a facet value on/offusePagination()
Pagination state derived from the current search results.
const {
currentPage,
totalPages,
totalHits,
setPage,
canPrevious,
canNext,
pages,
} = usePagination();
// pages: number[]
// - Window of page numbers for
// rendering a page list
// Pages are 0-indexed internallyuseStats()
Returns summary stats about the current search results.
const {
totalHits,
processingTimeMs,
query,
isLoading,
} = useStats();useSortBy({ items })
Sort-by dropdown state. Pass sort options, get back the current selection.
const {
currentSort,
setSort,
items,
} = useSortBy({
items: [
{
value: "price:asc",
label: "Price (low to high)",
},
{
value: "price:desc",
label: "Price (high to low)",
},
],
});
// items[n].isSelected: boolean
// - Added to each item
// setSort(null)
// - Reset to default relevanceuseRange({ attribute })
Numeric range filter for a given attribute.
const {
min,
max,
stats,
setRange,
clear,
isLoading,
} = useRange({
attribute: "price",
});
// stats: { min: number, max: number }
// | undefined
// - Overall min/max from engine
// setRange({ min: 10, max: 500 })
// - Set range filter
// clear()
// - Remove range filteruseHighlight({ hit, attribute })
Extracts the highlighted value for a specific attribute from a hit. Sanitizes HTML: only allows <em> tags.
const { value, highlighted } =
useHighlight({
hit,
attribute: "title",
});
// value: string
// - Safe HTML string with <em> tags
// highlighted: boolean
// - Whether the value contains
// highlight marks
// Usage:
// <span dangerouslySetInnerHTML={{
// __html: value,
// }} />Components
Pre-built UI components with default Tailwind styles. Set styled=false for unstyled variants.
Full example
A complete product search page with facets, sorting, pagination, and highlighting.
Set up the index first. The category filter and the price sort only work once category is filterable and price is sortable. On a fresh index the page shows 0 results and an error until then, because the engine refuses the whole search. On Meilisearch, Typesense or Visor, run this once with your admin key, from a server or your terminal:
export ADMIN_KEY="hb_adm_..."
API=https://api.headband.dev/v1
curl -X PATCH \
"$API/indexes/products/settings" \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"filterableAttributes": [
"category"
],
"sortableAttributes": [
"price"
]
}'The same fields can be set in the dashboard's Tune tab. Elasticsearch needs no settings for this and refuses them. There, use category.keyword as the facet attribute. See Engine compatibility. The page also needs the Tailwind @source line above.
import {
headband,
HeadbandProvider,
SearchBox,
Hits,
RefinementList,
Pagination,
Stats,
SortBy,
Highlight,
} from "@headband/react";
const client = headband(
"https://api.headband.dev",
"hb_src_YOUR_SEARCH_KEY"
);
const sortItems = [
{
value: "price:asc",
label: "Price: Low to High",
},
{
value: "price:desc",
label: "Price: High to Low",
},
];
// The page's Tailwind classes.
const styles = {
hit: "p-4 border rounded-lg",
price: "text-sm text-gray-500",
sidebar: "w-48 shrink-0",
facet: "text-sm font-semibold mb-2",
sort: "text-sm font-semibold mt-4 mb-2",
page: "max-w-5xl mx-auto p-6",
body: "flex gap-8 mt-6",
main: "flex-1",
};
function ProductHit({ hit }) {
return (
<div className={styles.hit}>
<Highlight
hit={hit}
attribute="title"
/>
<p className={styles.price}>
${hit.price}
</p>
</div>
);
}
function Sidebar() {
return (
<aside className={styles.sidebar}>
<h3 className={styles.facet}>
Category
</h3>
<RefinementList
attribute="category"
/>
<h3 className={styles.sort}>
Sort
</h3>
<SortBy items={sortItems} />
</aside>
);
}
export default function SearchPage() {
return (
<HeadbandProvider
client={client}
index="products"
>
<div className={styles.page}>
<SearchBox
placeholder="Search products"
autoFocus
/>
<div className={styles.body}>
<Sidebar />
<main className={styles.main}>
<Stats />
<Hits
hitComponent={ProductHit}
/>
<Pagination />
</main>
</div>
</div>
</HeadbandProvider>
);
}