Skip to content
Start free

Guides

Searching

Queries, filters, facets, sorting, paging, and highlighting, from the browser or your server.

The browser and server SDKs take the same search request. The examples use the browser client; with the script tag, create it with new Findlane.SearchBrowserClient(...) instead of importing it:

import { SearchBrowserClient } from "@findlane/browser"

const client = new SearchBrowserClient({ publicId: "pub_your_public_id" })

const results = await client.search<Product>({
  q: "rain jacket",
  filters: { "brand.name": { in: ["Northwind", "Acme"] }, price: { lte: 200 }, inStock: true },
  facets: ["brand.name", "categories"],
  sort: "price-asc",
  limit: 24,
  highlight: true,
})

Queries

q is up to 200 characters. Every word must match one of the searchable fields, with typo tolerance and synonyms, and results rank by how well and where they match. An empty q returns every record that passes the filters, in the chosen sort.

Filters

filters keeps records whose fields match, for up to 20 facet or filter fields. All filters must match.

FilterKeeps records where the field
{ inStock: true }Is true. Text and numbers work the same way.
{ "brand.name": { in: ["Acme", "Northwind"] } }Is any of up to 100 values
{ tags: { all: ["vegan", "organic"] } }Has every one of the values. List fields only.
{ "brand.name": { not: "Acme" } }Isn't the value
{ color: { notIn: ["red", "pink"] } }Is none of up to 100 values
{ price: { gte: 50, lte: 200 } }Is within a range; either bound can be left out. Numbers only.

A list field matches a value, in, or a range when any of its values does, and not or notIn when none of them does. Records without the field are kept by not and notIn, so { color: { not: "red" } } also keeps products with no color.

Conditions in one object must all match: { tags: { in: ["kids", "adult"], notIn: ["party"] } } keeps records tagged kids or adult, but not party.

Any of several groups

$or keeps records that match any of one to four groups of filters. A group's filters must all match, and so must the filters outside $or:

const results = await client.search({
  q: "costume",
  filters: {
    inStock: true,
    $or: [{ categories: "Kids" }, { "brand.name": "Acme", price: { lte: 30 } }],
  },
})
// In stock, and either for kids or by Acme for 30 or less
  • A group can't hold another $or.
  • The 20 filters include those in groups.
  • In browser searches, the fields in groups must be among the index's public filter fields too.

Facets

facets asks for value counts of up to 32 facet fields:

results.facets
// { "brand.name": { Acme: 12, Northwind: 7 }, categories: { Jackets: 15, Shoes: 4 } }

A field that isn't a facet field, or that browser search's policy doesn't allow, is left out of facets rather than failing the search. A page that asks for a facet before it's configured still gets its results. The same goes for disjunctiveFacets and facetStats. Filters are different: a filter on a field that isn't configured fails the search, since ignoring it would show results the visitor filtered out.

A list of checkboxes needs more: after a visitor picks Acme, the other brands should stay listed, with counts, so they can be added. List those fields in disjunctiveFacets as well, and each is counted without the filter on its own field, in the same request. Filters on the field inside $or still apply:

const results = await client.search({
  q: "jacket",
  filters: { "brand.name": { in: ["Acme"] }, inStock: true },
  facets: ["brand.name", "categories"],
  disjunctiveFacets: ["brand.name"],
})
results.facets["brand.name"] // { Acme: 12, Northwind: 7 }: in stock jackets, any brand
results.facets.categories // { Jackets: 12 }: in stock Acme jackets

Disjunctive facets are always counted across every match. React's SearchProvider sets them for you.

Number ranges

facetStats asks for the smallest and largest values of up to eight number fields across every match, such as the ends of a price slider. Each field's own filter is left out, like a disjunctive facet's, so the ends stay put while the slider moves:

const results = await client.search({
  filters: { category: "keyboards", price: { gte: 20000 } },
  facetStats: ["price"],
})
results.facetStats // { price: { min: 1990, max: 489000 } }: keyboards at any price

