Design a search results page
The shop page you land on after typing "running shoes": a query, faceted filters, sorting, a results grid and pages. The hard parts are one search state that lives in the URL, facet counts that don't make options vanish, one cancellable request per change, a grid that never blanks or jumps, honest zero results, and pages Google can index without crawling a billion filter combinations.
The question
Design the search results page for our marketplace. A shopper types "running shoes", lands on a grid of products, narrows by brand, size, price and rating, sorts by price, and moves through pages. The catalog has 4 million products.
Walk me through how you hold the search state, what the URL looks like, how filter counts work, how you fetch and render results, what happens when nothing matches, and how the page stays fast, indexable and accessible.
Real-life examples: Amazon search results, Etsy search, Zalando category and search pages, Algolia InstantSearch e-commerce demo, Best Buy search
Requirements: what to ask before drawing a grid
A search results page (often shortened to SRP) is the page a shopper sees after searching: a box with their words, a column of filters, a sort menu, a grid of products and a way to reach more pages. Amazon, Etsy and Zalando all build their shops around it. On many shops it is the page with the most traffic and the most revenue.
It looks like a form plus a list. It is really a small state machine that four parties read: the shopper, the browser history, the search backend and Google.
Several parts have their own guide here, linked rather than repeated. The typeahead box is the autocomplete guide, the page buttons are the pagination control guide and endless lists are the infinite scroll guide. Date and guest pickers and a list synced to a map are in the Airbnb guide; a slow, streaming search with filters is in the travel booking guide. This guide covers the page as a whole: the search state, the URL, the facets, the fetching and the rendering.
Start with questions. The middle column is what this guide assumes.
| Question | Answer we assume | What it changes |
|---|---|---|
| How big is the catalog? | 4 million products, 30,000 brands. | Filtering and counting happen on the server. The client never holds the catalog. |
| Which filters? | Category tree, brand and size (multi-select), price range, rating, "In stock" and "Free delivery" switches. | Four kinds of filter, each with its own URL encoding and UI. |
| Who searches, on what? | 70 percent phones, many on slow 4G. | A filter drawer on mobile, small payloads, a fast first paint. |
| Must results be indexable? | Yes. Category pages bring most organic traffic. | Server rendering of the first page, canonical URLs (one official address per page), rules for which filter pages Google may crawl. |
| Search as you type or on Enter? | On Enter in the header box; filters apply instantly. | How many requests one shopper makes, and when history entries are written. |
| Pages or infinite scroll? | Numbered pages, 48 results each. | The page is part of the state and the URL. Infinite scroll is a follow-up. |
| What does the business measure? | Searches with no results, click position, add to cart from search. | Analytics events tied to a search id. |
Functional requirements
- Show results for a query, with a total ("1,284 results") and 48 products per page.
- Filter by a category tree, multi-select facets (brand, size, color), a price range, a minimum rating and yes/no switches.
- Show a count next to every facet option, and keep options visible after you pick one.
- Show selected filters as removable chips, with "Clear all".
- Sort by relevance, price low to high, price high to low, newest and rating.
- Every state is a URL: reload, Back, Forward and share all restore the exact page.
- When nothing matches, explain why and offer a way out (a spelling fix, or filters to drop).
- Highlight the words that matched in product titles.
Non-functional requirements
- First load: Largest Contentful Paint (the moment the biggest image or text block is painted, LCP) under 2.5 seconds on a mid-range phone on 4G.
- A filter click responds within 200 ms: Interaction to Next Paint (INP, the delay from a click to the next frame) stays in the good range.
- No layout shift when results load: Cumulative Layout Shift (CLS) under 0.1.
- The backend answers a search in about 150 ms at the 50th percentile and 600 ms at the 99th; the page must look fine while waiting.
- The page never shows results for an old state after a newer one was asked for.
- Fully usable with a keyboard and a screen reader; Web Content Accessibility Guidelines (WCAG) 2.2, level AA.
- Category and a small set of filter pages are indexable; the endless rest is not.
Out of scope, and why
- The ranking algorithm and the search index: that's backend work. We only send the state and render what comes back.
- The typeahead dropdown under the search box: covered in the autocomplete guide.
- The product page, cart and checkout: covered in the Amazon and shopping cart guides.
- Personalized ranking and ads in the grid: we leave room for an ad slot but don't design the ad system.
Check your understanding · Exercise 1
Which answers would change the design the most? Select all that apply.
What makes it hard
Before choosing anything, name the forces. Every later decision traces back to one of these six.
- Many inputs, one answer. The query, eight filter groups, the sort and the page all shape one result set. Change any of them and the results, the total and every facet count change too. If each control keeps its own state, they drift apart.
- The URL is a public API. People bookmark it, paste it in chat, open it in new tabs and press Back. Google crawls it. Ads and emails link to it. Its format must be stable for years, so it deserves the same care as a REST endpoint.
- Counts are subtle. "Nike (312)" must tell the truth for the current state, yet picking Nike must not hide Adidas. The naive count gets this wrong, and the UI starts to feel broken.
- Clicks come fast, answers come slow. A shopper ticks three brands in two seconds, or drags a price slider through 40 values. Answers take 150 to 600 ms and can arrive out of order. Showing an older answer after a newer one is a real bug, not a theory.
- Image heavy and on phones. 48 product photos per page, 70 percent of traffic on phones. The first row decides LCP, and late images or late counts can shove the grid around (CLS).
- Infinite combinations. 30,000 brands times 40 sizes times 12 colors times ranges is more URLs than Google will ever crawl. Left alone, crawlers spend their whole budget on filter pages and miss new products.
Notice what is not on the list: drawing a grid of cards. CSS grid does that in five lines. Interviewers want to hear about the six forces above.
Think first
A teammate proposes keeping each filter in its own useState inside its own component, and building the request from all of them. What breaks first?
Back and share break first: none of that state is in the URL, so reload loses it and a pasted link shows the unfiltered page.
Next, changes that should be atomic are not. "Clear all" must reset eight components and the page number in one step. With eight separate states you get eight renders and possibly eight requests, and some of those requests ask for half-cleared states.
The search state: one object, one owner
The first decision is where the search state lives. The search state is everything that decides which results you see: the query, the filters, the sort and the page. Not the hover state of a card, not whether the drawer is open: only what changes the answer.
Decision Who owns the search state?
What we pick: The URL is the source of truth. A search state is exactly the kind of state the url-as-state lesson describes: shareable, bookmarkable, and meaningful on reload. The one thing the URL is bad at, values that change 60 times a second, we handle with a small local draft (the slider position while dragging) that commits to the URL when the drag settles.
toUrl, and the new URL is parsed back into the state everyone reads.The shape of the state
Model the four kinds of filter explicitly. Each kind has different rules for the UI, the URL and the backend, so a single Record of string to string would hide bugs.
type Sort = "relevance" | "price_asc" | "price_desc" | "newest" | "rating";
type SearchState = {
q: string; // "" means browse the category
category: string[]; // path: ["shoes", "running"]
facets: Record<string, string[]>; // brand: ["adidas", "nike"], size: ["10"]
ranges: Record<string, { min?: number; max?: number }>; // price
flags: string[]; // ["in_stock", "free_delivery"]
rating?: number; // 4 means "4 stars and up"
sort: Sort;
page: number; // 1-based
};
const DEFAULTS: SearchState = {
q: "", category: [], facets: {}, ranges: {}, flags: [],
sort: "relevance", page: 1,
};
- Multi-select facets (brand, size, color): any of the chosen values matches. "Nike or Adidas". Stored as sorted arrays.
- Ranges (price): a min, a max, or both. Either end can be open.
- Flags (in stock, free delivery): on or off. Off is the default and never appears in the URL.
- Hierarchical category: a path from the root. Choosing "Running" under "Shoes" implies "Shoes"; you never store both as separate selections.
Changes are named transitions
Don't let components build states by hand. Give them a few named transitions, each a pure function from state to state. The rules that matter live in one place: any change to the query, filters or sort resets page to 1, because page 7 of a new result set is meaningless and often empty.
function toggleFacet(s: SearchState, key: string, value: string): SearchState {
const current = s.facets[key] ?? [];
const next = current.includes(value)
? current.filter((v) => v !== value)
: [...current, value].sort();
const facets = { ...s.facets, [key]: next };
if (next.length === 0) delete facets[key];
return { ...s, facets, page: 1 };
}
const clearAll = (s: SearchState): SearchState => ({ ...DEFAULTS, q: s.q, sort: s.sort });
const setPage = (s: SearchState, page: number): SearchState => ({ ...s, page });
Two details pay off later. Sorting the values on insert means "Nike then Adidas" and "Adidas then Nike" produce the same state, so the same URL and the same cache key. And clearAll keeps the query and sort: shoppers who clear filters still want their words.
Check your understanding · Exercise 2
A shopper is on page 5 of "running shoes". Which changes should reset the page to 1? Select all that apply.
The URL: a stable, readable, canonical encoding
If the URL is the truth, its format is a contract. Marketing emails, ads, bookmarks and Google's index will hold old URLs for years. Changing the format later means redirects forever. So design it like an API.
Decision How do we encode the state in the URL?
What we pick: Short keys with comma lists, and the category as a path. It is what most large shops converge on, and the one-state-one-string property is worth the 40 lines of parser. Brand and size values are slugs from the catalog (new-balance, not New Balance), so commas never appear inside a value.
Serializing: defaults out, order fixed
const enc = encodeURIComponent;
function toUrl(s: SearchState): string {
const parts: string[] = [];
if (s.q) parts.push(`q=${enc(s.q)}`);
for (const key of Object.keys(s.facets).sort()) {
parts.push(`${key}=${s.facets[key].map(enc).join(",")}`);
}
for (const key of Object.keys(s.ranges).sort()) {
const { min, max } = s.ranges[key];
if (min != null || max != null) parts.push(`${key}=${min ?? ""}-${max ?? ""}`);
}
if (s.flags.length) parts.push(`only=${[...s.flags].sort().join(",")}`);
if (s.rating) parts.push(`rating=${s.rating}`);
if (s.sort !== "relevance") parts.push(`sort=${s.sort}`);
if (s.page > 1) parts.push(`page=${s.page}`);
const path = "/" + ["s", ...s.category].join("/");
return parts.length ? `${path}?${parts.join("&")}` : path;
}
Why not URLSearchParams.toString()? It percent-encodes the comma as %2C, so brand=adidas,nike becomes brand=adidas%2Cnike. It still works, but it is no longer readable.
Encoding each value with encodeURIComponent and joining with a literal comma keeps the URL clean. For reading, URLSearchParams is perfect: it decodes %2C, %20 and + alike, so hand-typed and old URLs still parse.
Parsing: forgiving in, strict out
The parser meets URLs written by people, by old versions of the site and by bots. It must never throw. Drop what it doesn't understand, clamp what is out of range, and let the serializer write the clean version.
- Unknown keys (
utm_source,gclid) are ignored by search but kept on the URL for analytics until the first in-page change. - Unknown facet values (
brand=nikee) are dropped; the brand list from the server is the allowlist. page=0,page=-3andpage=abcbecome 1.page=900when there are 27 pages renders the empty state with a link to page 1 and, from the server, a404status (Google asks for that on pagination URLs that don't exist), never a crash.price=100-50is swapped to50-100;price=-80means "up to 80".- An unknown
sortfalls back to relevance.
Then compare. If toUrl(parse(url)) differs from the URL that arrived, the URL was not canonical: on the server, answer with a 301 redirect to the clean form (or set it as the canonical link); on the client, fix it with replaceState so the bad form doesn't stay in history.
Push or replace: what deserves a Back step
Every navigation either adds a history entry (history.pushState, router.push) or overwrites the current one (history.replaceState, router.replace). The rule: push when the shopper would expect Back to undo it; replace when it is noise.
| Change | History | Why |
|---|---|---|
| Submit a new query | push | A new search is a new place. Back should return to the old one. |
| Tick or untick a facet, pick a sort | push | Shoppers press Back to undo a filter, so each filter change gets its own entry. |
| Go to page 3 | push | Back from page 3 to page 2 is what everyone expects. |
| Price slider while dragging | neither | Local draft only; the URL updates once when the drag ends. |
| Price slider drag ends | push | One entry per settled range, not 40. |
| Typing, if search as you type | replace | One entry per search session, not per letter. |
| Fixing a non-canonical URL | replace | The broken form should not be reachable with Back. |
Back has one more requirement: the previous state must render from cache in the same frame. If it shows a spinner first, the browser restores the scroll position against a short page and the shopper lands at the top. Cached results (chapter on fetching) make Back instant and keep the scroll where it was.
Check your understanding · Exercise 3
Which URL designs cause real problems? Select all that apply.
Faceted filtering: counts that don't lie and options that don't vanish
A facet is a filter group built from the results themselves: the brands, sizes and colors that appear in the current matches, each with a count. "Brand: Nike (312), Adidas (204), Asics (97)". The counts are what make facets useful: they tell the shopper what each click will give them before they click.
The naive count and why it breaks
The obvious way: run the search with every selected filter, then count brand, size and color over the matches. Try it: "running shoes" matches 1,284 products, and the shopper ticks Nike. Now the matches are 312 Nike shoes, and counting brands over them gives Nike (312) and nothing else.
Adidas has vanished. The shopper wanted "Nike or Adidas" and now has no way to add Adidas except to untick Nike first.
The fix is disjunctive faceting. Within one group, values are joined with OR (disjunction); across groups, with AND. So the counts for a group must be computed with every filter applied except that group's own selection.
Brand counts ignore the brand filter but respect size and price. Size counts ignore the size filter but respect brand and price.
| Group | Filters used to count it | Result after ticking Nike and size 10 |
|---|---|---|
| Brand | query, size 10 | Nike (41), Adidas (33), Asics (12): all still visible |
| Size | query, brand Nike | 9 (38), 10 (41), 11 (35): pick another size freely |
| Price stats | query, brand Nike, size 10 | min 45, max 220 for the slider ends |
| Total | everything | 41 results |
Read a disjunctive count as "what this option matches given everything else you chose". For a single-valued attribute like brand, ticking Adidas (33) next to Nike (41) gives exactly 74 results. For a multi-valued attribute like color, where one shoe can be both black and white, the sum overcounts, so never show a predicted total built by adding counts.
This is a backend job, but the frontend must ask for it and know its cost. With Elasticsearch, the selected facets go in a post_filter, which narrows the hits but not the aggregations (the counts). Each group then gets its own filtered aggregation that applies every other group's selection but leaves out its own.
Algolia's InstantSearch reaches the same counts differently: for each disjunctive facet with a selection, it sends one extra query without that facet's filter, batched with the main query in one multi-query request. Either way, the cost grows with the number of groups that have a selection, which is one reason to keep the number of multi-select groups small. The travel booking guide applies the same counting rule to flight filters such as stops and airlines.
14 results
Each kind of filter, done right
- Multi-select lists use real checkboxes. Selected options stay pinned at the top of their group even if their count drops to 0, so the shopper can always untick them.
- Zero-count options: in short fixed lists (sizes, rating), show them disabled with a 0, so the list doesn't reshuffle and the shopper learns that size 13 exists but not here. In long lists (30,000 brands), hide them; nobody needs 29,900 zeros.
- Ranges get two number inputs plus an optional slider. The server returns the min and max of the current matches, so the slider ends make sense. Typing
50must not fire a request until the shopper stops typing or leaves the field. - Flags (In stock, Free delivery) are checkboxes too, not toggle switches: a switch suggests an instant setting change, while these are filters like any other.
- Hierarchical categories show the path to the current node (Shoes, then Running) and the children of the current node with counts. Clicking a child drills down; clicking an ancestor goes up. It is a single path in the URL, never a set of independent ticks.
Chips and Clear all
Above the grid, every active filter appears as a chip: a small button with a label and a remove icon, such as "Brand: Nike". Chips answer "why am I seeing this?" at a glance, and on mobile they are the only visible trace of filters hidden in the drawer. Each chip is a button whose accessible name says what it does ("Remove filter Brand: Nike"); a final "Clear all" button runs clearAll. One click, one push, one request.
Long facet lists
Show the top 8 values by count, then a "Show more" button that expands to the top 50 from the same response. Past that, add a small search box inside the facet: "Search brands". Typing calls a facet value search endpoint (Algolia calls it search for facet values) that matches brand names within the current results, with counts, debounced by about 200 ms. The search-filters problem goes deeper on these controls.
Mobile: instant apply or an Apply button?
Decision On a phone, filters live in a full-screen drawer. When do changes apply?
What we pick: Instant apply on desktop, where the grid is visible next to the filters. Draft plus Apply on mobile, with the live result count on the button. The draft uses the same SearchState type and the same transitions, so no filter logic is duplicated: only the commit differs.
Check your understanding · Exercise 4
Shoes matching "trail": Nike 40, Salomon 25, Hoka 15. Size 10 matches 30 of them. The shopper ticks Salomon. With disjunctive counts, what is true? Select all that apply.
Fetching: one request per state, never a stale answer
Every committed state needs three things from the server: the page of results, the total, and the facet counts. Ask for all three in one request. Three separate calls would triple the round trips and could disagree with each other if the index changes between them.
Server or client filtering?
Decision Where do filtering, counting and sorting run?
What we pick: Server per state. With 4 million products there is no choice. Client filtering is right for a store with a few hundred items, a docs site, or a settings page; say that out loud, because it shows you picked on purpose. The rest of this chapter makes the server path feel nearly as fast.
Races: the answer that arrives late
On "running shoes", the shopper ticks Nike (request A), then quickly Adidas (request B). B is a query the engine has cached and returns in 120 ms; A hits a cold shard and returns in 700 ms. A naive useEffect that sets results when a response arrives shows B's answer, then overwrites it with A's.
The chips say "Nike, Adidas"; the grid shows only Nike. This is the bug the request-races lesson is about.
AbortController saves work, and the key check makes the order of answers irrelevant: only the answer for the URL on screen is ever rendered.Two defenses, and you want both. First, abort: every request gets an AbortController, and starting a new one aborts the previous. The browser stops reading the response, and on HTTP/2 it can cancel just that stream, so a server that watches for cancellation can stop work too.
Second, check identity: a response is applied only if its key equals the current URL's key. Abort saves bandwidth; the key check is what guarantees correctness, because an abort can lose the race with a response that was already parsed.
function useSearch(state: SearchState) {
const key = toUrl(state); // canonical: one state, one key
return useQuery({
queryKey: ["search", key],
queryFn: async ({ signal }) => {
const res = await fetch(`/api/search${key.slice(2)}`, { signal });
if (!res.ok) throw new SearchError(res.status);
return (await res.json()) as SearchResponse;
},
placeholderData: keepPreviousData, // keep old grid while loading
staleTime: 60_000, // Back within a minute: no refetch
gcTime: 5 * 60_000,
});
}
A server state library (see the server-state-libraries lesson) gives you the key check, abort on key change (when the query function uses signal), deduplication and the cache in one place. Writing it by hand is fine in an interview, but say which guarantees you would otherwise get for free.
Debounce only what moves fast
Clicks are slow enough to fetch on every one; abort handles a burst. Continuous inputs are not. A price slider dragged from 50 to 200 fires about 40 input events.
Keep the thumb position in a local draft so it moves at 60 frames per second, and commit to the URL on change (pointer released) or after 300 ms without movement, whichever comes first. Price number fields commit on blur, Enter, or 500 ms after the last keystroke. The debounce-throttle lesson explains why debounce, not throttle, fits here: only the final value matters.
Cache recent states, prefetch the next page
- Cache by canonical key. Ticking Nike, unticking it, and pressing Back all revisit states you already fetched. With a 60-second fresh window those are instant and cost nothing. Keep about 20 states; at 30 to 60 KB each (JSON, no images) that is around 1 MB.
- Prefetch page 2 when the browser is idle after page 1 renders, and prefetch any page link on hover or focus. Most shoppers who page do go to the next one.
- Don't prefetch filters. There are too many, and each guess costs a full search on the backend.
- Keep the previous results on screen while a new state loads (
keepPreviousData), dimmed slightly with a thin progress bar. Blanking the grid for 300 ms on every click is the most common way these pages feel slow.
Check your understanding · Exercise 5
Which steps prevent a stale grid after quick filter clicks? Select all that apply.
Rendering the grid: stable, fast, never blank
The grid is 48 cards: an image, a title, a price, a rating and an "Add to cart" button. Three questions decide whether it feels good: what the shopper sees while waiting, whether anything jumps, and how quickly the first row's images appear.
Decision What does the grid show while a new state loads?
What we pick: Keep previous results for every change after the first. Use layout-matching skeletons only when there is nothing to keep: a client-side navigation into search from another page with an empty cache. The first visit is server rendered, so it shows real cards, not skeletons.
Cards that don't jump
- Give every image box a fixed shape with
aspect-ratio: 3 / 4(orwidthandheightattributes), so the box is reserved before the image arrives. - Clamp titles to two lines with
-webkit-line-clamp: 2(works in every current browser despite the prefix), so a long title doesn't make one card taller than its row. - Reserve space for optional parts (a "Best seller" badge, a strike-through old price) even when empty, or put them in an overlay on the image.
- Facet counts and the total arrive with the results, so they change in the same frame; never load counts later in a second request that shifts the sidebar.
- Skeletons match the real card: same image ratio, two title lines, one price line. A skeleton of a different size is just a delayed layout shift.
Images: eager first row, lazy rest
The LCP element on a results page is almost always the first product image. On a phone with two columns, the first two to four images are in the first viewport; on desktop with four columns, four to eight. Load those eagerly, and give only the first one or two fetchpriority="high" so the browser fetches them before less useful resources.
Every image below the fold gets loading="lazy". Lazy loading the first row is a known LCP mistake: the browser waits for layout to learn that the image is in view before starting the download.
function CardImage({ hit, index, perRow }: { hit: Hit; index: number; perRow: number }) {
const aboveFold = index < perRow;
return (
<img
src={hit.image.url + "?w=320"}
srcSet={[240, 320, 480, 640].map((w) => `${hit.image.url}?w=${w} ${w}w`).join(", ")}
sizes="(min-width: 1024px) 22vw, 46vw"
width={hit.image.w} height={hit.image.h}
alt={hit.title}
loading={aboveFold ? "eager" : "lazy"}
fetchPriority={index < 2 ? "high" : "auto"}
decoding="async"
/>
);
}
An image CDN (content delivery network) resizes and converts to AVIF or WebP, so a phone gets a 240 to 320 pixel image of about 15 to 25 KB, not a 1,200 pixel original. The image-optimization lesson covers formats and sizes in depth.
Highlighting matched words safely
Showing the matched words in bold ("Men's trail running shoe") helps shoppers scan. Many search engines can return titles with highlight tags already inserted, as HTML: Algolia's _highlightResult wraps matches in em tags by default, and so does Elasticsearch's highlighter. The rest of the title comes back as the seller typed it, unescaped, unless you ask otherwise.
Putting that into the page with dangerouslySetInnerHTML means trusting every product title a seller ever typed. One title like Shoe followed by an image tag with an onerror handler is a stored cross-site scripting (XSS) attack on every shopper who searches for shoes (see the xss lesson). The libraries know this: Algolia's InstantSearch asks for placeholder tags and escapes the value before turning them into marks, and Elasticsearch has an html encoder option that escapes the snippet first.
Simpler still, ask the API for ranges instead: the plain title plus a sorted list of non-overlapping [start, end] offsets. The client builds text nodes and mark elements, and React escapes the text. Agree on the unit: JavaScript string offsets count UTF-16 code units, so an emoji or some accented letters in a title can shift ranges counted in bytes or code points.
type Range = [start: number, end: number];
function Highlight({ text, ranges }: { text: string; ranges: Range[] }) {
const out: React.ReactNode[] = [];
let at = 0;
for (const [s, e] of ranges) {
if (s > at) out.push(text.slice(at, s));
out.push(<mark key={s}>{text.slice(s, e)}</mark>);
at = e;
}
out.push(text.slice(at));
return <>{out}</>;
}
Check your understanding · Exercise 6
The grid has 4 columns on desktop and 2 on phones. Which image settings are right? Select all that apply.
The query, sorting, and when nothing matches
Search as you type, or on Enter?
Decision When the shopper edits the query on the results page, when do results update?
What we pick: Submit on Enter with typeahead suggestions, the pattern Amazon and Etsy use. Search as you type is a fine choice for small catalogs where the search is cheap and results are the suggestion list. Filters are different: a click is a complete intent, so they apply instantly.
Sorting and stable order
Relevance is the default sort, and its order comes from the engine. Price, newest and rating are simple field sorts, but they have a trap: ties. 300 shoes cost exactly 99.99, and if the engine breaks ties differently on each request, a shoe can appear on page 2 and again on page 3, while another never appears at all.
Always ask for a tie-breaker: sort by price, then by product id. The pagination lesson covers why offset pages drift when the data changes between requests, and when to switch to cursors.
Sorting by price also needs a rule for products with several prices (sizes priced differently) and for missing values. Pick the lowest variant price, put products with no price at the end, and show the price used on the card, so the order makes sense to the shopper.
Zero and low results
A page that says "No results" and nothing else loses the shopper. Every shop has some searches that return nothing, and each one is a shopper ready to buy who hit a wall. Two different causes need two different answers.
- The query matches nothing ("nikr air max"). Show "Did you mean nike air max?" with a link, or, if the engine is confident, show the corrected results directly with "Showing results for nike air max. Search instead for nikr air max". The suggestion comes from the server with the response.
- The query matches, the filters exclude everything ("running shoes", Nike, size 13, under 40). Never tell the shopper to start over. Offer the one-step fixes: "Remove Size: 13 to see 24 results", "Remove Price: under 40 to see 61 results". Each is a link to the state with that filter removed.
- Low results (1 to 5): show them, then a "Related products" row below from a relaxed query, clearly labelled so it doesn't look like a match.
The relaxation counts are cheap for the backend to compute when the total is 0: one count query per active filter, run in parallel, each with that one filter dropped. Return the top two or three in the same response as relaxations, so the empty state renders in one go. With disjunctive counts you sometimes already know the answer: if one brand is selected, the brand facet's other counts tell you what removing it would give.
{
"total": 0,
"hits": [],
"didYouMean": null,
"relaxations": [
{ "remove": { "facet": "size", "value": "13" }, "total": 24 },
{ "remove": { "range": "price" }, "total": 61 }
]
}
Check your understanding · Exercise 7
A shopper filters "rain jacket" by Brand: Patagonia, Color: Yellow, Size: XS and gets 0 results. What should the page do? Select all that apply.
Architecture: who owns what
With the decisions made, the pieces fall out. The rule that shapes every boundary: the URL owns the search state, the query cache owns server data, and components own only what they draw.
| Piece | Responsibility | Why the boundary is here |
|---|---|---|
Route /s/[...category] | Server renders page 1 from the URL; on the client, re-renders when the URL changes. | One entry point for first load, Back and shared links. |
searchState module | parse, toUrl and the transitions (toggleFacet, setRange, clearAll, setPage). Pure functions, no React. | Shared by server, client, tests and analytics. Easy to test with hundreds of URLs. |
useSearchState hook | Reads the parsed state from the URL; commit(next, mode) navigates with push or replace and scroll: false. | The only place that writes the URL, so history rules live in one spot. |
| Search client | Builds the API request from the state, fetches with signal, maps errors. | Swapping the backend (Elasticsearch to Algolia) touches one file. |
| Query cache | Results by canonical key: fresh window, previous data, prefetch, dedupe. | Server state, not UI state; a library does it better than a store. |
FacetPanel, FacetGroup, RangeFacet, CategoryTree | Draw facets from the response; call transitions on change. | Dumb about fetching, so the mobile drawer reuses them with a draft state. |
ActiveFilters (chips) | One chip per selected value, plus Clear all. | Derived purely from the state; no data of its own. |
ResultsGrid, ProductCard | Render hits; memoized by product id. | A filter change that returns the same products re-renders nothing. |
EmptyState | Did-you-mean and relaxations. | Its own component because it has its own analytics and copy. |
| Analytics | Search, click and empty-search events with a search id. | Fires from the hook and cards, never blocks rendering. |
Follow one click through the data flow. The shopper ticks Adidas, and FacetGroup calls commit(toggleFacet(state, "brand", "adidas"), "push"). The router pushes the new URL, the route re-renders, parse gives the new state, and useSearch computes the new key.
If the key is in the cache and fresh, the grid swaps in the same frame. If not, the previous grid stays dimmed, the search client calls /api/search through the CDN and the backend-for-frontend (BFF), and the response fills the cache and the grid together.
The BFF is a thin server layer owned by the frontend team. It turns the URL state into the search engine's query (with the disjunctive aggregations), trims the response to the fields cards need (about 600 bytes per hit instead of 4 KB of raw product data), and attaches prices for the shopper's region. The browser never talks to the search engine directly, so the engine's query language and keys stay private.
Check your understanding · Exercise 8
Why keep parse, toUrl and the transitions as pure functions outside React? Select all that apply.
Data model and API
Two questions settle the data model: where does each piece of data live, and what exactly crosses the network? The table applies the ownership rule from the architecture chapter to every piece of data, including the few that belong to neither the URL nor the cache.
Where each piece of data lives
| Data | Lives in | Why |
|---|---|---|
SearchState (query, filters, sort, page) | URL | Shareable, survives reload, drives Back. |
SearchResponse (hits, total, facets) | Query cache, keyed by canonical URL | Server data with a fresh window; reused on Back. |
| Drawer draft state | Component state in the drawer | Throwaway until Apply; must not touch history. |
| Slider position while dragging | Component state in RangeFacet | Changes 60 times a second; commits on release. |
| "Show more" expanded, facet search text | Component state | Pure UI; resetting it on reload is fine. |
| Recent searches | localStorage, capped at 10 | Personal, small, survives sessions; never in the URL. |
| Search id for analytics | Response field, kept with the cached response | Ties clicks to the search that produced them. |
type SearchResponse = {
searchId: string; // for analytics: clicks reference it
total: number;
totalIsExact: boolean; // false above 10,000: "10,000+ results"
page: number;
pageCount: number;
hits: Hit[]; // 48, trimmed to card fields
facets: Facet[]; // disjunctive counts, in display order
ranges: Record<string, { min: number; max: number }>;
categoryTree: CategoryNode; // ancestors plus children with counts
didYouMean?: { query: string; autoApplied: boolean };
relaxations?: { remove: FilterRef; total: number }[];
};
type Hit = {
id: string; title: string; titleHighlights: [number, number][];
url: string; price: { amount: number; currency: string };
rating?: number; image: { url: string; w: number; h: number };
};
type Facet = {
key: string; label: string; kind: "multi" | "flag";
values: { value: string; label: string; count: number; selected: boolean }[];
more: boolean; // more values exist past the top 50
};
toUrl(state). Anything that would be wrong in a shared link stays in component state.Endpoints
| Call | Purpose | Notes |
|---|---|---|
GET /api/search/shoes/running?q=trail&brand=adidas,nike&page=2 | Results, total, facets for one state. | Same query string as the page URL. Cacheable at the CDN for 60 seconds when no personal data is in it. |
GET /api/search-count/shoes/running?... | Only the total, for the drawer's "Show 312 results" button. | Skips hits and facets; a fraction of the cost. Its own path, so no category can collide with it. |
GET /api/facet-values?facet=brand&prefix=new&...state | Search inside a long facet. | Returns matching values with counts for the current state. |
POST /api/events (via sendBeacon) | Search, click and empty-search events. | Batched; never blocks navigation. |
Using the page's own query string for the API is a quiet win: the BFF reuses the exact parse module, and a CDN can cache popular states ("running shoes", page 1, no filters) for every shopper. Errors come back as { "error": { "code": "BAD_FILTER", "message": "..." } } with a 400 for unparseable input and a 503 with Retry-After when the engine is overloaded; the client shows the inline retry from the fetching chapter.
Think first
Why does the response have a totalIsExact flag?
Search engines often stop counting exactly past a limit to save time. Elasticsearch, for example, counts precisely up to 10,000 hits by default and then reports a lower bound. Showing "10,000 results" as if it were exact is wrong.
With the flag, the page shows "10,000+ results", and the pagination control (see the pagination control guide) caps the last page link instead of promising pages it can't reach.
SEO and analytics: pages Google can use, numbers the business can trust
Server render the first page
Category pages ("Running shoes") are often a shop's best organic landing pages. Render page 1 on the server: the route parses the URL, calls the BFF, and streams HTML with the real cards, facet counts and total. Crawlers get content without running JavaScript, and shoppers get a fast LCP because the first image's URL is in the HTML.
After hydration, every later change is client-side. The ssr lesson covers streaming and hydration costs; here the point is that one parse module serves both sides.
The crawl trap
Every facet option is a link, and every link leads to more links. 30,000 brands, 40 sizes, 12 colors and any price range produce more URLs than any crawler can visit. Google's guidance on faceted navigation names the two harms: crawlers fetch a huge number of useless filter URLs, and as a result they find new products more slowly. So decide on purpose which pages may be crawled, and close the rest.
Decision Which filtered pages may search engines crawl and index?
What we pick: A curated set. Category paths and a few hundred chosen combinations are indexable with clean paths and self-referencing canonicals. Every filter parameter (multi-select facets, ranges, flags) is disallowed in robots.txt, because Google calls rel="canonical" and rel="nofollow" generally less effective than blocking crawling. Sort only multiplies each page by five, so sorted URLs stay crawlable with a canonical that points to the unsorted page.
- Canonical links. Each crawlable page has a
link rel="canonical"to its clean form, which also strips tracking parameters such asutm_source./s/shoes?sort=price_ascpoints to/s/shoes, since sorting doesn't change the set. - Pagination. Page 2 is canonical to itself, not to page 1: it lists different products. Google no longer uses
rel="next"andrel="prev", so plain crawlable links between pages are what matter. - Empty filter pages. Google recommends a
404status for filter combinations with no results. Shoppers still see the helpful empty state in the body; only the status code changes. Server rendering makes this possible, since a client-rendered page always answers 200. - Blocked is not the same as noindex. A URL disallowed in
robots.txtis never fetched, so its canonical link and anynoindexon it are never read, and Google can still list the bare URL if other sites link to it. Usenoindexonly on pages crawlers may fetch. To remove a blocked URL that is already in the index, lift the block so crawlers can read itsnoindex. - Crawlable filter URLs follow Google's format rules. Google asks for the standard
&between parameters and a fixed filter order, because commas are hard for crawlers to read as separators. Here the comma lists only appear on blocked URLs; curated pages use clean paths. - Stable titles. "Nike running shoes | Shop" on the curated page; crawlable parameter pages (sorted views, tracking parameters) get a generic title and rely on their canonical.
Analytics: what to measure
Search analytics answers three questions: what people look for, whether they find it, and where in the list they click. Each search response carries a searchId; every later event references it, so clicks and purchases join back to the exact query and filters (the analytics-tracking lesson covers batching and delivery).
| Event | Fields | Answers |
|---|---|---|
search_view | searchId, q, filters, sort, page, total, latency ms, source (new or history) | Volume, latency, refinement patterns. |
search_empty | q, filters, didYouMean shown, relaxations shown | Missing stock, missing synonyms. |
result_click | searchId, productId, position (absolute, 1-based), page | Click-through rate by position; ranking quality. |
filter_change | searchId, facet, value, on or off | Which filters people use; which to promote. |
add_to_cart | searchId, productId, position | Revenue per search. |
Two traps. Position must be absolute: the third card on page 2 with 48 per page is position 51, not 3. And Back is not a new search: a state rendered from cache logs search_view with source: "history", or the dashboard counts every Back press as a search and inflates volume.
Check your understanding · Exercise 9
Which SEO choices fit this page? Select all that apply.
Accessibility and interaction speed
Structure a screen reader can follow
- A skip link as the first focusable element: "Skip to results". Without it, a keyboard user tabs through the header, the search box and up to 80 filter checkboxes before reaching the first product.
- Each facet group is a
fieldsetwith alegend("Brand"). The screen reader announces the group name when focus enters it, so "Nike, checkbox, not checked" has context. - Options are native checkboxes. The count goes in the label ("Nike, 312"); a disabled zero option says "Size 13, 0, unavailable" rather than vanishing silently.
- The price range is two labelled number inputs ("Minimum price", "Maximum price"). A slider is a bonus on top, never the only way in.
- Results are a list (
ulofli) under anh2such as "1,284 results for running shoes". Each card's title is the one link to the product; the whole card is clickable through CSS, not by nesting a button inside a link.
Announce results politely, once
A sighted shopper sees the grid change after a filter click. A screen reader user hears nothing unless you tell them. Put a visually hidden aria-live="polite" region near the results and write "41 results" into it when a committed state's results have rendered. Polite means it waits until the screen reader finishes the current sentence.
- Announce on settled results, not on every request. If three clicks fire three requests and two are aborted, announce once.
- Debounce the announcement itself by about 500 ms, so cached results that settle twice in quick succession (a tick, then an untick) produce one message, not two.
- Don't announce while the shopper types in a facet search box or drags a slider; wait for the commit.
- Include the change when it helps: "41 results, filtered by Nike and size 10".
- Keep the live region in the DOM from the first render. Regions added at the moment of the update are often not read.
Focus after each change
| Action | Where focus goes | Why |
|---|---|---|
| Tick a checkbox (desktop) | Stays on the checkbox. | The shopper is mid-choice; moving focus would throw them out of the group. |
| Remove a chip | The next chip, or Clear all, or the results heading if none are left. | The focused element disappeared; focus must not fall back to the top of the page. |
| Close the mobile drawer (Apply or close) | The "Filters" button that opened it. | The drawer is a modal dialog; returning focus to its trigger is the dialog pattern. |
| Go to another page | The results heading (tabindex="-1"). | The old cards are gone; the shopper should start reading the new page from its top. |
The mobile drawer is a modal dialog: the native dialog element opened with showModal() gives the focus trap, Escape to close and an inert background for free. The screen-readers lesson covers live regions and dialog behavior in more depth.
Fast filter clicks: INP
INP measures the time from a click to the next frame the browser paints. Good is 200 ms or less. A checkbox click that synchronously re-renders 48 cards, 8 facet groups and the chips on a mid-range phone can take 150 to 300 ms before the tick mark even appears. Three fixes, in order of payoff:
- Paint the tick first. Update the checkbox's own checked state right away, and run the navigation inside a transition (
startTransition; the Next.js App Router already does this forrouter.push), which React may interrupt and delay. The click paints in one cheap frame, and the heavy render follows. - Re-render only what changed.
ProductCardis memoized and receives the hit object from the cache, whose identity is stable for unchanged products. The dim-while-loading effect is one class on the grid container, not a prop on 48 cards. Facets re-render; cards don't. - Do less work per card. No per-card effects, no per-card observers (one shared
IntersectionObserveror native lazy loading), prices formatted once with a singleIntl.NumberFormatinstance.content-visibility: autoon rows below the fold lets the browser skip their layout and paint until they're near the viewport.
Measure, don't guess: the core-web-vitals lesson shows how to collect INP from real shoppers with attribution, so you know whether the slow interaction is the checkbox, the sort menu or the Add to cart button.
Check your understanding · Exercise 10
A screen reader user ticks Nike, then Adidas, quickly. Which behaviors are right? Select all that apply.
Running it in the interview
Forty-five minutes is not enough to say everything in this guide. Spend the time where the signal is: the state and the URL, facet counts, and races. Everything else gets a sentence and a promise to go deeper if asked.
| Minutes | What you do | What to draw |
|---|---|---|
| 0 to 5 | Scope questions: catalog size, filter kinds, SEO, devices, instant or Enter. | Nothing yet; write the assumptions in a corner. |
| 5 to 8 | Name the six forces from "What makes it hard". | The wireframe: header, facets, chips, sort, grid, pages. |
| 8 to 18 | The search state type, the URL format, push versus replace. | The state object and one example URL. |
| 18 to 26 | Disjunctive facet counts; chips; mobile drawer with Apply. | The counts table for Nike plus size 10. |
| 26 to 34 | One request per state, abort plus key check, cache, keep previous results. | The sequence diagram with the slow request. |
| 34 to 40 | Architecture and API response shape. | Boxes: URL, state module, query cache, BFF, components. |
| 40 to 45 | Pick two of: SEO crawl traps, zero results, accessibility, INP. | Whatever the interviewer leans toward. |
Follow-up questions to expect
Narrate trade-offs as you go: "I'm putting the state in the URL because shoppers share and press Back; the cost is a custom parser, and slider drags stay local." One sentence like that per decision is worth more than a perfect diagram. When the interviewer pushes on a detail, the questions below are the common ones.
Think first
"Product wants infinite scroll instead of pages. What changes?"
The state keeps a page number for the URL, but the grid now holds pages 1 to N. Use an infinite query keyed by the state without the page, append pages as a sentinel near the bottom becomes visible, and update the URL's page with replaceState so reload lands near where the shopper was.
Keep real paginated links in the HTML for crawlers, and consider a "Load more" button instead of automatic loading so the footer stays reachable. Past a few hundred cards, virtualize the grid. The infinite scroll guide covers the details.
Think first
"A price changes while the shopper is looking at the grid. Do you update it?"
Not live. Results are a snapshot, fresh for 60 seconds in the cache. The product page and the cart show the current price, and the cart is the authority at checkout.
Live price pushes into a 48-card grid would shift layout and re-sort a price-sorted list under the shopper's finger. If the business insists, show a small "Prices updated" notice with a refresh button, like a feed's new-posts pill.
Think first
"Search traffic is 5,000 requests per second at peak. How does the frontend help?"
Fewer requests per shopper: submit on Enter, debounced sliders, Apply in the mobile drawer, and a client cache that makes Back and undo free.
Cheaper requests: canonical URLs so the CDN can cache popular anonymous states for 30 to 60 seconds (page 1 of top queries is a large share of traffic), a count-only endpoint for the drawer, and trimmed hit payloads. Abort also lets the backend stop work on requests nobody will read.
Check your understanding · Exercise 11
You have 10 minutes left and haven't covered fetching. What is the strongest use of the time? Select all that apply.
Summary
- The search state is one typed object: query, category path, multi-select facets, ranges, flags, sort and page. Named transitions own the rules, like resetting the page on any filter change.
- The URL is the source of truth. Encode it readably and canonically: short keys, sorted comma lists, ranges as min-max, defaults omitted. One state, one string, which is also the cache key.
- Push history for changes a shopper would undo with Back (query, filters, sort, page); replace for noise (typing, canonical fixes); keep slider drags local until they settle.
- Facet counts are disjunctive: each group is counted with every filter except its own, so picking Nike never hides Adidas.
- One request per committed state returns hits, total and facets together. Abort the previous request and apply a response only if its key matches the current URL.
- Keep the previous results visible and dimmed while loading; reserve image space, eager-load the first row and lazy-load the rest.
- Zero results get a way out: did-you-mean for the query, and "remove this filter to see N results" for filters.
- Server render page 1, index category pages and a curated set of combinations, and block crawling of the endless filter URLs.
- Fieldsets and checkboxes, one polite result count announcement per settled change, and deliberate focus after chips, drawers and page changes.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- Managing crawling of faceted navigation URLs (Google Search Central) The crawl trap, robots.txt rules, and 404s for empty combinations.
- Block Search indexing with noindex (Google Search Central) Why noindex only works on pages crawlers are allowed to fetch.
- Pagination, incremental page loading, and their impact on Google Search Unique URLs per page and why page 1 is not the canonical for the rest.
- Filter search results: post_filter (Elasticsearch docs) How a search engine computes facet counts that ignore a group's own selection.
- History: pushState() method (MDN) Adding history entries; pair with replaceState.
- AbortController (MDN) Cancelling stale fetches.
- Paginated queries and keepPreviousData (TanStack Query) Keeping old results on screen while the next state loads.
- Optimize resource loading with the Fetch Priority API (web.dev) fetchpriority for the LCP image.
- Interaction to Next Paint (web.dev) What INP measures and the 200 ms threshold.
- ARIA live regions (MDN) Announcing result counts politely.
- Dialog (modal) pattern (WAI-ARIA APG) Focus handling for the mobile filter drawer.
Concepts behind it
- The URL as stateFilters, sort, page, tab, and selected item stored in the address bar instead of only in memory, so a view can be shared, bookmarked, refreshed, and walked back with the Back button: the cheapest state store the browser gives you.
- Request races and cancellationResponses can come back in any order, so a page can show the answer to an old question; learn to let only the latest answer reach the screen and to cancel the work nobody needs.
- Debounce and throttleTwo small timing tools that turn a flood of events (keystrokes, scrolls, resizes) into a few calls you can afford: debounce waits for a pause, throttle runs at a steady rate.
- Pagination: offset, cursor, and keysetWays to load a long list one piece at a time, and why the choice decides whether users see duplicates, miss items, or wait on slow deep pages when the list changes.
- Server-state librariesTanStack Query, SWR, RTK Query, and Apollo cache server data by key, refetch stale entries in the background, and refresh them after mutations, so components stop hand-writing loading flags, races, and invalidation.
- Images and mediaSending each user an image of the right size and format, reserving its space, and loading it at the right moment: images are usually the largest share of a page's bytes and its LCP element.
- Screen readers and live regionsHow screen readers turn a page into speech or braille, how every control gets its accessible name and description, and how to announce changes like search results, saves, and errors with live regions.
- Product analyticsRecording what users do as well-named events that follow a tracking plan, delivered reliably even offline, with consent respected, and checked so that the numbers people decide with are true.