cf.completefrontend
My progress
SYSTEM DESIGN · #49

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.

QuestionAnswer we assumeWhat 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.

Wireframe of the search results page for trail running shoes. At the top, the search box with a Search button. On the left, the facet panel: Brand with Nike 41 and Adidas 33, Size with 9, 10 and 11, a price range from 50 to 100, and an In stock checkbox. Above the grid, chips for Brand: Nike and Size: 10 and a Clear all button, then 41 results and a sort menu set to Price. The grid has two rows of four product cards: the first row loads eagerly, the second lazily. At the bottom, page links 1, 2 and 3.shop.example/s/shoes/running?q=trail&brand=nike&size=10trail running shoesSearchBrandNike (41)Adidas (33)Size9 · 10 · 11Price50 to 100In stockfacetsBrand: NikeSize: 10Clear all41 resultsSort: Priceeagerlazy‹123›
Every visible part reads from one search state. The chips repeat the facet panel's selections in one line, so on a phone, where the facets hide in a drawer, the shopper still sees why the grid looks the way it does.
  • 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?

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?

Each control owns its piece

Brand list, price slider and sort menu each hold useState.

A parent collects them to build the request.

Pros

  • Quick to start.
  • Each control is self-contained.

Cons

  • Nothing is in the URL: reload, Back and share break.
  • "Clear all" and "reset page on filter change" need cross-component wiring.
  • Several renders and requests for one logical change.

A client store is the truth, synced to the URL

A store (Redux, Zustand) holds the state.

An effect writes the URL when the store changes, and another reads the URL on Back.

Pros

  • Familiar store tooling and devtools.
  • Easy to read from anywhere.

Cons

  • Two sources of truth that sync both ways: loops and flicker when they disagree.
  • Back must be detected and copied into the store, a classic source of bugs.
  • Server rendering needs the same sync on the server.

The URL is the truth; the state is parsed from it

Every change builds the next state, serializes it and navigates to that URL.

Components read parse(location.search) and nothing else.

The router re-renders on Back and Forward for free.

Pros

  • One source of truth: reload, Back, share, new tab and server rendering all just work.
  • The server parses the same URL to render the first page.
  • No sync code at all.

Cons

  • Every change goes through the router, which is slower than a local setState (fine at click speed, not at slider-drag speed).
  • Needs a careful, stable URL format (next chapter).

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.

The search state at the center. The URL, the source of truth, is parsed into SearchState. SearchState feeds three readers: the Search client, which sends GET /api/search, the Query cache, whose key is toUrl of the state, and the Components such as facets and chips. Components call transitions like toggleFacet, which produce the next SearchState, and the next state is written back to the URL with toUrl and push. A note says the drawer draft and slider drag stay local.URLsource of truthSearchStateparsed, typedtransitionstoggleFacet ...Search clientGET /api/searchQuery cachekey = toUrl(s)Componentsfacets, chipsparsenext statetoUrl + pushclickdrawer draft,slider drag:local only
A loop with one owner. Components never set filters directly: they call a transition, the result goes to the URL with 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.

ts
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,
};
The search state. Defaults are part of the type's contract.
  • 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.

ts
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?

One JSON blob

?s= followed by the state as JSON, percent-encoded or base64.

Pros

  • Trivial to write: JSON.stringify and JSON.parse.
  • Handles any nesting.

Cons

  • Unreadable: ?s=%7B%22q%22%3A%22shoes%22....
  • Key order and spacing change the string, so one state has many URLs.
  • Analytics and search engine optimization (SEO) tools can't read it; nobody can edit it by hand.

Repeated keys

?brand=nike&brand=adidas&price_min=50&price_max=100.

Read with URLSearchParams.getAll.

Pros

  • The standard form encoding; every server parses it.
  • No custom separators.

Cons

  • Long and noisy with many values.
  • Order of repeated keys still needs normalizing.

Short keys, comma lists, a path for the category

/shoes/running?q=trail&brand=adidas,nike&price=50-100&sort=price_asc&page=2.

One key per filter; values are lowercase slugs joined by commas; ranges as min-max.

Defaults are left out; keys and values come out in a fixed order.

Pros

  • Readable and short; a human can edit it.
  • One state maps to exactly one string: a natural cache key and canonical URL.
  • The category path doubles as a clean landing page for SEO.

Cons

  • A custom serializer and parser to write and test.
  • Values must be slugs that never contain a comma.

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

ts
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;
}
One state, one string. Facet keys sorted, values sorted, defaults omitted.

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=-3 and page=abc become 1. page=900 when there are 27 pages renders the empty state with a link to page 1 and, from the server, a 404 status (Google asks for that on pagination URLs that don't exist), never a crash.
  • price=100-50 is swapped to 50-100; price=-80 means "up to 80".
  • An unknown sort falls 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.

A shopper's history entries, oldest first: q=shoes, then brand=nike, then price=50-100, then page=2, each added with push. Back from page=2 returns to price=50-100. A note says the slider fired 40 input events but wrote one entry when released. Another note says typing and canonical URL fixes use replaceState and add no entry.history entries, oldest firstpushpushpushpushq=shoesbrand=nikeprice=50-100page=2Backslider: 40 input events,1 entry when releasedtyping, canonical fixes:replaceState, no entry
Four meaningful changes, four Back steps. The 40 slider positions and any URL clean-ups never reach history, so Back always undoes something the shopper actually chose.
ChangeHistoryWhy
Submit a new querypushA new search is a new place. Back should return to the old one.
Tick or untick a facet, pick a sortpushShoppers press Back to undo a filter, so each filter change gets its own entry.
Go to page 3pushBack from page 3 to page 2 is what everyone expects.
Price slider while draggingneitherLocal draft only; the URL updates once when the drag ends.
Price slider drag endspushOne entry per settled range, not 40.
Typing, if search as you typereplaceOne entry per search session, not per letter.
Fixing a non-canonical URLreplaceThe 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.

The same selection, brand Nike and size 10, counted two ways. Naive counts, computed over the 41 results: Nike 41, Adidas 0, Asics 0, and sizes 9: 0, 10: 41, 11: 0, so every other option vanishes. Disjunctive counts, where each group ignores its own filter: brands counted with size 10 give Nike 41, Adidas 33, Asics 12; sizes counted with brand Nike give 9: 38, 10: 41, 11: 35.naive: count the resultsdisjunctive: skip own groupBrandBrand (with size 10)Nike 41Adidas 0Asics 0Nike 41Adidas 33Asics 12SizeSize (with Nike)9: 010: 4111: 09: 3810: 4111: 35Adidas vanished:untick Nike to add itevery option reachablein one clicktotal, both sides: 41 results
Both columns show 41 results. Only the right one lets the shopper add Adidas or switch to size 11 without first undoing a choice, because each group is counted as if its own selection were not there.
GroupFilters used to count itResult after ticking Nike and size 10
Brandquery, size 10Nike (41), Adidas (33), Asics (12): all still visible
Sizequery, brand Nike9 (38), 10 (41), 11 (35): pick another size freely
Price statsquery, brand Nike, size 10min 45, max 220 for the slider ends
Totaleverything41 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.

Counts
Brand
Size

14 results

Disjunctive counts. URL: /s/shoes. Chips: none. 14 of 14 shoes match: Pegasus, Vomero, Zoom Fly, Wildhorse, Terrex, Ultraboost, Adizero, Gel, Novablast, Kayano, Trabuco, Speedgoat, Clifton, Mafate. History has 1 entries and you are on entry 1; the last change was a push./s/shoesno filters: no chips14 resultsPegasusVomeroZoom FlyWildhorseTerrexUltraboostAdizeroGelNovablastKayanoTrabucoSpeedgoatCliftonMafatehistory: entry 1 of 1last change: pushquery matches name,brand, trail or road
Tick Nike, then look at the brand buttons: Adidas, Asics and Hoka keep their counts, because brand is counted without the brand filter. Type in the query box: the first letter pushes a history entry, the rest replace it, so Back skips the half-typed words.

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 50 must 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?

Instant apply

Each tick navigates and fetches, exactly as on desktop.

The grid updates behind the drawer.

Pros

  • One code path for desktop and mobile.
  • Counts in the drawer are always live.

Cons

  • The shopper can't see the grid behind the drawer, so the work is invisible.
  • Five ticks are five history entries and five requests on a slow network.
  • Back after closing the drawer undoes one tick, which surprises people.

Draft plus Apply

Opening the drawer copies the state into a local draft.

Ticks change only the draft; a cheap count request keeps "Show 312 results" on the button up to date.

Apply navigates once; closing without Apply discards the draft.

Pros

  • One history entry and one full search per session in the drawer.
  • The button tells the shopper what they will get before they leave.

Cons

  • Two states to keep apart (draft and committed).
  • The count request is extra work for the backend.

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?

In the browser

Download the whole catalog once.

Filter, count and sort in JavaScript on every change.

Pros

  • Instant: no network on a filter click.
  • Works offline once loaded.

Cons

  • Only works for small catalogs: 2,000 products at 200 bytes each is 400 KB of JSON before images.
  • No real relevance ranking, typo tolerance or synonyms.
  • Prices and stock go stale.

On the server, per state

Each committed state is one request to a search API.

The response holds 48 hits, the total and every facet's counts.

Pros

  • Scales to millions of products.
  • Ranking, typo tolerance and synonyms come from the search engine.
  • Fresh prices and stock.

Cons

  • Each change costs a round trip (150 to 600 ms).
  • Needs care with races, caching and loading states.

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.

Sequence of two quick filter clicks on running shoes. The user ticks Nike, which pushes a URL; the Router gives the Cache key A, and the Cache sends GET A to the API. The user ticks Adidas, which pushes again; the Router gives key B and the Cache aborts A, then sends GET B. The API answers B, 516 results, after 120 ms, and the Cache renders B for the user. Request A, slow at 700 ms, was aborted; if its answer had already arrived late, its key no longer matches and it is dropped.UserRouterCacheAPItick Nike: pushkey AGET Atick Adidas: pushkey B, abort AGET BB: 516 resultsrender BA late: dropped
The URL changes first; the request follows the key. Aborting A with its 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.

ts
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,
  });
}
The search query, keyed by the canonical URL. TanStack Query v5.

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.

