Guides · August 12, 2023
Product Listing & Faceted Search in a Headless Store
A practical Next.js tutorial for building a product listing page with faceted search: URL-driven filter state, server-rendered results, debounced client refinement, and facet-count aggregation that stays fast at scale.
By Polo Themes
Faceted search in a headless Next.js store comes down to three decisions: keep filter state in the URL (not component state) so pages are shareable and cacheable, render the first result set on the server for speed and SEO, then layer client-side refinement on top for instant filter interactions. Get those three right and the rest — facet counts, pagination, empty states — falls into place. This tutorial builds the whole thing end to end, independent of which commerce backend sits behind it.
Faceted search is one of the highest-leverage UI patterns in ecommerce. Shoppers who filter convert at meaningfully higher rates than shoppers who scroll a flat grid, and a slow or janky filter UI is one of the fastest ways to lose that shopper to a back button. In a traditional server-rendered theme, faceted search is mostly solved for you by the platform. In a headless build, you own every part of it: the URL scheme, the data fetching, the facet aggregation, the loading states, and the accessibility of the controls. That is more work, but it is also why headless storefronts can feel dramatically faster and more precise than platform-default category pages once they are built well.
The Architecture: URL as State, Server as Source of Truth
The single most important design decision in a faceted search implementation is where filter state lives. It should live in the URL's query string, not in a `useState` hook, not in a client-side store, and not in a form that submits on change without touching the address bar. The URL is the state.
- A URL like `/collections/frames?color=black,tortoise&material=acetate&price=0-150&sort=price-asc&page=2` is shareable, bookmarkable, and works correctly when a shopper hits back after opening a product.
- Server-rendered pages built from the URL are trivially crawlable — search engines see the same filtered result a human does, without executing client JavaScript.
- It gives you a single, unambiguous source of truth. Component state and URL state drifting out of sync is one of the most common bug classes in hand-rolled filter UIs.
- It plays well with the browser cache and CDN edge caching for anonymous, unauthenticated traffic, since the URL fully determines the response.
In the Next.js App Router, this maps directly onto a route's `searchParams` prop. A collection page component receives the current query string as a plain object on every request, server-side, with no client-side parsing needed for the initial render. That is the foundation everything else is built on.
Step 1: Model the Filter Schema
Before writing any UI, define the shape of a filter query as a typed object, independent of the URL's string encoding. This separation matters: your components should work with `ProductFilterQuery`, and only the URL-parsing layer should know about query-string mechanics.
- facets: a map of facet key to selected values, e.g. `{ color: ["black", "tortoise"], material: ["acetate"] }`. Multi-select within a facet is nearly always OR logic (color is black OR tortoise); selections across different facets are AND logic.
- priceRange: a min/max tuple, kept separate from discrete facets since it is a range control, not a checkbox list.
- sort: an enum (`relevance`, `price-asc`, `price-desc`, `newest`), with `relevance` as the default so an unfiltered collection page has a stable, cacheable URL.
- page: 1-indexed, defaulting to 1 so the canonical unpaginated URL never needs a `page=1` query param.
- q: an optional free-text search term, when the listing page doubles as a search results page.
Write a pair of pure functions, `parseFilterQuery(searchParams)` and `serializeFilterQuery(query)`, and test them directly. Every bug in a faceted search UI that looks like "the filters don't match the URL" is usually a bug in one of these two functions, so isolating them pays for itself the first time something breaks.
Step 2: Server Component for the Initial Render
The collection page itself should be an async Server Component. It parses `searchParams` into your typed query object, fetches both the product results and the facet aggregation for that query, and renders the full page — grid, filter sidebar, facet counts, and pagination — before any client JavaScript runs. A shopper who lands on a filtered link from a Google search or a marketing email should see the correct, fully filtered result on the very first paint, with zero loading spinner.
This means your data layer needs to answer two questions in roughly the same request: which products match the current filters, and what are the available facet values (and their counts) given those same filters. Those are two different queries against your product catalog, and conflating them is a common mistake — more on that below.
Streaming the slow part
Facet-count aggregation is frequently the slower of the two queries, especially on a catalog with thousands of SKUs and several facet dimensions. Rather than blocking the whole page on it, wrap the facet sidebar in a `<Suspense>` boundary with its own async Server Component and a skeleton fallback. The product grid — usually the part a shopper is most impatient to see — streams in first, and the facet counts fill in a beat later. This single change is often the biggest perceived-performance win available on a listing page.
Step 3: Client-Side Refinement Without a Full Page Reload
Server rendering solves the first load. It does not, by itself, solve what happens when a shopper clicks a color swatch in the filter sidebar. You want that click to update the URL, refetch just the affected data, and re-render — without a full document navigation that repaints the whole page and loses scroll position.
The pattern is: filter controls are client components that call `router.push(url, { scroll: false })` with the new query string built by your `serializeFilterQuery` function, wrapped in `startTransition` so the pending state doesn't block the input from responding immediately. Because the destination is a Server Component route, Next.js re-runs the server render for the new `searchParams` and streams back updated content, while React keeps the surrounding layout — header, footer, filter sidebar shell — intact rather than remounting the page.
- Wrap the navigation call in `useTransition` and expose the `isPending` flag as a subtle opacity dim on the grid, not a full-screen spinner. The shopper should feel like the page is "thinking," not "gone."
- Debounce range-slider and free-text inputs (150-300ms) before pushing a URL update — a slider firing on every pixel of drag will otherwise spam navigations.
- Checkbox and swatch facets don't need debouncing; they're discrete clicks and should feel instant.
- Preserve scroll position explicitly (`scroll: false`) so applying a filter doesn't yank the shopper back to the top of a long page.
Step 4: Facet Aggregation — Counts That Update With Every Selection
The subtle part of faceted search, and the part most tutorials skim past, is that facet counts must reflect the filters currently applied, minus the facet being displayed. If a shopper has filtered to "black" frames, the material facet should show counts for acetate, titanium, and metal *among black frames only* — but the color facet itself should still show counts for tortoise and clear as if black weren't selected, so the shopper can see what switching to another color would yield. This is sometimes called multi-select faceting, and getting it wrong is the single most common bug in home-grown filter UIs — either every facet count collapses to reflect all active filters (making it impossible to broaden a search), or no facet ever excludes its own dimension (making counts wrong for combined filters).
Concretely: for each facet dimension you display, run the aggregation query with every *other* active filter applied, but not that dimension's own selection. If your backend supports faceted aggregation natively (Meilisearch, Algolia, Elasticsearch/OpenSearch aggregations, Typesense facet counts), this is usually a native feature exposed as a query parameter or a query-per-facet request — use it rather than reimplementing the exclusion logic by hand in application code. If you're aggregating against a general-purpose relational or document store without a search layer, this is the point to seriously consider adding one; hand-rolled multi-select facet counting against raw SQL tends to become slow and difficult to maintain past a few hundred SKUs and more than two or three facet dimensions.
Step 5: Pagination and Infinite Scroll
Both classic pagination and infinite scroll are valid choices; the mistake is picking infinite scroll and then losing scroll restoration when a shopper opens a product and hits back. If you go with infinite scroll, persist how many pages have been loaded in the URL (e.g. `page=3` meaning "pages 1 through 3 are loaded") rather than only in component state, and restore that many pages server-side when the shopper navigates back — otherwise the back button drops them at page 1 with a jarring scroll-to-top. Numbered pagination avoids this class of bug entirely and is usually the safer default for a first build; add infinite scroll later once the URL-state discipline is proven out.
Step 6: Empty States and Zero-Result Recovery
A faceted search that returns zero results and simply shows an empty grid is a dead end for the shopper. Treat the zero-result state as a first-class piece of UI: show which filters are active, offer one-click removal of the most recently added or most restrictive filter, and — if you have the data — surface the nearest non-empty alternative ("no black frames in acetate under $150 — 6 results if you remove the material filter"). This single affordance recovers a meaningful share of what would otherwise be an abandoned session, and it costs little to build once your facet-count query already knows which filters would change the result.
Accessibility and Semantics
Filter sidebars are frequently the least accessible part of a storefront because they get built fast, late, and under pressure. A few non-negotiables: every facet checkbox needs a real `<label>` associated with its input, not a clickable `<div>`; the sidebar's applied-filter count and the loading-transition state should be announced via an `aria-live` region so screen reader users know a change happened; and the whole filter UI needs to be fully operable by keyboard, including range sliders (native `<input type="range">` elements handle this for free — custom drag-only sliders often don't). None of this is exotic, but all of it is easy to skip when a filter sidebar is treated as a purely visual component.
Where Design Comes In
Everything above is behavior — the visual design of the filter sidebar, the facet-value swatches, and the grid itself still needs to look intentional rather than like a generic admin panel. If you're designing this from scratch rather than coding against an existing design, working from a well-structured Figma source — with the collection grid, filter sidebar, and swatch/checkbox states already designed as reusable components — saves a meaningful amount of the design-to-code loop, particularly around states that are easy to forget (hover, selected, disabled-and-zero-count). Our Figma UI kits are built with that componentized structure in mind, which is exactly the shape you want feeding into React components with props for each state, whether or not you build the underlying storefront on Shopify.
Choosing a Data Layer for Facets
The commerce backend matters less to this pattern than it might seem — the architecture above (URL state, server render, streamed facets, multi-select counting) is the same whether your product data comes from Shopify's Storefront API, a headless engine like Medusa, or a dedicated search index. What changes is where the facet-count query runs. Shopify's native product filtering has gotten more capable but is still comparatively limited for deep multi-select faceting across many custom metafields; teams pushing hard on facets often put a dedicated search index (Algolia, Meilisearch, or a self-hosted Elasticsearch/OpenSearch cluster) in front of the catalog, synced from the source of truth, and query that index for both results and facet counts. That extra layer is worth its complexity once you have more than a handful of facet dimensions or a catalog large enough that naive aggregation queries get slow.
Frequently Asked Questions
Should filter state live in the URL or in React state?
The URL. Keeping filter state in component state alone breaks shareable links, breaks the back button, and breaks SEO crawlability of filtered pages. React state (loading flags, transition pendency, open/closed sidebar sections) is fine to keep locally — the actual selected filters should not be.
Do I need a dedicated search index, or can I query my database directly?
For a small catalog with one or two facet dimensions, direct queries against your product database are fine. Once you need accurate multi-select facet counts across several dimensions on a catalog of more than a few hundred products, a dedicated search index (Algolia, Meilisearch, Elasticsearch/OpenSearch, Typesense) will be faster to build correctly and faster to run than hand-rolled aggregation SQL.
How do I keep filter interactions feeling instant if the server has to re-render?
Wrap navigation in `useTransition` so the input responds immediately while the new content streams in, stream the facet sidebar separately from the product grid with its own `<Suspense>` boundary so the slower aggregation query doesn't block the faster product query, and debounce continuous inputs like range sliders so they don't fire a navigation per pixel of drag.
What's the most common bug in a hand-built faceted search?
Facet counts that don't exclude the facet's own current selection — either every count collapses to reflect all active filters (so you can never see what switching would yield) or no count ever excludes anything (so combined-filter counts are wrong). Getting this "self-excluding" aggregation right per facet dimension is the crux of correct multi-select faceted search.