Design the frontend for a product listing page (component structure, state, data flow, edge cases, scalability)
Page composes `<Filters>`, `<ProductGrid>`, `<Pagination>`. URL is the source of truth for filters/sort/page (shareable, back-button safe). Server-side fetch + SSR/streaming for SEO; React Query handles client refetches on filter change. Skeleton states, optimistic UI on add-to-cart, image lazy-load + responsive srcset. A11y: filter region landmarks, announce result count.
A product listing page (PLP) is the canonical "list + filters" frontend system design. The interesting parts are URL state, rendering strategy, filter ergonomics, and perf at scale.
Component tree
<ProductListingPage>
<Breadcrumb />
<FiltersSidebar>
<Filter name="category" />
<Filter name="price" />
<Filter name="brand" />
</FiltersSidebar>
<Toolbar>
<ResultCount />
<SortSelect />
<ViewToggle />
</Toolbar>
<ProductGrid>
<ProductCard />
...
</ProductGrid>
<Pagination />
</ProductListingPage>State
| State | Location |
|---|---|
| Filters / sort / page | URL (?category=shoes&sort=price&page=2) — shareable, back-button. |
| Products data | React Query (cached, refetched on URL change). |
| UI ephemeral (drawer open, hover) | local useState. |
| Cart | global (Zustand or Context). |
URL as source of truth is the single most important decision — it makes the page shareable, back-button safe, and SSR-able.
Rendering strategy
- SSR for SEO + LCP. Server renders the first page with current URL filters.
- Client refetch on filter change (URL update → React Query refetch).
- Streaming if filter facets or related data load slowly — render grid first, facets next.
- RSC for non-interactive parts (breadcrumb, footer) to reduce JS.
Data flow
URL change → useSearchParams → query key changes → React Query refetch → grid re-rendersDebounce the URL update for sliders / text filters (e.g. price range) to avoid refetch-per-pixel.
Filter ergonomics
- Multi-select with chip display.
- Apply on change for fast filters; Apply button for slow / mobile.
- Clear all + per-filter clear.
- Result count updates live ("142 results"); announced with
aria-live="polite". - Counts per option ("Red (23)") help discoverability — facet counts from API.
Grid
- CSS Grid:
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)). - Skeleton cards during loading; not a spinner over the whole grid.
- Image:
<img loading="lazy" srcset sizes>; AVIF/WebP with JPEG fallback.
Pagination
- Cursor pagination for big catalogs (avoid OFFSET drift).
- Or "Load more" / infinite scroll with intersection observer (but keep a Pagination fallback for SEO).
Performance levers
- Lazy load images below the fold.
- Above-the-fold critical CSS inlined.
- Prefetch likely-next page on hover.
- Cache product detail data so PDP loads instantly on click.
- Image CDN with
?w=400&q=70&fm=aviftransforms.
A11y
<aside aria-label="Filters">landmark.- Filter group:
<fieldset><legend>. - Result count:
aria-live="polite". - Pagination as a labeled
<nav>witharia-current="page". - Skeleton has
aria-busy="true"on parent.
Edge cases
- Empty filter combos → empty state with "Clear filters" CTA.
- Out-of-stock items — filter or fade them.
- Search + filters together → combine query keys.
- Locale / currency — formatted from user settings.
Interview framing
"URL is the source of truth for filters/sort/page — shareable, back-button safe, SSR-ready. React Query handles the data layer keyed by URL params. Server renders the first page for SEO + LCP; subsequent navigations are client-side. Filters with live counts, skeleton grid for loading, lazy-load images with responsive srcset. Use CSS Grid auto-fit for the layout. Announce result counts via aria-live. Performance levers: image CDN, prefetch on hover, cache product detail. The biggest mistake is putting filter state in local React state and losing it on back navigation."
Follow-up questions
- •Why URL over local state for filters?
- •Compare offset vs cursor pagination.
- •How do you handle infinite scroll a11y?
Common mistakes
- •Filters in component state — back button breaks.
- •Re-rendering all cards on filter change.
- •No skeletons → blank during refetch.
- •Image LCP killed by lazy-loading hero images.
Performance considerations
- •Image CDN + AVIF/WebP, lazy-load below fold, prefetch hover, cache PDP, SSR first page.
Edge cases
- •URL-encoded weird filter values.
- •Filter combinations with zero results.
- •Mobile filter drawer vs desktop sidebar.
Real-world examples
- •Amazon, Shopify, ASOS, eBay; Algolia + InstantSearch reference impl.