Search, filters and sensible defaults
Search is how a returning customer buys. They know they want atta, and every second spent browsing categories is friction. This lesson builds search and filters that hold up — and is honest about where the approach stops working.
State lives in the URL
From module 4, and it matters more here than anywhere:
/products?q=atta&category=staples&sort=name&page=2
Shareable, refreshable, and the Back button works. A customer sending a
search result to their spouse on WhatsApp is normal, and useState would send
them the unfiltered page.
It also means the server does the filtering, so the page stays a server component and stays indexable.
The search box
"use client";
import { useEffect, useState, useTransition } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useDebounced } from "@/hooks/use-debounced";
export function SearchBox() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const [query, setQuery] = useState(searchParams.get("q") ?? "");
const debounced = useDebounced(query, 300);
useEffect(() => {
const current = searchParams.get("q") ?? "";
if (debounced.trim() === current) return;
const params = new URLSearchParams(searchParams);
if (debounced.trim()) {
params.set("q", debounced.trim());
} else {
params.delete("q");
}
params.delete("page");
startTransition(() => {
router.replace(`${pathname}?${params}`, { scroll: false });
});
}, [debounced]);
return (
<div className="relative">
<label htmlFor="search" className="sr-only">
Search products
</label>
<input
id="search"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search for atta, dal, milk…"
className="w-full rounded-lg py-2.5 pl-4 pr-10 text-sm ring-1 ring-gray-300 focus:outline-none focus:ring-2 focus:ring-emerald-500"
/>
{isPending && (
<span
role="status"
aria-label="Searching"
className="absolute right-3 top-3 size-4 animate-spin rounded-full border-2 border-gray-300 border-t-emerald-600"
/>
)}
</div>
);
}
Six decisions worth naming.
Debounce 300ms, so "tomato" is one request rather than six.
The guard if (debounced.trim() === current) return; stops the effect
firing on mount and on every navigation, which would otherwise replace the URL
with itself and fight the Back button.
router.replace, not push, or every keystroke becomes a history entry.
scroll: false, or the page jumps to the top on each update.
params.delete("page"), or a customer on page 3 searches and lands on page
3 of two results.
useTransition gives you isPending, so the spinner appears during the
server round trip. Without it, the interface looks frozen on a slow connection.
This is a legitimate useEffect: synchronising React state with the URL, which
is outside React.
Filters
"use client";
export function CategoryFilter({ categories }: { categories: CategoryOption[] }) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const current = searchParams.get("category");
function select(slug: string | null) {
const params = new URLSearchParams(searchParams);
if (slug) {
params.set("category", slug);
} else {
params.delete("category");
}
params.delete("page");
router.push(`${pathname}?${params}`, { scroll: false });
}
return (
<div className="-mx-4 overflow-x-auto px-4">
<div className="flex gap-2 pb-1" role="group" aria-label="Filter by category">
<FilterChip active={!current} onClick={() => select(null)}>
All
</FilterChip>
{categories.map((category) => (
<FilterChip
key={category.slug}
active={current === category.slug}
onClick={() => select(category.slug)}
>
{category.name}
<span className="ml-1.5 text-xs opacity-70">{category.productCount}</span>
</FilterChip>
))}
</div>
</div>
);
}
router.push here, not replace. Choosing a category is a deliberate
navigation a customer may want to undo with Back — unlike a keystroke.
Counts next to each category, from the API's _count. They tell a customer
whether a filter is worth tapping.
Horizontally scrollable on a phone. Eight categories will not fit at 320px, and wrapping them onto three lines pushes the products off the screen. The negative margin plus padding lets it scroll edge to edge.
The empty state is part of search
export function ProductGrid({ products, query }: { products: ProductSummary[]; query?: string }) {
if (products.length === 0) {
return (
<EmptyState
title={query ? `No results for "${query}"` : "No products match these filters"}
description={
query
? "Check the spelling, or try a shorter word — searching for “atta” finds more than “aashirvaad atta 5kg”."
: "Try removing a filter."
}
action={
<Button variant="secondary" onClick={clearFilters}>
Clear filters
</Button>
}
/>
);
}
// ...
}
A no-results page is the most common search outcome for a small catalogue, and it is the one most often left as a blank grid. Saying what to try next — and offering the way out — is the difference between a customer trying again and leaving.
The advice is specific for a reason: people search the way they read a packet, and a six-hundred-product catalogue will not match a full product name.
Where this approach stops working
Being honest about the ceiling, because it arrives sooner than people expect.
contains with mode: "insensitive" gives you substring matching. It does not
give you:
Typo tolerance. "tomatos" finds nothing. A real shop loses those sales silently — the customer assumes you do not stock it.
Stemming. "egg" does not match "eggs" unless the substring happens to align. Here it does; "potatoes" and "potato" behave differently.
Relevance ranking. A match in the name and a match in the description are equal, so a product mentioning atta in passing can outrank actual atta.
Multi-word sense. "toor dal 1kg" matches nothing, because no single field contains that whole string.
That last one is the first you will hit, and it is worth fixing now:
if (query.q?.trim()) {
const terms = query.q.trim().split(/\s+/).slice(0, 5);
where.AND = terms.map((term) => ({
OR: [
{ name: { contains: term, mode: "insensitive" } },
{ brand: { contains: term, mode: "insensitive" } },
{ description: { contains: term, mode: "insensitive" } },
],
}));
}
Every term must match somewhere, so "toor dal" finds the product and
"toor xyz" does not. .slice(0, 5) caps how many conditions a caller can force
you to evaluate.
Beyond that you want PostgreSQL full-text search with a GIN index, and beyond
that a search engine. Knowing where the boundary is matters more than
crossing it — for six hundred products, multi-term contains is genuinely
enough.
Sensible defaults
Three that cost nothing and are frequently missed.
An empty q must not filter. ?q= would otherwise match every product
containing an empty string — which is all of them, so it happens to work, until
you add relevance sorting and it does not.
Cap the query length. @MaxLength(100) on the DTO. There is no legitimate
200-character product search.
Default to something useful. With no filters, show everything sorted by name. Not an empty page asking the customer to choose a category first.
Search should be findable
// apps/web/src/app/(shop)/layout.tsx
<header>
<div className="lg:hidden">
<SearchBox />
</div>
{/* ... */}
</header>
On a phone, search belongs in the header on every page, not only on the products page. A customer who knows what they want should not have to navigate to a listing first.
Check your work
Why search state belongs in the URL: it is shareable, survives a refresh, works with Back, and keeps the page a server component.
Why the effect needs a guard: without it, it fires on mount and on every navigation, replacing the URL with itself and fighting the Back button.
Why replace for search but push for filters: a keystroke should not be a
history entry; choosing a category is a deliberate navigation worth undoing.
Why params.delete("page"): otherwise a customer on page 3 searches and
lands on page 3 of two results.
What useTransition gives you: isPending, so the interface shows activity
during the server round trip instead of looking frozen.
The four limits of contains search: no typo tolerance, no stemming, no
relevance ranking, and no multi-word matching across fields.
How multi-term search is fixed: split on whitespace and require every term to match somewhere, with a cap on the number of terms.
Why an empty q must be treated as absent: it matches everything, which
works by accident until it does not.
Practice
- Build the search box with debouncing. Confirm one request per pause, not per keystroke.
- Remove the guard in the effect and watch the Back button stop working.
- Use
pushinstead ofreplaceand try navigating back after typing a word. - Search on page 3 and confirm you land on page 1.
- Throttle to Slow 3G and confirm the pending spinner appears.
- Search "toor dal" with single-term matching and watch it fail. Implement multi-term and confirm it works.
- Search "tomatos" and confirm it finds nothing. Write down what you would do about it in a real shop.
- Send
?q=empty and confirm it does not filter. - Search for something with no results. Confirm the empty state names the query and offers a way out.
- At 320px, confirm the category filter scrolls horizontally rather than wrapping.
Next: images, and why they are the slowest thing on your page.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship