Step 1: configure filterable attributes
- Step 1: configure filterable attributes
- Step 1: configure filterable attributes
- Step 2: request facet distributions
- Step 3: apply a filter when the user clicks a facet
- Step 4: combine multiple facet filters
- Frontend implementation pattern
- Tune maxValuesPerFacet
- Key points
- Next steps
- Search with facets
- Search API reference
- Combine filters and sort
Faceted navigation displays filter options alongside the number of matching documents, letting users progressively refine their search. This is the pattern behind product sidebars on ecommerce sites, where users can click “Electronics (42)” or “Books (18)” to narrow results. This guide walks through the full pattern: configuring filterable attributes, requesting facet distributions, and building an interactive UI.
Step 1: configure filterable attributes
Only attributes listed in filterableAttributes can be used as facets. Suppose you have a books index with documents like this:
{
"id": 5,
"title": "Hard Times",
"genres": ["Classics", "Fiction"],
"publisher": "Penguin Classics",
"language": "English",
"author": "Charles Dickens",
"rating": 3
}
Add the attributes you want as facets to filterableAttributes:
cURL
JS
Python
PHP
Java
Ruby
Go
C#
Rust
Dart
curl \
-X PUT 'MEILISEARCH_URL/indexes/books/settings/filterable-attributes' \
-H 'Authorization: Bearer API_KEY' \
-H 'Content-Type: application/json' \
--data-binary '[
"genres", "rating", "language"
]'
client.index('movie_ratings').updateFilterableAttributes(['genres', 'rating', 'language'])
client.index('movie_ratings').update_filterable_attributes([
'genres',
'director',
'language'
])
$client->index('movie_ratings')->updateFilterableAttributes(['genres', 'rating', 'language']);
client.index("movie_ratings").updateFilterableAttributesSettings(new String[] { "genres", "director", "language" });
client.index('movie_ratings').update_filterable_attributes(['genres', 'rating', 'language'])
filterableAttributes := []interface{}{
"genres",
"rating",
"language",
}
client.Index("movie_ratings").UpdateFilterableAttributes(&filterableAttributes)
List<string> attributes = new() { "genres", "rating", "language" };
TaskInfo result = await client.Index("movie_ratings").UpdateFilterableAttributesAsync(attributes);
let task: TaskInfo = client
.index("movie_ratings")
.set_filterable_attributes(&["genres", "rating", "language"])
.await
.unwrap();
await client
.index('movie_ratings')
.updateFilterableAttributes(['genres', 'rating', 'language']);
Wait for the settings task to complete before searching.
If an attribute passed to facets has not been added to filterableAttributes, Meilisearch silently ignores it. No error is raised; the attribute simply will not appear in the facetDistribution response. If a facet is missing from your UI, double-check that it is declared as a filterable attribute.
Step 2: request facet distributions
Use the facets search parameter to tell Meilisearch which attributes should include distribution counts in the response:
cURL
JS
Python
PHP
Java
Ruby
Go
C#
Rust
Dart
curl \
-X POST 'MEILISEARCH_URL/indexes/books/search' \
-H 'Authorization: Bearer API_KEY' \
-H 'Content-Type: application/json' \
--data-binary '{
"q": "classic",
"facets": [
"genres", "rating", "language"
]
}'
client.index('books').search('classic', { facets: ['genres', 'rating', 'language'] })
client.index('books').search('classic', {
'facets': ['genres', 'rating', 'language']
})
$client->index('books')->search('classic', [
'facets' => ['genres', 'rating', 'language']
]);
SearchRequest searchRequest = SearchRequest.builder().q("classic").facets(new String[]
{
"genres",
"rating",
"language"
}).build();
client.index("books").search(searchRequest);
client.index('books').search('classic', {
facets: ['genres', 'rating', 'language']
})
resp, err := client.Index("books").Search("classic", &meilisearch.SearchRequest{
Facets: []string{
"genres",
"rating",
"language",
},
})
var sq = new SearchQuery
{
Facets = new string[] { "genres", "rating", "language" }
};
await client.Index("books").SearchAsync<Book>("classic", sq);
let books = client.index("books");
let results: SearchResults<Book> = SearchQuery::new(&books)
.with_query("classic")
.with_facets(Selectors::Some(&["genres", "rating", "language"]))
.execute()
.await
.unwrap();
await client
.index('books')
.search('', SearchQuery(facets: ['genres', 'rating', 'language']));
The response includes a facetDistribution object showing every value for each requested facet and how many documents match:
{
"hits": [
{ "id": 5, "title": "Hard Times", "genres": ["Classics", "Fiction"], "rating": 3 }
],
"query": "classic",
"facetDistribution": {
"genres": {
"Classics": 12,
"Fiction": 8,
"Literature": 6,
"Victorian": 4,
"Romance": 3
},
"language": {
"English": 15,
"French": 3,
"Spanish": 1
},
"rating": {
"3": 5,
"4": 8,
"5": 6
}
},
"facetStats": {
"rating": {
"min": 1,
"max": 5
}
},
"processingTimeMs": 1,
"estimatedTotalHits": 19
}
The facetDistribution tells you exactly which values exist and how many documents match each one. The facetStats object provides minimum and maximum values for numeric facets, useful for building range sliders.
Request several facets with a wildcard
facets also accepts wildcard patterns, which is convenient when related facets share a prefix and you do not want to list them one by one. Pass “*” to request every filterable attribute, or a pattern containing * to request the subset that matches it:
{
"q": "classic",
"facets": ["specs.*"]
}
Given filterable attributes specs.language, specs.binding, and specs.edition, that single entry returns a distribution for all three. Only filterable attributes are ever matched.
A wildcard makes it easy to request far more facets than your interface displays, and every matched attribute costs a distribution computation. Prefer an explicit list for a page whose facets you know, and keep wildcards for cases where the attribute names are genuinely dynamic. See Optimize facet performance for the cost model.
facetStats only considers values stored as JSON numbers. String values are ignored, even when the string contains a numeric value such as “42”. If you expect min/max for a given facet and the object is missing, confirm that the underlying field is indexed as a number rather than a string.
Step 3: apply a filter when the user clicks a facet
When a user clicks a facet value, send a new search request with a filter parameter:
curl \
-X POST 'MEILISEARCH_URL/indexes/books/search' \
-H 'Content-Type: application/json' \
--data-binary '{
"q": "classic",
"filter": "genres = Classics",
"facets": ["genres", "language", "rating"]
}'
The response updates both the hits and the facetDistribution to reflect the active filter. This means the facet counts adjust dynamically, showing users how many results remain for each option.
Step 4: combine multiple facet filters
Users often select multiple facet values. Combine them using AND and OR operators:
curl \
-X POST 'MEILISEARCH_URL/indexes/books/search' \
-H 'Content-Type: application/json' \
--data-binary '{
"q": "classic",
"filter": "genres = Classics AND language = English AND rating >= 4",
"facets": ["genres", "language", "rating"]
}'
Use AND to require all conditions (narrow results) and OR to match any condition (broaden results within a facet group). See the filter expression syntax reference for the full list of operators:
"filter": "(genres = Classics OR genres = Fiction) AND language = English"
Frontend implementation pattern
Here is a JavaScript pattern for building an interactive faceted sidebar:
<div id="app">
<input type="text" id="search-input" placeholder="Search books..." />
<div style="display: flex;">
<div id="facets-sidebar" style="width: 250px;"></div>
<div id="results" style="flex: 1;"></div>
</div>
</div>
<script>
const MEILI_URL = 'MEILISEARCH_URL';
const MEILI_KEY = 'your_search_api_key';
const FACET_ATTRIBUTES = ['genres', 'language', 'rating'];
let activeFilters = {};
async function search() {
const query = document.getElementById('search-input').value;
// Build filter string from active selections
const filterParts = Object.entries(activeFilters)
.filter(([_, values]) => values.length > 0)
.map(([attr, values]) => {
const conditions = values.map(v => `${attr} = "${v}"`).join(' OR ');
return `(${conditions})`;
});
const filter = filterParts.join(' AND ');
const response = await fetch(`${MEILI_URL}/indexes/books/search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${MEILI_KEY}`
},
body: JSON.stringify({
q: query,
filter: filter || undefined,
facets: FACET_ATTRIBUTES
})
});
const data = await response.json();
renderFacets(data.facetDistribution);
renderResults(data.hits);
}
// Note: when a user selects a facet value (e.g., brand = "Nike"),
// the facet counts update to reflect only results matching that filter.
// This means other brand options may show reduced counts or disappear.
// If you want to preserve the full list of facet values regardless of
// active filters, use disjunctive facets:
// /capabilities/filtering_sorting_faceting/advanced/disjunctive_facets
function renderFacets(distribution) {
const sidebar = document.getElementById('facets-sidebar');
sidebar.innerHTML = Object.entries(distribution)
.map(([attribute, values]) => {
const items = Object.entries(values)
.sort((a, b) => b[1] - a[1])
.map(([value, count]) => {
const selected = (activeFilters[attribute] || []).includes(value);
return `<label style="display: block;">
<input type="checkbox" ${selected ? 'checked' : ''}
onchange="toggleFacet('${attribute}', '${value}')" />
${value} (${count})
</label>`;
})
.join('');
return `<div><h4>${attribute}</h4>${items}</div>`;
})
.join('');
}
function renderResults(hits) {
document.getElementById('results').innerHTML = hits
.map(hit => `<div><strong>${hit.title}</strong> by ${hit.author}</div>`)
.join('');
}
function toggleFacet(attribute, value) {
if (!activeFilters[attribute]) activeFilters[attribute] = [];
const index = activeFilters[attribute].indexOf(value);
if (index === -1) {
activeFilters[attribute].push(value);
} else {
activeFilters[attribute].splice(index, 1);
}
search();
}
document.getElementById('search-input').addEventListener('input', search);
search(); // initial load
</script>
This pattern:
- Tracks active filter selections in an
activeFiltersobject - Builds a filter string from active selections on each search
- Renders facet values as checkboxes with document counts
- Updates both facets and results when the user toggles a checkbox
Tune maxValuesPerFacet
By default, Meilisearch returns up to 100 distinct values per facet in the facetDistribution object. If your UI only displays the top 10 or 20 options, lower maxValuesPerFacet to match what you actually render:
cURL
curl \
-X PATCH 'MEILISEARCH_URL/indexes/books/settings/faceting' \
-H 'Authorization: Bearer API_KEY' \
-H 'Content-Type: application/json' \
--data-binary '{
"maxValuesPerFacet": 20
}'
Setting maxValuesPerFacet to a high value might negatively impact performance. Raising it only makes sense when you genuinely need to surface a large number of facet values at once.
Key points
- Always include the
facetsparameter in every search request so the sidebar stays updated - Facet counts reflect the current filter state, so users see accurate numbers
- Use
ORwithin the same attribute (for example, multiple genres) andANDacross attributes (for example, genre AND language) - Numeric facets include
facetStatswithminandmaxvalues, useful for range sliders
Next steps
Search with facets
Learn more about facets and facet search
Search API reference
Full documentation for the search endpoint parameters
Combine filters and sort
Add sorting to your filtered search results
