Search
Storefront search matches and scores products for each query, and you tune the results by changing how Litestore weighs matches.
Storefront search turns a shopper's query into a list of matching products. Read this page when results look wrong, or when you need to change how Litestore finds or weighs matches.
Request
Search works in two steps. First it finds candidate products, then it scores them. Neither step can break the page. If the fuzzy layer fails, the shopper gets fewer results instead of an error page.
Litestore keeps the two steps apart on purpose. identifyCandidates() passes a SearchCandidateSet to the ranking pipeline and stops there. Changing how Litestore finds candidates doesn't touch how it ranks them, and the other way round.
Trigram matching
Litestore does approximate matching in JavaScript instead of using the Postgres pg_trgm extension. If you self-host, you don't need to install any database extensions.
/**
* "cat" → [" c", " ca", "cat", "at ", "t "]
*/
function trigrams(s: string): string[] {
const padded = ` ${s.toLowerCase()} `
const result: string[] = []
for (let i = 0; i <= padded.length - 3; i++) {
result.push(padded.slice(i, i + 3))
}
return result
}The function pads the string with spaces at both ends. The start and end of a word then form trigrams of their own instead of blending into the middle.
trigramSimilarity returns a number from 0 to 1. We wrote it to return what Postgres's similarity() would return, so you could later swap in pg_trgm without changing callers. It divides the overlap of the two trigram sets by their union. If the two strings match exactly, ignoring case, it returns 1.0 straight away.
The source comments name two possible replacements. You could use pg_trgm for speed on large catalogs, or embedding cosine similarity for matching by meaning. The authors chose the JavaScript version with that trade-off in mind.
Pipeline
The search module is split into small files, and each one does a single job.
| File | What it does |
|---|---|
candidates.ts | Has identifyCandidates(), the first pass that gathers products that might match. |
contains.ts | Does substring matching. |
fuzzy.ts | Computes trigram similarity, which catches typos and alternate spellings. |
synonyms.ts | Has a fixed synonym map written in code. |
match-strength.ts | Turns a match into a weighted score. |
use-search-suggestions.ts | Holds the storefront suggestion policy, which determines when a query is long enough to search at all. |
admin-search-scope.ts | Handles the separate admin search. |
buildContainsSearch in contains.ts builds a Prisma OR filter that matches when any of the given fields contains the query, ignoring case. For an empty query it returns undefined, so callers can spread it in only when there is something to search. It can follow one level of relation with a dotted path such as customer.email.
SEARCH_SUGGESTION_POLICY in use-search-suggestions.ts is the suggestion policy that the storefront header and the mobile search overlay both use:
| Setting | Value |
|---|---|
debounceMs | 250 |
minQueryLength | 2 |
Two callers use the full pipeline: the header's live suggestions (searchItems in modules/storefront/search/actions.ts) and the product queries in modules/products/storefront/queries.ts, which the shopper MCP tools also use (the assistant's searchProducts tool uses the admin picker search instead). The /feed?q=… results page doesn't: its filter translator turns q into a plain case-insensitive contains on name or description, so a typo can show a suggestion that the results page then doesn't match.
admin-search-scope.ts serves the admin, where the main concern is permissions. allowedSearchModules removes every group of records from modules the user cannot view. It does this before the search touches the database, so Litestore never even queries a module the operator cannot read. Nobody can use the command palette to see records their role hides from them.
Phases
identifyCandidates() always runs these phases in the same order.
Synonym expansion
Litestore adds related terms to the query.
Primary identification
It runs ILIKE across name, description, tags and categories, using the original query and every synonym.
Metadata computation
It works out which sources matched each candidate.
Fuzzy identification
It runs trigram similarity over near-misses the first pass did not return.
Merge
It combines everything into the final candidate set.
Litestore wraps phases 1 and 4 in try/catch and logs a failure. If synonym expansion breaks, the query simply gets no synonyms. If the fuzzy pass breaks, there are no fuzzy matches. Either way, the primary ILIKE results still come back.
The primary query returns at most maxCandidates products, 500 by default. It sorts featured products first and then the newest.
This sort order matters. take cuts off the results at the limit, and without an orderBy Postgres returns rows in whatever order they have on disk. The same query could then rank different products on different runs. Sorting by featured and then newest makes the cut-off predictable, and it keeps the merchant's featured products.
Fuzzy search
The fuzzy pass only runs when the primary pass found fewer candidates than 80% of maxCandidates. If the primary pass already found enough, Litestore skips it. It also skips queries shorter than three characters, because trigrams can't compare such short strings meaningfully.
To stay cheap, the fuzzy pass does not score the whole catalog. It takes roughly the first 40% of the query as a prefix, with a minimum of two characters. It then loads at most 200 products whose name or tag slug starts with that prefix, leaving out products the primary pass already returned.
Litestore scores each product in that pool on how similar its name is and how similar its closest tag is. It keeps the product if either score reaches the default threshold of 0.3. That threshold catches common typos without many false positives, and it's tunable for your catalog.
| Setting | Default |
|---|---|
maxCandidates | 500 |
| Fuzzy pass runs below | 80% of maxCandidates |
| Shortest query for fuzzy | 3 characters |
| Prefix length | About 40% of the query, at least 2 characters |
| Fuzzy pool size | 200 products |
| Similarity threshold | 0.3 |
Fuzzy matching is a second pass, not the first
identifyCandidates() uses trigrams to catch near-misses that a plain ILIKE would miss. This adds candidates, and the scoring pass then determines how they rank.
Match strength
Each candidate gets a queryMatchStrength between 0 and 1. Litestore works it out from the ways the product matched, which the code calls sources. SOURCE_WEIGHTS gives each source a weight:
name_ilike 1.0
tag_exact 0.9
supplier_exact 0.85
category_exact 0.8
description_ilike 0.7
name_fuzzy 0.6
tag_fuzzy 0.5
supplier_fuzzy 0.5
synonym 0.5The weights reflect what the shopper probably meant. If the name matches, the shopper searched for exactly what the product is. If only the description matches, the product mentions the term but isn't about it. Fuzzy and synonym matches are guesses, so they score lower.
A tag match scores 0.9 as tag_exact. A tag is a strong signal, but slightly weaker than a match on the product name.
computeMatchStrength uses the highest weight among the matched sources, not their sum. A product that matches on both name and tag is not necessarily more relevant than one that matches on name alone. If Litestore added the weights, several weak matches could outrank one strong match.
Synonyms
We wrote synonyms in code instead of storing them in the database. They are part of the store's product vocabulary, not data an operator edits.
The map is a list of groups instead of pairs. One entry links laptop ↔ notebook ↔ portable computer, where pairs would need a row for every combination. Litestore builds an index from each word to its group ahead of time. Expansion works in both directions and word by word, so red laptop expands to red notebook.
Litestore returns the expansions as separate terms instead of rewriting the query. A synonym match can then score as synonym at 0.5, instead of counting as a name match.
As with fuzzy matching, the source comments list the replacements the authors suggest. You could store synonyms in the database, generate expansions with machine learning, or match by meaning with embeddings.
Search changes
When you tune search, don't stop at checking whether your query returns a product. Ask this instead: which source did that match come from, and is that source worth what it currently scores?
Related
SEO
Litestore builds page metadata, structured data, the sitemap and slug redirects from the same records.
Caching
Litestore tags cached reads from CACHE_TAGS, the CRUD layer invalidates them on writes, and some paths are never cached.
Schema
The core Prisma models in schema.prisma follow shared conventions, and schema changes follow a few rules to stay safe.
Storefront pages
A storefront page is a database row with HTML content and an optional product feed, and the storefront routes render both with the store's design.
SEO
Litestore derives page metadata, structured data and the sitemap from the records it already has, and renamed URLs keep redirecting to the new address.