React SDK

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

terminal
npm install @headband/react

Optional: 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.

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

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.

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://api.headband.dev",
  "hb_src_YOUR_SEARCH_KEY"
);

function App() {
  return (
    <HeadbandProvider
      client={client}
      index="products"
    >
      {/* Search components go here */}
    </HeadbandProvider>
  );
}
ParameterTypeDefaultDescription
client · HeadbandClientHeadbandClientRequiredClient created with headband(host, apiKey).
index · stringstringRequiredThe index UID to search.
initialQuery · stringstring""Optional initial search query.
mode · "instant" | "retrieval""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.
disjunctiveFacets · booleanbooleantrueFacet 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.
children · ReactNodeReactNodeRequiredChild 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.

TypeScript
const {
  query,
  results,
  isLoading,
  error,
  setQuery,
  setPage,
  refresh,
} = useSearch();

useSearchBox()

Binds to the search input. Returns the current query plus setters.

TypeScript
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.

TSX
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.

TypeScript
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/off

usePagination()

Pagination state derived from the current search results.

TypeScript
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.

TypeScript
const {
  totalHits,
  processingTimeMs,
  query,
  isLoading,
} = useStats();

useSortBy({ items })

Sort-by dropdown state. Pass sort options, get back the current selection.

TypeScript
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.

TypeScript
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.

TypeScript
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.

ComponentWhat it doesProps
<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.placeholder, className, autoFocus, onQueryChange, styled
<Hits />Renders search result hits. Pass a custom hitComponent for custom rendering.hitComponent, className, emptyComponent, styled
<RefinementList />Checkbox list for facet filtering on a given attribute.attribute, limit, sortBy, className, styled
<Pagination />Page navigation with previous/next buttons and page number list.className, styled
<Stats />Displays result count and processing time (e.g. "42 results in 2ms").className, styled
<SortBy />Dropdown to switch sort order.items, className, styled
<Highlight />Renders highlighted text for a given hit attribute.hit, attribute, className
<RangeInput />Min/max numeric input fields for range filtering.attribute, className, styled
<PoweredBy />"Powered by Headband" attribution badge.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
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.

tsx
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>
  );
}