SDKs

React

@findlane/browser/react: a search box that suggests as visitors type, a results page with filters, and the hooks behind them for your own markup.

Install

The React hooks and components come with the browser SDK, under @findlane/browser/react. They work with React 18.2 and 19.

npm install @findlane/browser

Pages that don't import @findlane/browser/react don't load it, and the browser SDK itself doesn't need React.

SearchBox suggests results as visitors type, with the arrow keys, Enter, and Escape working as screen readers expect. It needs only the index's public ID:

import { SearchBox } from "@findlane/browser/react"
import "@findlane/browser/react/styles.css"

export function Header() {
  return (
    <SearchBox
      publicId="pub_your_public_id"
      placeholder="Search products"
      fields={["title", "image", "url"]}
      getHref={(hit) => hit.record.url}
      action="/search"
    />
  )
}

Each suggestion shows the record's image and title (or name), with the matched words marked. Pass renderHit to show something else.

Prop
publicIdThe index's public ID. Or set it once with FindlaneProvider.
fieldsThe record fields to return. Fewer fields make responses smaller.
getHrefA hit's page. Clicking a suggestion, or Enter on one, goes there.
hrefTemplateA hit's page as a template, such as "/products/{handle}", for server components.
onSelectCalled with the chosen hit instead of following its link, for client-side routers.
actionA results page, such as /search. Enter without a suggestion goes there with ?q=.
onSubmitCalled with the query on Enter without a suggestion, instead of action.
renderHitA suggestion's content.
limitSuggestions to show: 6 by default.
filters, sortAs in Search, for every suggestion.
minLengthCharacters to type before searching: 1 by default.
debounceMsHow long to wait for a pause in typing: 150 ms by default.
placeholder, labelThe input's placeholder, and its accessible name ("Search" by default).
empty, seeAllThe "No results" and "See all N results" text.

In a server component, such as a Next.js page, props must be plain values, so use hrefTemplate instead of getHref. {handle} puts in the record's handle, URL-encoded, and {objectID} the hit's ID. A template that is a single field, such as "{url}", uses that field's URL as it is. Only http:, https:, and relative links are allowed.

A search dialog

SearchDialog is a search button that opens a dialog with the same search box, as on docs sites. ⌘K (Ctrl+K elsewhere) and / open it too:

<SearchDialog publicId="pub_your_public_id" hrefTemplate="{url}" fields={["title", "url"]} />

It takes the same props as SearchBox, plus trigger for the button's content, footer for a line at the bottom of the open dialog, such as keyboard hints, and shortcuts={false} to turn the keys off. It uses the browser's <dialog>, so focus stays inside it while it's open. Escape or a click outside closes it.

Styles

The components render plain elements with data-findlane-* attributes and no styles of their own. @findlane/browser/react/styles.css gives them a default look that follows the page's color-scheme. Its rules are inside :where(), so your own CSS wins without !important. To theme it, set these variables anywhere above the components:

.site-header {
  --findlane-accent: #0f766e;
  --findlane-radius: 6px;
  --findlane-background: #fff;
  --findlane-border: #d4d4d8;
  --findlane-muted: #71717a;
}

Your own markup

useAutocomplete keeps the search box's state and gives your elements their attributes and events, so the ARIA combobox pattern works with any design:

import { Highlight, useAutocomplete } from "@findlane/browser/react"