A field that no match has is left out. In browser searches, the fields must be among the index's public filter fields.

Sorting

sort is a sort ID from the index's settings, or relevance, or distance with near. Without one, the index's default sort applies. results.sort says which sort was used. The index's rank first fields order results before any sort but distance.

Location

With a location field, searches can find records near a point or inside a map's area.

near orders matches by distance from a point, nearest first, and gives each hit its distance in meters. Add a radius, in meters, to keep only what's within it:

const results = await client.search({
  q: "drive thru",
  near: { lat: 35.6595, lng: 139.7005, radius: 5000 },
})
results.hits[0].distance // 420
results.near // { lat: 35.6595, lng: 139.7005 }
  • With a query, relevance comes first. The best matches, within the index's relevance tolerance of the top score (10% by default), are ordered by distance, as with custom ranking. So a near record that matches only through a typo doesn't outrank a farther exact match.
  • Without a query, results are nearest first.
  • sort: "distance" orders by distance alone, for a "closest first" option.

within keeps records inside a box, in degrees, such as a map's visible area. Add near at the map's center to show the nearest first:

const results = await client.search({
  within: { north: 51.56, south: 51.45, east: 0.02, west: -0.27 },
  near: { lat: 51.505, lng: -0.125 },
})

When west is greater than east, the box crosses the antimeridian (180° longitude). For a map, send near with within: results come back nearest the map's center, and a zoomed-out map stays as fast as a city.

Near the visitor. In browser searches, near: { visitor: true } uses the visitor's approximate location from their IP address, about the center of their city. radius works with it too.

  • results.near says where that was, so you can center a map on it.
  • It's null when the location isn't known, and results then come back in their usual order.
  • Searches from your server pass the user's coordinates instead, because your server's IP address would place them near your server.

A few more things to know:

  • Records without a location are left out of searches with near or within.
  • Distances follow the Earth's surface, to within about half a percent.
  • Caching. Visitors in the same city share cached results. Exact coordinates, such as a phone's GPS position, rarely repeat, so those searches are answered fresh and count toward your plan's searches. Rounding them to three decimals (about 100 m) lets nearby searches share results.
  • Public rules. If the index has custom public rules, they must allow location search.

Maps

For drawing a map, ask for fixed map tiles rather than the visitor's box. A box almost never repeats, but everyone looking at one area asks for the same tiles, so they're answered from the cache near the visitor. In React, useMapSearch does all of this.

import { tilesFor } from "@findlane/browser"

// One level above the map's zoom: at most six tiles on a desktop screen.
for (const tile of tilesFor(map.getBounds(), map.getZoom())) {
  const { clusters, total } = await client.search({
    filters: { status: "active" },
    tile,
    limit: 0,
    clusters: { cells: 16, stats: ["price"] },
  })
}
  • tile is { z, x, y }, numbered as MapLibre and other web maps number them, up to zoom 22. It can't be combined with near or within. A record is on exactly one tile of a zoom, because a tile's south and east edges belong to its neighbours, so totals and clusters add up across tiles.
  • clusters groups a tile's matches by cell, with cells of 1, 2, 4, 8 or 16 a side. It respects the query, filters and rules. With limit: 0, the answer has no hits.
  • spread makes the page each cell's best records rather than the top of one list, so pins spread over the map: { cells: 8, perCell: 1 } shows the first record of each cell by the search's ranking, up to limit. perCell goes up to 10. It takes no offset, and leaves distinct off. It can come with clusters, so one request gets bubbles and pins.
  • bounds: true adds the box around every match, not only the page, for a map to fly to. It doesn't need a tile. It's null when no match has a place on the map.

Each cluster says where to draw it and what to zoom to:

