React SDK

@headband/react provides InstantSearch-inspired hooks and pre-built components for building search UIs with React. It handles debouncing, state management, facet filtering, pagination, and more out of the box.

npm has version 0.2.0, published on 2026-10-22. It adds the experiment helpers (assignExperiment, recordExperimentEvent), the filter helpers, the mode prop and client.warm() to 0.1.1. This page marks what needs 0.2.1 or later.

0.2.0 and facet lists

In 0.2.0, a page with a RefinementList or useRefinementList renders without end and never searches. 0.1.1 does not have this bug, and 0.2.1 fixes it. Use 0.2.1 or later for a page with facet lists.

Installation

terminal
npm install @headband/react

Optional CLI setup for browser-safe React search

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.

app/globals.css
@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.

HeadbandProvider

Wrap your search UI in a HeadbandProvider. It creates a client, manages search state, and provides context to all child hooks and components.

tsx
import { headband, HeadbandProvider } from "@headband/react";

const client = headband("https://headband.dev", "hb_src_YOUR_SEARCH_KEY");

function App() {
  return (
    <HeadbandProvider client={client} index="products">
      {/* Search components go here */}
    </HeadbandProvider>
  );
}
ParameterTypeDefaultDescription
clientHeadbandClientRequiredClient created with headband(host, apiKey).
indexstringRequiredThe index UID to search.
initialQuerystring""Optional initial search query.
mode"instant" | "retrieval"—Visor's ranking mode for every search. Other engines ignore it. Also a field of client.search() params and of each multiSearch() query. New in @headband/react 0.2.0: 0.1.1 does not take mode.
disjunctiveFacetsbooleantrueFacet lists keep counting their other values after one is ticked. Each facet with a ticked value costs one more search per request. Set false for one search per request. New in 0.2.1: before it, a facet list narrows to the ticked values.
childrenReactNodeRequiredChild components that use Headband hooks.

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 } = useSearchBox();

// query: string        - Current query
// setQuery(q: string)  - Update query (resets pagination, triggers debounced search)
// clear()              - Reset query to ""
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 | null
useRefinementList(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",
  limit: 20,          // max facet values (default: 20)
  sortBy: "count",    // "count" or "alpha" (default: "count")
});

// items: { value: string, count: number, isRefined: boolean }[]
// refine(value: string)  - Toggle a facet value on/off
usePagination()

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 internally
useStats()

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 relevance
useRange({ 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 filter
useHighlight({ 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.

<SearchBox />

Search input with built-in clear button and keyboard handling. In 0.1.1 and 0.2.0, Chrome and Safari add a second clear button; hide it with input[type="search"]::-webkit-search-cancel-button { -webkit-appearance: none; }. 0.2.1 and later render a text input with the searchbox role, so there is only one.

Props: placeholder, className, autoFocus, onQueryChange, styled

<Hits />

Renders search result hits. Pass a custom hitComponent for custom rendering.

Props: hitComponent, className, emptyComponent, styled

<RefinementList />

Checkbox list for facet filtering on a given attribute.

Props: attribute, limit, sortBy, className, styled

<Pagination />

Page navigation with previous/next buttons and page number list.

Props: className, styled

<Stats />

Displays result count and processing time (e.g. "42 results in 2ms").

Props: className, styled

<SortBy />

Dropdown to switch sort order.

Props: items, className, styled

<Highlight />

Renders highlighted text for a given hit attribute.

Props: hit, attribute, className

<RangeInput />

Min/max numeric input fields for range filtering.

Props: attribute, className, styled

<PoweredBy />

"Powered by Headband" attribution badge.

Props: className, styled

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:

terminal
curl -X PATCH https://headband.dev/v1/indexes/products/settings \
  -H "Authorization: Bearer hb_adm_YOUR_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.

tsx
import {
  headband,
  HeadbandProvider,
  SearchBox,
  Hits,
  RefinementList,
  Pagination,
  Stats,
  SortBy,
  Highlight,
} from "@headband/react";

const client = headband(
  "https://headband.dev",
  "hb_src_YOUR_SEARCH_KEY"
);

function ProductHit({ hit }) {
  return (
    <div className="p-4 border rounded-lg">
      <Highlight hit={hit} attribute="title" />
      <p className="text-sm text-gray-500">${hit.price}</p>
    </div>
  );
}

export default function SearchPage() {
  return (
    <HeadbandProvider client={client} index="products">
      <div className="max-w-5xl mx-auto p-6">
        <SearchBox placeholder="Search products..." autoFocus />
        <div className="flex gap-8 mt-6">
          <aside className="w-48 shrink-0">
            <h3 className="text-sm font-semibold mb-2">Category</h3>
            <RefinementList attribute="category" />
            <h3 className="text-sm font-semibold mt-4 mb-2">Sort</h3>
            <SortBy
              items={[
                { value: "price:asc", label: "Price: Low to High" },
                { value: "price:desc", label: "Price: High to Low" },
              ]}
            />
          </aside>
          <main className="flex-1">
            <Stats />
            <Hits hitComponent={ProductHit} />
            <Pagination />
          </main>
        </div>
      </div>
    </HeadbandProvider>
  );
}