State machine for the results grid. Skeleton, used only when there is no previous result, moves to showing on a 200. Showing moves to fetching when the URL key changes. Fetching keeps the old grid dimmed; it moves back to showing when the answer for the current key arrives, to empty when the total is 0, and to error on failure. A new key while fetching aborts the old request and stays in fetching. Error keeps the old grid with Retry, which returns to fetching. Empty shows did-you-mean and relaxations, and a new key returns to fetching. A note says a late answer for an old key is dropped.skeletonno previousshowingresults for keyfetchingold grid dimmedemptyrelaxationserrorold grid, Retrylate answer,old key:dropped200new key200 for this keyfailRetrytotal 0new keynew key: abort old
Only the first visit without cached data ever shows skeletons. In every other state the grid holds real products: the current ones, the previous ones dimmed, or the previous ones with a Retry message.

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.
Shopper
Defenses
Actions: Tick Nike, Tick Adidas. 3 requests sent, 1 aborted, 0 cache hits, 0 stale renders. The URL says A+N; the grid shows A+N, which is correct. Blank grid time: 0 ms.URL: A+Ngrid: A+Nall150 msNabortA+N120 mssent 3, aborted 1, hits 0stale renders: 0blank grid: 0 msold grid stays, dimmed
Each row is one committed state: a bar from request to answer. Green was rendered, gray was aborted or ignored, red was rendered although the URL had already moved on, and a short box is a cache hit. Try two ticks with Abort off, then a drag with Debounce off, then Back with Cache off.

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?