{
  "count": 45,
  "lat": 64.14,
  "lng": -21.93,
  "bounds": { "north": 64.15, "south": 64.13, "east": -21.91, "west": -21.95 },
  "stats": { "price": { "min": 40000000, "max": 90000000, "avg": 62000000 } }
}
  • lat and lng are the average place of the cluster's records, so a bubble sits where the records are rather than mid-cell.
  • bounds with no size means every record is at one place, such as flats in one building. Its edges are rounded to about ten centimeters and a record's place isn't, so a record on an edge can fall just outside. clusterFor allows for that. If you compare them yourself, allow 0.000001 degrees.
  • stats takes up to three number fields, under the same rule as facetStats. Write one as { field: "price", gt: 0 } to leave out values at or below gt, such as a price of 0 that means no price. Those records stay in count and on the map. Records without the field are left out as well, and a cluster with no values says nothing of the field.

A few more things to know:

  • Plan. Each tile not answered from the cache counts as one search, as any search does. The x-edge-cache response header says which it was: miss counts, while hit and stale (an older copy, refreshed in the background) don't. Tile searches show on the Traffic tab, not in search analytics.
  • total covers whole tiles, which reach past the view. To say how many are in sight, add up the clusters within it.
  • Freshness. After a write, the cache may answer with a tile up to 5 minutes old while it refreshes, so a new pin can appear a little late.
  • Heavy visitors. Clusters return no records, so they don't count toward a visitor's heavy-visitor budget. spread hits do, even when the cache answers them.
  • Public rules. Browser searches need location search allowed, as above. clusters.stats fields must be ones visitors may filter by; others are left out. A cell with one record shows that record's place and values.
  • The map's edge. Web Mercator stops at about 85° north and south, so records past that are on no tile and in no bounds. Matches on both sides of the antimeridian get a bounds as wide as the map.

Paging and counts

limit is the number of hits per request (20 by default; up to 100 from your server and 50 from browsers; 0 with clusters), and offset skips hits, up to 5,000.

By default, total and facet counts cover the returned page when a query matches more, which keeps searches fast:

  • hasMore is true when there are more hits after this page.
  • totalRelation is "gte" when total is a lower bound.

Set countMode: "exact" for the total number of matches, and facetMode: "exact" for facet counts across all of them. They cost more time on broad queries, so ask for them when you show them, such as on the first page of results.

Grouped results

With the index's distinct setting, such as a product's colors grouped by the product ID they share, each group shows once, as the record that best matches the search:

  • total, offset, limit and hasMore count groups, and totalRecords counts the matching records.
  • Each hit has group: its value of the group field (null for a record without one) and how many of the group's records matched.
  • Facet counts still count records.
  • distinct: false shows every record, and a number from 1 to 4 shows that many of each group's records.
const results = await client.search({ q: "blue trail shoe" })
// results.hits[0]: { id: "trail-shoe-blue", group: { value: "trail-shoe", matched: 3 }, record: { color: "blue", ... } }

Matched variants

With the index's variants setting, each hit says which item of its variants list the search matched, by its words or by filters on the variants' fields:

const results = await client.search({ q: "amino blue raspberry" })
const hit = results.hits[0]
// hit.variant: { index: 1 }
const shown = hit.variant ? hit.record.variants[hit.variant.index] : null
// shown: { flavor: "Blue Raspberry", image: "https://cdn.example.com/amino-blue.png", ... }

A hit without variant matched no variant in particular: show the product as it is.

Large indexes

On an index of 100,000 records or more, a search that matches more than 2,000 records counts its facets on a sample, scaled to the total: every nth match, up to 2,000 of them, or for filters with many matches, the matches on a sample of the index's pages. The response then has facetsEstimated: true. total stays exact, and so do the counts of all records when nothing narrows the search. Facets on these indexes give each field's 100 most common values.

Highlighting

highlight: true returns each hit's matched searchable fields with the matched words in <mark> tags, under highlights. The values are HTML-escaped, so they are safe to insert as HTML. List fields give one string per value.

results.hits[0].highlights
// { title: "Waterproof <mark>rain</mark> <mark>jacket</mark>" }

Choose other tags with highlight: { preTag: "<em>", postTag: "</em>" }.

Did you mean

