Building Faceted Filters with Aggregations End-to-End
You have an Elasticsearch index and a sidebar that needs checkboxes with live counts, and clicking a checkbox must narrow the results. The specific problem is the round trip: turning an aggs request body into rendered facet rows, then turning a user’s clicks back into filter clauses on the next request. This walkthrough sits under the faceted navigation and filtering guide and the broader search frontend UX patterns area, and it assumes you have already decided which fields are facets. Here we wire them together so the loop closes correctly on the first click.
Prerequisites
- An index at
localhost:9200withkeyword/numeric facet fields (see schema design and index mapping). - A backend that proxies search requests (never let the browser hit Elasticsearch directly).
- Node 18+ for the example fetch code.
Diagnosis / Context
The mistake that breaks most first implementations is treating the aggregation result and the filter request as unrelated. They are one cycle: aggregations describe the available filters, the user’s selection becomes the applied filter, and the next response’s aggregations describe the new available filters. If you parse buckets without preserving the field-to-agg mapping, you cannot reconstruct which checkbox produced which term clause.
A raw aggregation response looks like this — note that bucket keys are the literal filter values you will send back:
{
"aggregations": {
"by_brand": {
"doc_count_error_upper_bound": 0,
"sum_other_doc_count": 0,
"buckets": [
{ "key": "acme", "doc_count": 42 },
{ "key": "globex", "doc_count": 17 }
]
}
}
}
Each key is what goes into a term query; each doc_count is the number rendered beside the checkbox. The naming convention by_<field> is deliberate — it lets the parser derive the field name by stripping the prefix, so the click handler knows to emit { "term": { "brand": "acme" } }.
Solution Steps
1. Define the aggregation request from a facet config
Drive the request from a declarative config so adding a facet is a one-line change.
// facets.js — single source of truth for which fields are facets
const FACETS = [
{ name: "brand", field: "brand", type: "terms" },
{ name: "color", field: "color", type: "terms" },
{
name: "price", field: "price", type: "range",
ranges: [
{ key: "under-50", to: 50 },
{ key: "50-100", from: 50, to: 100 },
{ key: "100-plus", from: 100 }
]
}
];
function buildAggs() {
const aggs = {};
for (const f of FACETS) {
aggs[`by_${f.name}`] = f.type === "range"
? { range: { field: f.field, ranges: f.ranges } }
: { terms: { field: f.field, size: 20 } }; // cap buckets per facet
}
return aggs;
}
2. Translate active selections into filter clauses
Selected values for the same facet union (OR); different facets intersect (AND). Build one terms clause per facet, and collect them into the filter array.
// selections = { brand: ["acme"], color: ["red", "blue"] }
function buildFilters(selections) {
const filter = [];
for (const f of FACETS) {
const picked = selections[f.name];
if (!picked || picked.length === 0) continue;
if (f.type === "range") {
// OR across selected bands -> bool.should
filter.push({
bool: {
should: picked.map((key) => rangeClauseFor(f, key)),
minimum_should_match: 1
}
});
} else {
// terms clause = OR across values within one facet
filter.push({ terms: { [f.field]: picked } });
}
}
return filter;
}
function rangeClauseFor(facet, key) {
const band = facet.ranges.find((r) => r.key === key);
const r = {};
if (band.from != null) r.gte = band.from;
if (band.to != null) r.lt = band.to; // 'lt' so bands don't double-count the edge
return { range: { [facet.field]: r } };
}
3. Assemble and send the request
async function search(queryText, selections) {
const body = {
size: 24,
query: {
bool: {
must: queryText ? [{ match: { title: queryText } }] : [{ match_all: {} }],
filter: buildFilters(selections)
}
},
aggs: buildAggs()
};
const res = await fetch("http://localhost:9200/products/_search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
return res.json();
}
4. Parse buckets into render-ready facet rows
Strip the by_ prefix to recover the field name, then mark which values are currently checked so the UI is stateful across re-renders.
function parseFacets(aggregations, selections) {
return FACETS.map((f) => {
const agg = aggregations[`by_${f.name}`] || { buckets: [] };
return {
name: f.name,
rows: agg.buckets.map((b) => ({
value: b.key,
count: b.doc_count,
checked: (selections[f.name] || []).includes(b.key)
}))
};
});
}
5. Close the loop on click
A click toggles the value in selections, then re-runs search. The new response’s aggregations replace the rendered rows.
function onToggle(state, facetName, value) {
const cur = new Set(state.selections[facetName] || []);
cur.has(value) ? cur.delete(value) : cur.add(value);
state.selections[facetName] = [...cur];
return search(state.query, state.selections).then((r) => ({
hits: r.hits.hits,
facets: parseFacets(r.aggregations, state.selections)
}));
}
Verification
Run a request with one active filter and confirm both the hit total and the bucket counts respond.
curl -s -X POST "localhost:9200/products/_search" -H 'Content-Type: application/json' -d '{
"size": 0,
"query": { "bool": { "filter": [{ "terms": { "color": ["red","blue"] } }] } },
"aggs": { "by_brand": { "terms": { "field": "brand", "size": 20 } } }
}' | jq '{ total: .hits.total.value, brands: .aggregations.by_brand.buckets }'
Expected output: a total reflecting only red/blue items, with per-brand counts summing to that total.
{
"total": 59,
"brands": [
{ "key": "acme", "doc_count": 41 },
{ "key": "globex", "doc_count": 18 }
]
}
Common Pitfalls
Range bands double-count items at the boundary
Using lte on one band and gte on the next makes an item priced exactly 50 fall into both. Always pair gte with lt (half-open intervals) so each value lands in exactly one band, matching the from/to semantics Elasticsearch uses internally for range aggregations.
Checked state is lost after re-render
If parseFacets does not receive the current selections, every re-render returns unchecked rows and the UI appears to forget the user’s clicks even though the filter still applies. Always pass selections into the parser so each row’s checked flag is recomputed.
Bucket truncation hides values the user already selected
A terms agg with size: 20 can omit a selected value that ranks 21st, so its checkbox disappears mid-session. Either raise size, or merge selected values back into the row list after parsing so an active filter never vanishes from the sidebar.
Related
- Faceted navigation and filtering — the parent guide covering aggregation types and multi-select semantics.
- Dynamic facet counts and post-filtering — fix counts that go wrong once a filter is active.
- Schema design and index mapping — make facet fields aggregatable before you build any of this.