examples/storefront Public
id: search
type: concept
owner: justin
component: search
describes:
- src/search.ts
- search/migrations/**
reviewed: 2026-10-16
Search
GET /search?q=linen apr returns active products whose name or description match, best match first:
GET /search?q=linen%20apr&limit=24
{ "query": "linen apr", "products": [{ "id": "linen-apron", "name": "Linen work apron", ... }] }
q is 1 to 100 characters. limit works as on GET /products: 1 to 100, default 24. There is no paging; a shopper who does not find it in the first page types more.
The index
The index is one FTS5 table, products_fts, in its own D1 database, storefront-search ([[0011-search-index-in-its-own-database]]). It has a row per active product with the product's id (stored, not searched), name and description. Nothing else about a product is in it.
The tokenizer is porter unicode61 remove_diacritics 2: mugs finds mug, and creme finds Crème.
Queries
toMatch() turns what the shopper typed into an FTS5 query. It keeps letters and digits, quotes each word, and makes the last one a prefix:
| typed | MATCH |
|---|---|
linen apr |
"linen" "apr"* |
apron" OR name:mug |
"apron" "OR" "name" "mug"* |
*** |
nothing; the answer is an empty list |
Quoting means FTS5 operators and column filters in the input are only words, so no input can make the query fail. The prefix on the last word lets results follow someone who is still typing. Up to eight words are used.
Matches are ranked with bm25(products_fts, 0, 10, 1): a word in the name counts ten times as much as one in the description, so the apron comes before the tote whose description mentions an apron.
Reading the results
The index only answers with ids. activeProducts() (src/catalog.ts) reads those products from the catalog with json_each, keeps the active ones and returns them in the index's order. Price and stock are therefore always the catalog's, including stock that checkout took a second ago, and a product retired since it was indexed is left out.
Keeping it in step
PUT /admin/products/:idsaves the product, then callsindexProduct(), which deletes the product's row and inserts it again in one batch, or only deletes it when the product is inactive. If that fails, the answer is503 search_index_failed; the product is saved, and sending the same request again fixes the index.POST /admin/search/rebuild(admin token) empties the index and fills it from every active product, 50 at a time, and answers{ "indexed": n }. Use it for a new search database, afternpm run db:seed, or whenever results look wrong. While it runs, searches can miss products it has not reached yet.
Image uploads and checkout do not touch the index: they change nothing search matches on.