With the index's didYouMean setting on, searches say how typo tolerance corrected the query. didYouMean is null when it didn't:

const results = await client.search({ q: "protien bar" })
results.didYouMean
// { q: "protein bar", applied: true, changes: [{ from: "protien", to: "protein" }] }

applied: true means the results are already for q: show "Showing results for protein bar".

When a search finds nothing, ask for a suggestion with the same request. It returns a query that finds results, or null:

const results = await client.search({ q: "prtien bar", filters: { inStock: true } })
if (!results.total && results.didYouMean !== undefined) {
  const suggestion = await client.suggest({ q: "prtien bar", filters: { inStock: true } })
  // { q: "protein bar", applied: false, changes: [{ from: "prtien", to: "protein" }] }
}

Show it as a link that searches for q: "Did you mean protein bar?". Changed words keep the case the shopper typed and the spelling your records use, with accents. Suggestions only use fields the request can search and return.

A suggestion corrects what looks like a typing mistake: letters left out, two letters swapped, a doubled letter, or one letter replaced by a key next to it or one that sounds the same, such as kafe → café. A word your records don't have that doesn't look like a typo, such as a brand you don't sell, gets no suggestion rather than an unrelated word that's close in spelling. Words of up to five letters get one change, up to seven two, and longer words three. A word is split in two only where neither part is a stop word.

  • Ask once typing stops. While a visitor types, many in-between queries find nothing. Ask for a suggestion after a pause of about 400 ms, or on a results page, not for every keystroke. React's DidYouMean does this for you.
  • It's free. Suggestions don't count toward your plan's searches, but they do count toward the rate limits.
  • Check the setting first. results.didYouMean is left out entirely when the setting is off, so !== undefined tells you whether to ask.

With the index's relatedResults setting on, a search whose words find nothing also returns the products closest to it in meaning, in related:

const results = await client.search({ q: "vampire teeth" })
results.total // 0
results.related
// { hits: [{ id: "v2", score: 0.71, record: { title: "Vampírutennur, hvítar", ... } }], total: 1 }

Show them under a heading of their own, such as "No exact matches. Related products". score is how close each one is in meaning. React's Hits does this for you.

  • Only when nothing matched. related is left out when the search finds something, on later pages, and while the last word has only one or two letters, as a search being typed often does.
  • Filters still apply. Related results are only records the search's filters allow.
  • Ask for none with related: false.
  • Clicks. Send a related result's click with list: "related", so Analytics counts it apart from search results. The browser SDK and React do this for you.

Facet matches

With the index's facetMatches setting, a search can ask which facet values its query names, such as a category, a brand or a zip. Offer them beside the products, as rows that go to the category or brand page:

const results = await client.search({ q: "prót", facetMatches: true })
results.facetMatches
// [{ field: "categorySlugs", values: ["protein"], label: "Prótein", count: 48, match: "prefix", whole: true,
//    typed: [0, 4], link: "/shop?category=protein", filter: { categorySlugs: { in: ["protein"] } }, applied: false },
//  { field: "categorySlugs", values: ["proteinstangir"], label: "Próteinstangir", count: 93, match: "prefix", ... }]

Each match says:

  • values: the field's values it stands for. Values that share a label match as one: "hafnarfjörður" gives zips ["220", "221"].
  • label: its name, and count: its records that the search's filters allow.
  • match: how the query named it:
    • exact;
    • prefix, a word still being typed;
    • typo;
    • contains, when the whole query is a later word of the value, as whey is of "100% Pure Whey".
  • whole: the match took every word of the query. Use it to choose what Enter does: a whole exact brand match can go straight to the brand's page.
  • typed: where the matched words are in q, as UTF-16 offsets, so they can be set apart.
  • link: the setting's link with the values in it. filter: filters for the match, to add to a search's filters.
  • applied: whether the search filtered by it itself. See Applying.

