Storefront Search Without a Search Service
111 products, no search index. The predicate, the rule that stops results looking broken, and the bundle trade we took on one page only.
A storefront with a few thousand products does not need a search service. A pure predicate over an in-memory array returns in under a millisecond, has no index to keep in sync, no monthly bill, and no second source of truth that can disagree with your database. The interesting decisions are not in the matching — they are in which fields you're allowed to match on and where the product data lives at runtime.
This catalog holds 111 products, 110 of them available, across 116 ready framework editions. Everything below is the filtering and search code that actually ships on this site, including the rule that made results stop looking broken and the bundle trade we accepted on one page and refused on another.
Start with the predicate
Filtering is a function from products and criteria to products. Ours is nine lines:
/** Pure AND-composed catalog filter. A framework matches if ANY edition has it. */
export function filterTemplates(
products: TemplateProduct[],
f: TemplateFilters,
): TemplateProduct[] {
return products.filter(
(p) =>
(!f.category || p.category === f.category) &&
(!f.framework || p.editions.some((e) => e.framework === f.framework)) &&
(!f.status || p.status === f.status) &&
(!f.pricing || p.pricing === f.pricing),
);
}
Two properties are worth stealing.
Absent means "don't care", not "match nothing". Every clause short-circuits on a falsy criterion, so an empty filter object returns everything and criteria compose without a builder. Adding a facet is one line, and no call site changes.
The framework clause is .some(), not ===. A product here is sold once and grants every framework edition of itself, so the unit a shopper filters is the product, while framework is a property of its editions. Filtering on "Next.js" has to mean "has a Next.js edition," not "is a Next.js thing." Getting that wrong silently hides multi-edition products from the exact people looking for them — and it is the kind of bug a search service would happily index for you at whatever granularity you fed it.
Because it is pure, it is tested as a function rather than through the UI, in src/lib/__tests__/filterTemplates.test.ts. No render, no DOM, no fixtures beyond a product array.
The rule that fixes "why did this match?"
Text search is where most storefronts get sloppy: match against everything, on the theory that more recall is more helpful. We match against strictly less, and the comment on our dashboard search says why:
/**
* Search matches only the fields the card actually displays (name
* + tagline), never the longer `description`, so every match is visibly
* self-explanatory.
*/
export function matchesProductFilter(
product: ProductDownloadGroupData,
activeCategory: ProductCategory | "all",
searchText: string,
): boolean {
if (product.category === null) return true;
if (activeCategory !== "all" && product.category !== activeCategory) return false;
const query = searchText.trim().toLowerCase();
if (query === "") return true;
const nameMatch = product.productName.toLowerCase().includes(query);
const taglineMatch = (product.tagline ?? "").toLowerCase().includes(query);
return nameMatch || taglineMatch;
}
Search the long description too and you get results where the query appears nowhere on the card. To the user that is not extra recall — it is a bug. They typed "invoice", they got a card about sneakers, and the word only occurs in paragraph four of a description they cannot see. Confidence in the search box drops immediately, and it does not come back.
So: only match on fields the result renders. If you want a field to be searchable, put it on the card. If it can't go on the card, it shouldn't be searchable. This costs some recall and buys the thing that matters more, which is that every result explains itself.
The rest of that function is small and deliberate too. if (query === "") return true means an empty box is not a filter. The category === null escape hatch lets a non-template row (our backend bundle) pass every filter rather than be silently hidden by a facet that doesn't apply to it — every catalog eventually grows a row that isn't quite the same kind of thing as the others, and it needs an explicit answer rather than an accidental one.
Where the data lives is the real performance question
filterTemplates runs in a "use client" component that imports the catalog directly:
"use client";
import { catalog } from "@/data/catalog";
import { filterTemplates, type TemplateFilters } from "@/lib/filterTemplates";
export default function TemplatesExplorer() {
const [filters, setFilters] = useState<TemplateFilters>({});
const results = filterTemplates(catalog, filters);
// ...
}
That ships the whole catalog module — nearly 8,000 lines — to the browser. On /templates we accept it, because that page is the catalog: every product is a potential result, filtering is instant with no network round trip, and the data would have to arrive anyway.
We refused the identical trade one component over. The header's saved-templates side-sheet renders on every page, and resolving a saved slug to a name and thumbnail would have pulled the same module into the global client bundle. Instead the wishlist stores denormalized entries — slug, name, image, the three fields the UI paints:
Resolving a slug to a name and thumbnail would mean importing
src/data/catalog.ts— a ~7k-line data module — into the global client bundle, which would cost the Lighthouse performance score the site currently holds.
Same data, same lookup, opposite answer, because the page that needs all of it is one page and the component that needs three fields is on all of them. The stale-label trade is stated in that file and accepted: a renamed product shows an old label in the saved list until it is re-saved.
The general rule: an in-memory catalog is free on the page that is about the catalog and expensive everywhere else. Measure it as a bundle question, not a search question.
Component state or the URL?
Our explorer holds filters in useState. That makes filters ephemeral — not shareable, not linkable, not in the back-stack.
The alternative is searchParams, which gives shareable URLs and server-side filtering, and brings a crawl problem with it: every facet combination becomes a URL, and a crawler will find all of them. That is the failure mode that once put 38 of our product pages in Search Console's "Discovered — currently not indexed", and the URL-driven filtering post covers which facet URLs to index and which to keep out of the index entirely.
Pick by whether a filtered view is a destination. A shopper narrowing to "size 10 running shoes" may well want to send that link to someone; a buyer poking at four facet chips on a catalog page does not. Ours is the second, so it stays in component state.
When you should buy a search service
Client-side search runs out in specific, recognizable ways. Reach for a real engine when you need:
- Typo tolerance.
includes()will never match "sneekers". Fuzzy matching is genuinely hard to do well. - Stemming and synonyms. "running shoe" ↔ "runners" ↔ "trainers", and everything about non-English morphology.
- Relevance ranking. Our predicate returns catalog order. Past a page of results, order matters more than membership.
- Scale past memory. Somewhere in the low tens of thousands of items, shipping the data stops being defensible; that threshold arrives sooner if each record is large.
- Search analytics. What people typed and got nothing for is often the most valuable product feedback you have.
Below that line, a service is a synchronization problem you volunteered for: an index that can drift from your database, a key to rotate, an outage that takes your storefront's search down while the site is up.
Mistakes table
| Mistake | Symptom | Fix |
|---|---|---|
| Searching fields the card doesn't show | Results users read as broken | Match only rendered fields |
=== on a property that lives on child records | Multi-variant products vanish from their own facet | .some() over the children |
| Empty criteria treated as "match nothing" | Blank search returns zero results | Falsy criterion short-circuits to "don't care" |
| Importing the catalog into a global component | Every page pays for one side-sheet | Denormalize the fields the UI paints |
| Filtering inside the component | Untestable without a render | Pure function, unit-tested |
| Every facet in the URL, unguarded | Crawl budget spent on facet permutations | Decide per facet what is indexable |
| Not lowercasing both sides | "Drift" fails to match "drift" | Normalize query and field together |
FAQ
Do I need to debounce the input? Not for an in-memory filter over a few thousand items — it completes between keystrokes, and a debounce only adds latency you can feel. Debounce when the keystroke costs a network request.
Should search run on the server instead? If the data already needs to reach the client for rendering, filtering it there is free and instant. Move search to the server when the dataset is too large to ship, when results depend on the viewer's permissions, or when you want the query in your logs.
How do I keep a category count in sync with the results? Derive it from the same predicate rather than storing it. Any count that is computed separately from the list it labels will eventually disagree with it, usually in the screenshot someone puts in a bug report.
Is includes() really enough?
For name-and-tagline matching over a curated catalog, yes. Products here are named and described by hand; there is no user-generated text, no OCR, no long tail of misspellings in the corpus. The moment your catalog is machine-generated or user-supplied, the answer changes.
Where this shows up in a template
Multi-department storefronts are where filtering stops being decorative — a catalog split across categories, sizes and price bands needs the facet composition above on day one. The templates below ship exactly that shape, with the product grid and filter UI already wired.