Blank plus spinner

Clear the results, show a spinner until the answer arrives.

Pros

  • Simple. Nobody mistakes old results for new ones.

Cons

  • The page height collapses and the footer jumps up, then down: CLS.
  • Feels slow even at 200 ms.
  • The scroll position is lost.

Skeleton cards

Replace cards with gray blocks of the same size.

Pros

  • Layout is stable.
  • Signals progress clearly.

Cons

  • Still throws away content the shopper was reading.
  • Flashes on every quick click.

Keep previous results, dimmed

Leave the old grid, lower its opacity a little, show a thin progress bar above it.

Swap in the new grid in one frame when it arrives.

Pros

  • No layout shift, no flash.
  • Fast answers (under 100 ms) look instant.

Cons

  • Must signal clearly that results are updating, or shoppers may click a stale card.
  • Needs a fallback when there is nothing previous.

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.

Two windows, 200 ms after a filter click. Left, blank plus spinner: the grid is cleared to a spinner and the footer jumps up into its place, causing layout shift and losing the scroll position. Right, keep previous: the old six cards stay, dimmed, under a thin progress bar labelled updating, so nothing moves until the new grid swaps in.blank + spinner41 resultsspinnerfooter jumped upkeep previous41 resultsupdatinglayout shift, lost scrollno shift, no flash
The right side has nothing to shift and nothing to flash. If the answer comes in 80 ms, the shopper never notices a loading state at all.

Cards that don't jump

  • Give every image box a fixed shape with aspect-ratio: 3 / 4 (or width and height attributes), 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.

tsx
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.

tsx
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?

Search as you type

Debounce keystrokes by about 250 ms, then run the full search.

replaceState while typing, so one session is one history entry.

Pros

  • Feels fast on small, fast indexes (the Algolia demos).
  • No Enter key needed.

Cons

  • Results for half-words ("runn") flash past and shift the page.
  • Each pause is a full search with facets, so one typed query costs several searches instead of one.
  • Analytics fill up with partial queries; zero-result rates look worse than they are.
  • Screen reader users hear the count change while still typing.

Submit on Enter, typeahead for help

The box shows suggestions as you type (the autocomplete guide), which are cheap prefix lookups.

The full search runs on Enter or when a suggestion is picked, with pushState.

Pros

  • One full search per intent.
  • Clean analytics: each search is a real query.
  • Stable page while typing.

Cons

  • One extra key press.
  • Needs a typeahead service to feel modern.

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.
Sequence of a search with no results. The user presses Enter on nikr air max. The Page sends GET q=nikr air max to the BFF, which runs the search on the Engine. The Engine returns 0 hits. The BFF asks the Engine for a spelling fix and relaxed counts, and gets back nike air max with 212 hits. The BFF replies to the Page with total 0 and didYouMean, and the Page shows Did you mean nike air max to the user. On the failure path, the BFF answers 503 and the Page keeps the previous grid with a Retry message.UserPageBFFEngineEnter: nikr air maxGET q=nikr+air+maxsearch0 hitsspelling, relaxnike air max: 212total 0, didYouMeanDid you mean ...?or 503: Retry
The empty state is built on the server in the same round trip, so the page renders it at once. A failure is different from an empty result: it keeps the last good grid and offers Retry instead of claiming nothing matched.

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.

json
{
  "total": 0,
  "hits": [],
  "didYouMean": null,
  "relaxations": [
    { "remove": { "facet": "size", "value": "13" }, "total": 24 },
    { "remove": { "range": "price" }, "total": 61 }
  ]
}
Part of a zero-result response.

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.