Matches come best first, at most 10: exact before prefix, typo and contains; whole ones first; then values more of whose words are typed; then by count.

  • In the same search. Facet matches come back with the results. Searches that don't ask get none and don't change.
  • Some fields only: facetMatches: { fields: ["brandSlugs"] }.
  • Browser search gets matches only in facets the public policy lets browsers count, and names read from records only from fields browsers may read. Otherwise the value itself is the label.
  • Empty ([]) when the query names nothing, or the index has no facet matches.
  • When a shopper picks one, send client.events.facetMatch(match, { query, position }). Analytics then shows the filters opened this way, and the searches they came from.
  • In React and the search box widget, they show as rows. See React and data-facet-matches.

Applying a match

With apply in the setting, a search that asks is filtered by a match that names a value exactly: diablo finds the brand Diablo's products, rather than every product that says "diablo".

const results = await client.search({ q: "diablo whey", facetMatches: true })
results.facetMatches[0].applied // true
results.rewrittenQuery // "whey": what was searched, among Diablo's products
  • Only exact matches apply. A word still being typed or a typo only shows.
  • At most one per field, and two can't share words.
  • None on a field the search already filters, or that a rule filters. A rule that replaced words wins over all of them.
  • A search that then finds nothing runs as typed, and says applied: false.
  • For this search only: facetMatches: { apply: "never" } only reports them, for a dropdown, or after a shopper takes the filter away. "whole" and "exact" choose too.

Show the filter when a search applies one, with a way to remove it, so shoppers know why they see what they see. Searches that don't ask are never filtered.

Query suggestions

As a shopper types, suggest whole searches, the way search engines do: hrek suggests "hrekkjavöku búningar" above the products. With nothing typed, show the store's popular searches.

const suggestions = await client.querySuggestions({ q: "hrek" })
// [{ q: "hrekkjavöku búningar", typed: [0, 4] }, { q: "hrekkjavaka", typed: [0, 4] }]

const popular = await client.popularSearches()
// [{ q: "gríma" }, { q: "latex" }, { q: "hárkolla" }]

typed says where what was typed is in the suggestion, as UTF-16 offsets: show that part plain and the rest bold. suggestionParts(suggestion) splits it for you. Picking a suggestion should search for it. limit asks for 1 to 10 suggestions, 5 by default.

They come from three places:

  • Popular searches: what shoppers searched over the last 90 days that found something, searched at least 3 times on 2 different days. Recent searches count more, so seasonal ones rise and fade, and searches whose results get clicked count more too. Typing on the way to a longer search, typos, long numbers, and offensive words don't count.
  • Your catalog: values of fields such as categories, themes, and brands, and words of your titles that two or more records have. These only complete what's typed, and never show in the empty box.
  • You: searches you pin, add, or hide in the index's settings.

A suggestion starts with what was typed or, lower down, has a later word that does: búningar finds "hrekkjavöku búningar". Accents and case don't matter, and suggestions use your records' spelling, so grima suggests "gríma".

  • Ask on every pause in typing. Suggestions are answered from memory at the Cloudflare location nearest the shopper, and never reach the database. They don't count toward your plan's searches, and have their own rate limit.
  • Lists are rebuilt daily for indexes searched in the last week, and within a few minutes of a settings change. A new index gets its list once it's searched.
  • Popular searches need traffic. The empty box shows none until three searches qualify, unless you pin one.

Recent searches

The browser SDK keeps each shopper's recent searches in their own browser, in localStorage, and never sends them to Findlane. Add a search when it's submitted, picked from suggestions, or leads to a result being opened, not on every keystroke:

client.recentSearches.add("hárkolla")
client.recentSearches.list() // ["hárkolla", ...], the newest first
client.recentSearches.remove("hárkolla")
client.recentSearches.clear()

React's SearchBox shows suggestions, popular searches, and recent searches with three options, and the Shopify app with its theme settings.

Rules

When a search fires the index's rules, the response lists them in appliedRules, in the index's order, each with its userData. When a rule replaced words, rewrittenQuery is the query that was searched. With the party shop's rules:

