Product Filtering Development for E-Commerce
Filtering is one of the main navigation tools in catalog. User with 500 laptops doesn't browse pages — filters by brand, RAM, screen size. Poor filtering loses these sales. Good filtering is faceted search: available filter values update based on selections made, and user always knows how many products match each value.
Filter Types
| Type | UX Component | Example | Technical |
|---|---|---|---|
| Multiple selection | Checkboxes | Brand: Apple, Samsung | WHERE brand IN (...) |
| Single selection | Radio buttons | Condition: new/used | WHERE condition = ... |
| Numeric range | Slider with handles | Price: 5000–30000 | WHERE price BETWEEN ... |
| Range via inputs | "From" and "To" fields | Diagonal: 13–15.6" | WHERE diagonal BETWEEN ... |
| Boolean | Toggle | In stock only | WHERE stock > 0 |
| Rating | Stars (≥N) | Rating from 4 | WHERE rating >= 4 |
| Color | Color swatches | Color: black, silver | WHERE color IN (...) |
Faceted Search with SQL
Simplest approach — filtering via PostgreSQL. Works up to ~100,000 products with proper indexing.
-- Main query with filters
SELECT p.* FROM products p
WHERE p.category_id = :cat
AND (:brands IS NULL OR p.brand = ANY(:brands::text[]))
AND (:price_min IS NULL OR p.price >= :price_min)
AND (:price_max IS NULL OR p.price <= :price_max)
AND (:in_stock IS NULL OR p.stock > 0)
ORDER BY p.sort_order
LIMIT 48 OFFSET :offset;
-- Aggregations for counters (separate query per filter)
SELECT brand, COUNT(*) FROM products p
WHERE p.category_id = :cat
-- All filters EXCEPT brand
AND (:price_min IS NULL OR p.price >= :price_min)
GROUP BY brand;
Problem with SQL approach: for correct counters need separate aggregation query for each filter, excluding that filter from conditions. With 10 active filters — 10 additional queries. Doesn't scale on real load.
Faceted Search with Elasticsearch
Elasticsearch solves task in one request via aggregations:
{
"query": {
"bool": {
"filter": [
{ "term": { "category_id": 14 } },
{ "terms": { "brand": ["Apple", "Samsung"] } },
{ "range": { "price": { "gte": 5000, "lte": 30000 } } }
]
}
},
"aggs": {
"brands": {
"filter": {
"bool": {
"filter": [
{ "term": { "category_id": 14 } },
{ "range": { "price": { "gte": 5000, "lte": 30000 } } }
]
}
},
"aggs": {
"values": { "terms": { "field": "brand", "size": 50 } }
}
},
"price_range": {
"stats": { "field": "price" }
}
}
}
Each aggregation (brands, ram, screen_size) uses filter without its own condition — that's faceted search. One request returns both products and counters for all filters.
URL Schema for Filters
URL must reflect filter state for sharing and SEO:
/laptops?brand=apple,samsung&ram=16&price_min=50000&price_max=100000&sort=price_asc
On filter change — pushState or replaceState without page reload. On direct URL entry — initialize filter state from parameters.
SEO approach: popular filter combinations (brand + category) as separate static pages with unique content and canonical. Rare combination pages — <meta name="robots" content="noindex">.
Client Implementation
Filter state stored in URL (source of truth) mirrored in React state:
type FilterState = {
brands: string[];
ram: number | null;
priceMin: number | null;
priceMax: number | null;
inStock: boolean;
sort: 'price_asc' | 'price_desc' | 'popularity' | 'rating';
};
function useFilters() {
const [searchParams, setSearchParams] = useSearchParams();
const filters = useMemo(() => parseFilters(searchParams), [searchParams]);
const setFilter = (key: keyof FilterState, value: unknown) => {
const next = { ...filters, [key]: value };
setSearchParams(buildParams(next), { replace: true });
};
return { filters, setFilter };
}
On filter change — debounce 300ms, then API request. Results update without page reload.
Price Slider
Price range component — separate task. Requirements:
- Two handles (min/max) that can't cross
- Keyboard input validation and clamp
- Histogram of price distribution behind slider (shows where products concentrate)
Histogram: Elasticsearch histogram aggregation with interval = (max_price - min_price) / 20. Display via SVG path or tiny bar chart.
Ready components: @radix-ui/react-slider, rc-slider, noUiSlider. Radix option preferred with Tailwind stack.
Performance Optimization
Aggregation caching: facet counts don't change per request. Cache aggregation results for category with typical filter set in Redis for 5–10 minutes. On product update invalidate category cache.
PostgreSQL indexes:
-- Composite index for typical query
CREATE INDEX ON products (category_id, brand, price)
WHERE status = 'active';
-- GIN-index for JSONB attributes
CREATE INDEX ON products USING GIN (attributes);
Lazy loading facets: show first 5–7 values, "Show all" button loads rest separately.
Mobile Adaptation
On mobile filters hidden behind "Filters" button → opens bottom drawer (bottom sheet) fullscreen. Same components inside but with larger touch targets. "Apply" button fixed at bottom. On apply drawer closes, list updates.
Timeline
- Basic filtering (SQL, checkboxes for 3–4 attributes, price range): 1–2 weeks
- Faceted search on Elasticsearch (dynamic counters, all filter types, URL sync): 3–4 weeks
- Add price histogram and aggregation caching: +1 week
Choice between SQL and Elasticsearch determined by catalog size. Up to 50,000 products well-designed SQL handles. Above — Elasticsearch makes quality difference in speed and facet richness.