Architecture of the search results page. In the browser: Router and URL at the top passes the URL to useSearch. useSearch uses the searchState module to parse it and reads and writes the Query cache by key. useSearch gives hits to ResultsGrid. FacetPanel and ActiveFilters call transitions in searchState, which commits the next URL to the Router with push. On the server side: Query cache misses send GET to the CDN, which forwards misses to the BFF, which queries the Search engine.BrowserRouter + URL/s/...?brand=searchStateparse, toUrluseSearchkey, abortQuery cacheby URL keyFacetPanelfieldsetsActiveFilterschipsResultsGridmemo cardsURLparsekeyhitstransitionscommit: pushServerSearch engineBFFsame parseCDN60 s for anonGETmissquery
Three owners and no overlap: the URL owns the search state, the query cache owns server data, components own only what they draw. The BFF keeps the engine's query language and keys off the client.
PieceResponsibilityWhy 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 moduleparse, 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 hookReads 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 clientBuilds the API request from the state, fetches with signal, maps errors.Swapping the backend (Elasticsearch to Algolia) touches one file.
Query cacheResults 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, CategoryTreeDraw 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, ProductCardRender hits; memoized by product id.A filter change that returns the same products re-renders nothing.
EmptyStateDid-you-mean and relaxations.Its own component because it has its own analytics and copy.
AnalyticsSearch, click and empty-search events with a search id.Fires from the hook and cards, never blocks rendering.
Data flow for one filter click. The URL passes the parsed state to useSearch, which sends GET /api/search with the same query string to the CDN. On a miss the CDN calls the BFF, which sends the query with facet aggregations to the Search engine. The engine's hits and counts go back through the BFF as trimmed JSON of 30 to 60 KB into the Query cache. The cache gives hits to ResultsGrid and facet counts to FacetPanel, and a tick in FacetPanel pushes a new URL.URL?brand=nikeuseSearchkey + signalCDNGET cacheBFFparse, trimFacetPanelcountsResultsGrid48 hitsQuery cache20 statesEnginestateGETmissaggsJSONhitsfacet countstick: push
A loop with one way in. The request reuses the page's own query string, so the CDN can cache popular anonymous states, and the BFF trims each hit to about 600 bytes before it crosses the network.

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

DataLives inWhy
SearchState (query, filters, sort, page)URLShareable, survives reload, drives Back.
SearchResponse (hits, total, facets)Query cache, keyed by canonical URLServer data with a fresh window; reused on Back.
Drawer draft stateComponent state in the drawerThrowaway until Apply; must not touch history.
Slider position while draggingComponent state in RangeFacetChanges 60 times a second; commits on release.
"Show more" expanded, facet search textComponent statePure UI; resetting it on reload is fine.
Recent searcheslocalStorage, capped at 10Personal, small, survives sessions; never in the URL.
Search id for analyticsResponse field, kept with the cached responseTies clicks to the search that produced them.
ts
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
};
What the BFF returns for one state.
Where the data lives. In the URL, the SearchState: q, category, facets, ranges, flags, rating, sort and page. In the Query cache, the SearchResponse: searchId, total and whether it is exact, 48 hits, facets, ranges, didYouMean and relaxations. In component state: the drawer draft, the slider value, show more, and the facet search text. The cache key is toUrl of the state. Recent searches live in localStorage.URL: SearchStateQuery cache: responseComponent stateqcategory[]facets{}ranges{}flags[]ratingsortpagesearchIdtotal, exacthits[48]facets[]rangesdidYouMeanrelaxationsdrawer draftslider valueshow morefacet searchcache key = toUrl(state)localStorage: 10 recent searches
Three homes, one link between them: the cache key is toUrl(state). Anything that would be wrong in a shared link stays in component state.

Endpoints

CallPurposeNotes
GET /api/search/shoes/running?q=trail&brand=adidas,nike&page=2Results, 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&...stateSearch 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?

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?

All of them

Every filter link is a normal followable link; no robots rules.

Pros

  • Nothing to configure.
  • Long-tail combinations might rank.

Cons

  • Crawl budget goes to millions of thin, near-duplicate pages.
  • New products get discovered later.
  • Server load from crawlers rendering filter pages.

None of them

robots.txt disallows every URL with a filter parameter.