const candles = await client.search({ q: "kerti 5 ára" })
candles.appliedRules // [{ id: "age-5" }]
candles.rewrittenQuery // "kerti"

const results = await client.search({ q: "halloween masks" })
results.appliedRules // [{ id: "halloween", userData: { banner: "Everything for Halloween", url: "/halloween" } }]

Both are left out when no rule fired. A hit a rule pinned at its place carries pinned: true, so your page can mark it, and its score is null. Rules' banners come in banners, [{ image, link, title, position, rule }] in the rules' order, to show at their places among the hits, and a rule's page in redirect, to go to instead of showing results. Use userData to show a banner, or to go to another page:

type RuleData = { banner?: string; url?: string; redirect?: string }

const data = results.appliedRules?.find((rule) => rule.userData)?.userData as RuleData | undefined
if (data?.redirect) location.assign(data.redirect)
else if (data?.banner) showBanner(data.banner, data.url)

userData is whatever JSON the rule holds, up to 4 KB, so choose its shape for your site. React's useHits returns the whole response as response, appliedRules included.

enableRules: false searches without the rules, to compare results with and without them. It's for searches with an API key, the dashboard's Playground (its Rules switch), and AI agents. Browser search always applies the rules: a search with a public ID and enableRules fails with a 400.

Boost and bury

A search can order its own results as a rule's boost does: boost puts records matching its filters first, after rank first, in searches sorted by relevance, with words or without, and bury puts them last. Each takes filters as filters does, or a list of up to 10, each counting once, so records matching more come first:

// The shopper's own store first, then products on sale; sold-out colours last.
const results = await client.search({ q: "jacket", boost: [{ store: "kringlan" }, { onSale: true }], bury: { available: false } })

Named sorts, such as price, keep the order the shopper chose. Browser search takes them on fields its public settings let it filter by.

Contexts

A page can send context: a name, or up to 10, that rules with a context condition fire on. Your site chooses the names, each 1 to 64 letters, digits, _, :, or -:

// The shoe category page, during the sale.
const page = await client.search({ q: "", filters: { category: "Skór" }, context: ["category-page", "black-friday"] })

A page that filters by its category needs no context for rules on that category: a rule can fire on the search's filters. Send a context for what the filters don't say, such as which page or store the search comes from. React's SearchProvider takes context too.

rulesAt, a time in milliseconds since 1970, checks the rules as at that time instead of now, such as a campaign's start. It's for searches with an API key; browser search doesn't take it.

Explain results

explain: true says why each hit matched and what put it where it is, for tuning weights, synonyms, typo tolerance, and ranking. Search with it from your server, with the server SDK, and each hit gets an explain object:

import { SearchServerClient } from "@findlane/server"

const client = new SearchServerClient({
  workspaceId: process.env.FINDLANE_WORKSPACE_ID!,
  apiKey: process.env.FINDLANE_API_KEY!,
})

const results = await client.index("products").search({ q: "blodur gyllt", explain: true })
results.hits[0].explain
// {
//   words: [
//     {
//       query: "blodur",
//       matches: [
//         { field: "title", word: "Blöðrur", how: "typo", weight: 0.8, edits: 1 },
//         { field: "tags", word: "blöðrur", how: "typo", weight: 0.2, edits: 1 },
//       ],
//     },
//     { query: "gyllt", matches: [{ field: "title", word: "gylltar", how: "prefix", weight: 1.6 }] },
//   ],
//   band: 0,
//   bestScore: 11.6,
//   rankFirst: [{ field: "inStock", direction: "desc", value: true }],
//   customRanking: [{ field: "popularity", direction: "desc", value: 82 }],
//   rules: [{ id: "gold", effect: "boost" }],
// }

Over HTTP, add "explain": true to the request body:

curl "https://api.findlane.dev/api/teams/$FINDLANE_WORKSPACE_ID/indexes/products/search" \
  -H "Authorization: Bearer $FINDLANE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "q": "blodur gyllt", "explain": true }'

words lists each query word, up to eight and without the languages' common words, with its best match in each searchable field, strongest first. word is the matched word as the record writes it, and how says how it matched:

