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/browserPages that don't import @findlane/browser/react don't load it, and the browser SDK itself doesn't need React.
A search box
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 | |
|---|---|
publicId | The index's public ID. Or set it once with FindlaneProvider. |
fields | The record fields to return. Fewer fields make responses smaller. |
getHref | A hit's page. Clicking a suggestion, or Enter on one, goes there. |
hrefTemplate | A hit's page as a template, such as "/products/{handle}", for server components. |
onSelect | Called with the chosen hit instead of following its link, for client-side routers. |
action | A results page, such as /search. Enter without a suggestion goes there with ?q=. |
onSubmit | Called with the query on Enter without a suggestion, instead of action. |
renderHit | A suggestion's content. |
limit | Suggestions to show: 6 by default. |
filters, sort | As in Search, for every suggestion. |
minLength | Characters to type before searching: 1 by default. |
debounceMs | How long to wait for a pause in typing: 150 ms by default. |
placeholder, label | The input's placeholder, and its accessible name ("Search" by default). |
empty, seeAll | The "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 | |
|---|---|
facets | Fields to count values for, which RefinementList and useRefinementList choose from. |
filterFields | Fields that useRange and useToggle filter, so the URL can keep them. |
facetStats | Number fields whose smallest and largest values useRange returns, such as a price slider's bounds. |
filters | Filters always applied, such as { published: true }. They aren't shown as choices. |
near | Where searches are near until the visitor picks another place, such as { visitor: true }; see Location. |
pageSize | Hits per page: 20 by default, up to 50. |
fields | The record fields to return. |
countMode, facetMode | "exact" by default, so totals and counts cover every match. "page" is faster on large indexes. |
routing | Keep the search in the page's URL, as ?q=shoe&brand=Acme&page=2, so it can be shared and Back works. |
state, onStateChange | Sync the search with your router instead of routing. |
initialResults | Results 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:
| Hook | Returns |
|---|---|
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, andnearVisitor(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 asnear=51.5074,-0.1278(ornear=visitor) andwithin=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.
One search
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.