Only category paths and product pages are crawled.

Pros

  • Simple and effective; Google recommends it when filter pages aren't needed in search.

Cons

  • Loses valuable pages people do search for, like "Nike running shoes".

A curated set, the rest closed

Pick valuable combinations from search demand (category plus brand, category plus one color).

Give them clean paths like /c/running-shoes/nike, their own title and heading, and link them from category pages.

Disallow the filter parameters in robots.txt; crawlable pages set a canonical link to their clean URL.

Pros

  • Captures demand without the trap.
  • Landing pages get proper titles and copy.

Cons

  • Needs a list maintained with the SEO team.
  • Two URL forms for some states (path and parameters).

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.

Crawl map. The category /s/shoes links to /s/shoes/running; both are indexable. Running links to its page 2, which is indexable with a self canonical; to the curated landing page /c/running-shoes/nike, indexable; to the sorted URL ?sort=price_asc, whose canonical points to the clean URL; and to multi-select filter URLs like ?brand=nike,asics&size=10, which robots.txt disallows./s/shoesindex/s/shoes/runningindex?page=2index, self canonical/c/running-shoes/nikecurated landing page?sort=price_asccanonical: clean URL?brand=nike,asics&size=10robots.txt: disallow
A small, chosen set of URLs is open to crawlers. Sorting points back to the clean page, and the endless filter combinations are blocked before a crawler spends any time on them.
  • Canonical links. Each crawlable page has a link rel="canonical" to its clean form, which also strips tracking parameters such as utm_source. /s/shoes?sort=price_asc points 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" and rel="prev", so plain crawlable links between pages are what matter.
  • Empty filter pages. Google recommends a 404 status 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.txt is never fetched, so its canonical link and any noindex on it are never read, and Google can still list the bare URL if other sites link to it. Use noindex only on pages crawlers may fetch. To remove a blocked URL that is already in the index, lift the block so crawlers can read its noindex.
  • 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).

EventFieldsAnswers
search_viewsearchId, q, filters, sort, page, total, latency ms, source (new or history)Volume, latency, refinement patterns.
search_emptyq, filters, didYouMean shown, relaxations shownMissing stock, missing synonyms.
result_clicksearchId, productId, position (absolute, 1-based), pageClick-through rate by position; ranking quality.
filter_changesearchId, facet, value, on or offWhich filters people use; which to promote.
add_to_cartsearchId, productId, positionRevenue 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 fieldset with a legend ("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 (ul of li) under an h2 such 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

ActionWhere focus goesWhy
Tick a checkbox (desktop)Stays on the checkbox.The shopper is mid-choice; moving focus would throw them out of the group.
Remove a chipThe 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 pageThe 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:

  1. 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 for router.push), which React may interrupt and delay. The click paints in one cheap frame, and the heavy render follows.
  2. Re-render only what changed. ProductCard is 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.
  3. Do less work per card. No per-card effects, no per-card observers (one shared IntersectionObserver or native lazy loading), prices formatted once with a single Intl.NumberFormat instance. content-visibility: auto on 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.

MinutesWhat you doWhat to draw
0 to 5Scope questions: catalog size, filter kinds, SEO, devices, instant or Enter.Nothing yet; write the assumptions in a corner.
5 to 8Name the six forces from "What makes it hard".The wireframe: header, facets, chips, sort, grid, pages.
8 to 18The search state type, the URL format, push versus replace.The state object and one example URL.
18 to 26Disjunctive facet counts; chips; mobile drawer with Apply.The counts table for Nike plus size 10.
26 to 34One request per state, abort plus key check, cache, keep previous results.The sequence diagram with the slow request.
34 to 40Architecture and API response shape.Boxes: URL, state module, query cache, BFF, components.
40 to 45Pick 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?"

Think first

"A price changes while the shopper is looking at the grid. Do you update it?"

Think first

"Search traffic is 5,000 requests per second at peak. How does the frontend help?"

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.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next