howThe query word matched
exactAs itself. Case and accents don't matter.
prefixThe start of a longer word.
typoWith edits typos, or firstLetter: true for another first letter, which is tried only when nothing matches without it.
synonymThrough a synonym: synonym is the one that matched.
formAs a plural or another spelling in the index's languages.
compoundWith compound words: as its parts written apart, such as word: "Sjóræningja búningur" for sjóræningjabúningur, or as the word it makes with a neighboring query word.

With the one-way synonym { input: "balloons", synonyms: ["blöðrur"] }, a search for balloons explains its match as { field: "title", word: "Blöðrur", how: "synonym", weight: 6.4, synonym: "blöðrur" }.

  • weight isn't points. A search scores the whole record, not each field, so a match shows its weight in the search instead: the field's weight times 1 for an exact match, 0.8 for a synonym or word form, 0.4 for a compound, 0.2 for a prefix, and 0.1 for a typo. Above, the title has weight 8 and the tags 2. Weights show which matches carried a result; they don't add up to its score.
  • band comes with custom ranking, or near, and a query. Band 0 holds the results within the index's relevanceTolerance (10% by default) of bestScore, band 1 the next step down (10–20% below it by default), and so on. Within a band, custom ranking (and distance, with near) decides the order, so a result with a lower score can come first. With rank first, or rules that boost, bestScore is the best in the hit's group: the records with its rank first values and as many boosts.
  • rankFirst, customRanking, and sort give the record's value for each field that ordered it: the index's rank first fields, its custom ranking fields when sorting by relevance, and the sort's fields when sorting by a named sort. A record without a value has null, and comes last.
  • learned comes on a hit that learning from clicks put first in its group: { place: 1 } for the search's most picked product.
  • rules comes when the query fired rules that boost or bury, in searches they order (with a query, sorted by relevance): each rule whose boost or bury filters the record matched, with effect: "boost" or "bury". It's empty for a record they didn't move.

Explain is for searches with an API key, the dashboard's Playground (under Why this result?), and AI agents. Browser search doesn't explain results: a search with a public ID and explain fails with a 400. It makes responses larger, so leave it out of the searches your site runs.

Fewer fields

attributesToRetrieve returns only the listed fields of each record, such as ["title", "price", "image"], which makes responses smaller. It doesn't keep the other fields private; see Security.

Responses

{
  hits: [{ id: "sku-2", score: 12.4, record: { objectID: "sku-2", title: "Waterproof rain jacket", ... } }],
  total: 1,
  totalRelation: "eq",
  hasMore: false,
  facets: { "brand.name": { Northwind: 1 } },
  sort: "relevance",
  near: { lat: 35.66, lng: 139.7 }, // only with near, and each hit then has a distance in meters
  didYouMean: null, // only with the didYouMean setting
  appliedRules: [{ id: "jackets" }], // only when the query fired rules
  rewrittenQuery: "rain jacket", // only when a rule replaced words
  related: { hits: [...], total: 3 }, // only with the relatedResults setting, when nothing matched
  totalRecords: 4, // only with the distinct setting, when total counts groups; each hit then has a group
  facetMatches: [...], // only when the search asks for facet matches
  // with the variants setting, a hit can have variant: { index: 1 }, the item of its list the search matched
  tookMs: 6,
}

Search as you type

  • Search on the first keystroke at once. People type a key every quarter of a second or so, so a long wait after each one only delays the results; wait about 50 ms only when keys come faster, as a paste does.
  • Let earlier requests finish, and show an answer only if no newer one has been shown. Answers from the cache aren't counted, and a finished one makes Backspace instant. To stop a request, pass { signal } to the browser client, or a signal as the server client's second argument.
  • Open the connection when the search box gets focus: client.warm() in the browser SDK, or <link rel="preconnect" href="https://api.findlane.dev" crossorigin> in the page.
  • Keep the first request light: exact counts and many facets can wait for the results page.