function Search() {
  const box = useAutocomplete<Product>({
    fields: ["title", "url"],
    onSelect: (hit) => navigate(hit.record.url),
  })
  return (
    <div className="search">
      <input {...box.getInputProps({ placeholder: "Search", "aria-label": "Search" })} />
      {box.isOpen && (
        <ul {...box.getListboxProps()}>
          {box.hits.map((hit, index) => (
            <li key={hit.id} {...box.getOptionProps(index)}>
              <Highlight hit={hit} field="title" />
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

It takes the same options as SearchBox and returns query, hits, total, error, isPending, isOpen, and activeIndex. A new keystroke cancels the search in flight, and a slow response never replaces a newer one.

A results page

SearchProvider holds a results page's search. The components and hooks inside it share its query, filters, and results, wherever they are in the page:

import {
  CurrentRefinements,
  Hits,
  LoadMore,
  RefinementList,
  SearchInput,
  SearchProvider,
  SortSelect,
  Stats,
} from "@findlane/browser/react"

export function SearchPage() {
  return (
    <SearchProvider
      publicId="pub_your_public_id"
      facets={["brand", "category"]}
      pageSize={24}
      routing
    >
      <SearchInput placeholder="Search products" />
      <aside>
        <RefinementList field="brand" label="Brand" />
        <RefinementList field="category" label="Category" />
      </aside>
      <main>
        <Stats />
        <SortSelect
          options={[
            { id: "relevance", label: "Most relevant" },
            { id: "price_asc", label: "Price: low to high" },
          ]}
        />
        <CurrentRefinements />
        <Hits<Product> render={(hit) => <ProductCard hit={hit} />} />
        <LoadMore />
      </main>
    </SearchProvider>
  )
}

When a visitor picks a brand, the other brands stay listed with their counts, so more can be added. That's still one search per change: the provider asks for disjunctiveFacets.

Prop
facetsFields to count values for, which RefinementList and useRefinementList choose from.
filterFieldsFields that useRange and useToggle filter, so the URL can keep them.
facetStatsNumber fields whose smallest and largest values useRange returns, such as a price slider's bounds.
filtersFilters always applied, such as { published: true }. They aren't shown as choices.
nearWhere searches are near until the visitor picks another place, such as { visitor: true }; see Location.
pageSizeHits per page: 20 by default, up to 50.
fieldsThe record fields to return.
countMode, facetMode"exact" by default, so totals and counts cover every match. "page" is faster on large indexes.
routingKeep the search in the page's URL, as ?q=shoe&brand=Acme&page=2, so it can be shared and Back works.
state, onStateChangeSync the search with your router instead of routing.
initialResultsResults from the server; see Server rendering.

With routing, typing replaces the current history entry, and choosing a filter adds one, so Back undoes a filter rather than a keystroke. Other parameters in the URL are kept.

Hooks

Every component is built on a hook, for when you want your own markup:

HookReturns
useSearchBox()query, setQuery, clear, isPending.
useHits()hits, total, response, request, ms, isLoaded, isPending, error.
useRefinementList(field)items (value, count, selected), toggle(value), clear.
useRange(field)range ({ gte, lte }), min, max, setRange, clear, for numbers and dates.
useToggle(field, value)on, set, toggle, for a filter like inStock: true.
useSortBy()sort, setSort.
useCurrentRefinements()items (field, value, label, remove), clear.
useLoadMore()hasMore, loadMore, isLoading, shown, total.
usePagination()page (from 0), pages, setPage, hasPrevious, hasNext.
useDidYouMean()didYouMean (q, applied, changes) and apply; see below.
useGeoSearch()near, within, center, setNear, nearVisitor, setWithin, clear; see below.
useSearchState()The whole state and setState.

A price range with two inputs, for example:

function PriceRange() {
  const { range, setRange } = useRange("price")
  const bound = (value: string) => (value ? Number(value) : undefined)
  return (
    <fieldset>
      <legend>Price</legend>
      <input
        type="number"
        placeholder="Min"
        value={range?.gte ?? ""}
        onChange={(event) => setRange({ ...range, gte: bound(event.target.value) })}
      />
      <input
        type="number"
        placeholder="Max"
        value={range?.lte ?? ""}
        onChange={(event) => setRange({ ...range, lte: bound(event.target.value) })}
      />
    </fieldset>
  )
}

For a slider, list the field in the provider's facetStats too: min and max are its smallest and largest values across the matches, without the range itself, so the slider's ends stay put while it moves.

The playground is built this way.

Did you mean

With the index's didYouMean setting on, DidYouMean says "Showing results for protein" when typo tolerance corrected the query. When nothing matches, it waits until the visitor stops typing, asks for a suggestion, and shows "Did you mean protein?" as a button that searches for it:

<SearchInput />
<DidYouMean />
<Hits render={(hit) => <ProductCard hit={hit} />} />

It shows nothing for indexes without the setting, and doesn't ask them. delay sets the pause (400 ms by default), and format renders your own line: format={(didYouMean, apply) => ...}.

Location

With the index's location field, useGeoSearch searches by location. Pass near={{ visitor: true }} to the provider to start nearest the visitor:

<SearchProvider publicId="pub_…" near={{ visitor: true }} routing>
  <StoreFinder />
</SearchProvider>

function StoreFinder() {
  const { hits } = useHits<Store>()
  const { center, setNear, setWithin } = useGeoSearch()
  // center: where distances are from, such as the visitor's city, to center a map on.
  return hits.map((hit) => (
    <p key={hit.id}>
      {hit.record.name}: {(hit.distance! / 1000).toFixed(1)} km
    </p>
  ))
}
  • setNear({ lat, lng, radius }) searches near a point, and nearVisitor(radius) near the visitor.
  • setWithin({ north, south, east, west }) keeps what a map shows. Call it from the map's move events: map moves replace the history entry instead of adding one, so Back doesn't step through every drag.
  • clear() removes both.
  • With routing, the URL keeps them as near=51.5074,-0.1278 (or near=visitor) and within=north,east,south,west.

There's no map component: use any map library. The map playground uses MapLibre.

Highlights

Highlight shows a field with the words the query matched in <mark>. Snippet cuts a long field, such as a description, to the words around its best match:

<Highlight hit={hit} field="title" />
<Snippet hit={hit} field="description" words={24} />

Both render React elements, not HTML, so they work under a strict Content Security Policy. A field without a match shows its plain value. field can be a path such as brand.name, and a list field's values are joined with separator (", " by default). Pass mark="strong" or your own component to change the element.

Server rendering

Browser search answers only browsers on the index's allowed origins, so a server renders the first results with the server SDK and a key with the search scope. getServerResults runs the same search the provider would, from @findlane/browser/react/ssr, which server code can import. A Next.js page:

// app/search/page.tsx
import { SearchServerClient } from "@findlane/server"
import { getServerResults, stateFromUrl } from "@findlane/browser/react/ssr"
import { SearchPage } from "./search-page" // a "use client" component with the SearchProvider

const config = { facets: ["brand", "category"], pageSize: 24 }
const products = new SearchServerClient({
  workspaceId: process.env.FINDLANE_WORKSPACE_ID!,
  apiKey: process.env.FINDLANE_SEARCH_KEY!,
}).index("products")

export default async function Page({
  searchParams,
}: {
  searchParams: Promise<Record<string, string | string[]>>
}) {
  const state = stateFromUrl(await searchParams, config)
  const initialResults = await getServerResults(config, state, (request) =>
    products.search(request)
  )
  return <SearchPage config={config} initialResults={initialResults} />
}

Give the client's SearchProvider the same config, initialResults, and routing. Its first render then matches the server's and doesn't search again. Searches from the server count toward your plan like browser searches.

useSearch keeps a single search current, for anything the components don't cover. It waits for a pause in typing, cancels the search in flight, and keeps the previous results while the next ones load. Pass null to not search.

const { data, error, isPending } = useSearch<Product>(query ? { q: query, limit: 5 } : null, {
  publicId: "pub_your_public_id",
})

Recent responses are kept in memory for five minutes, so going back to a query shows its results at once and doesn't search again.

Errors

The hooks return a failed search's FindlaneError as error; see Errors. messageFor(error) turns it into a sentence for visitors. A 403 (an origin that isn't allowed) or 402 (a plan's searches used up) becomes "Search isn't available right now.", since visitors can't fix either.