​ Step 1: configure filterable attributes

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:

  1. Tracks active filter selections in an activeFilters object
  2. Builds a filter string from active selections on each search
  3. Renders facet values as checkboxes with document counts
  4. 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 facets parameter in every search request so the sidebar stays updated
  • Facet counts reflect the current filter state, so users see accurate numbers
  • Use OR within the same attribute (for example, multiple genres) and AND across attributes (for example, genre AND language)
  • Numeric facets include facetStats with min and max values, 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