This article targets Meilisearch v1.48.2 (opens in a new tab), with sync scripts written against Node.js 24.4.0 (opens in a new tab). Every attribute a shopper can filter by — metal, purity, stone, price bracket, in-stock — has to be declared before the API will facet or filter on it, and price needs extra handling because Meilisearch counts exact values, not ranges. The commands below assume a running Meilisearch instance and an index named jewelry already populated with product documents.
Declare metal, purity, stone, price and stock as filterable attributes
Meilisearch starts every index with filterableAttributes set to an empty array, per the settings API reference (opens in a new tab). Filtering on an attribute that isn’t in the list — or passing it inside a search request’s facets array — returns an HTTP 400 naming that attribute as not filterable. For jewelry, that means metal, purity, stone, price and inStock all need to be declared before the storefront can query on them.
Since Meilisearch v1.14 (opens in a new tab), filterableAttributes accepts a per-attribute object — {attributePatterns, features: {facetSearch, filter: {equality, comparison}}} — instead of a flat string array. Use this to turn on equality-only filtering for categorical fields (metal, purity, stone) and reserve comparison operators (>=, <=, TO) for price, per the filterable attributes setting API specification (opens in a new tab). Equality-only filtering is cheaper for Meilisearch to maintain than comparison, so don’t turn it on for a field that never needs a range. Add inStock as a plain filterable boolean for the in-stock-only toggle.
Send the update as a PATCH to /indexes/{index}/settings/filterable-attributes. It applies to documents already in the index — no re-import needed.
[
{
"attributePatterns": ["metal", "purity", "stone"],
"features": { "facetSearch": true, "filter": { "equality": true, "comparison": false } }
},
{
"attributePatterns": ["price"],
"features": { "facetSearch": true, "filter": { "equality": true, "comparison": true } }
},
"inStock"
]curl -X PATCH 'http://localhost:7700/indexes/jewelry/settings/filterable-attributes' \
-H "Authorization: Bearer <YOUR_API_TOKEN>" \
-H 'Content-Type: application/json' \
--data-binary @filterable-attributes.jsonYou should now see: GET /indexes/jewelry/settings/filterable-attributes echoes the configuration above, and a search request with filter: "metal = gold" returns hits instead of a 400 error.
Return facet counts and stats in one search request
A single POST to /indexes/{index}/search with a facets array — for example ["metal", "purity", "stone", "inStock"] — returns hits, facetDistribution and facetStats in the same response, per search with facets (opens in a new tab). facetDistribution gives a document count per exact value for each requested attribute. facetStats gives the min and max of numeric attributes across the current result set, which is enough to set a price slider’s bounds but not enough to bucket it.
Facet counts are estimated by default. Setting exhaustiveFacetCount: true gets an exact count, but the documentation describes it as slower on large indexes, so leave it off for a live storefront widget where a slightly-off count next to a checkbox costs nothing.
sortFacetValuesBy defaults to alphanumeric ordering. Set it to "count" for a given facet and Meilisearch sorts that facet’s values by descending document count instead — useful for putting the most common metal at the top of the checklist. sortableAttributes is a separate setting from filterableAttributes; declare price and carat there too, so the storefront can sort hits with the sort parameter, not just filter them.
{
"q": "engagement ring",
"filter": "inStock = true",
"facets": ["metal", "purity", "stone", "inStock"],
"sort": ["price:asc"],
"sortFacetValuesBy": { "metal": "count" }
}{
"hits": [
{ "id": "ring-1042", "metal": "gold", "purity": "18k", "stone": "diamond", "price": 1250, "inStock": true }
],
"facetDistribution": {
"metal": { "gold": 214, "platinum": 58, "silver": 132 },
"purity": { "18k": 190, "14k": 96, "950": 58 },
"stone": { "diamond": 240, "sapphire": 44, "none": 120 },
"inStock": { "true": 340, "false": 24 }
},
"facetStats": {
"price": { "min": 89, "max": 18400 }
}
}You should now see: one API call returns filtered hits plus a count per facet value, which the storefront renders as checkbox counts, sorted lowest-price-first.
Bucket price into ranges since Meilisearch facets exact values, not brackets
Requesting facets: ["price"] on a numeric field returns one facetDistribution key per distinct price value present in the result set — most of them with a count of 1. That isn’t a usable bracket like “$500–$1,500 (42)”; it’s a list of individual prices. facetStats doesn’t fill the gap either: it only reports the min and max of the current result set, never intermediate buckets.
Two strategies close this gap. The first precomputes a priceBracket string field — for example "500-1500" — at indexing time and adds it to filterableAttributes. facetDistribution then buckets directly on that field in the same request as everything else, with no extra round trips. This is the right default when the storefront has a fixed set of price brackets, which most jewelry catalogs do.
The second strategy runs one filter query per bracket using the TO operator and reads the count from estimatedTotalHits. It costs one request per bracket instead of one request total, so reach for it only when bracket boundaries are dynamic or user-defined — a custom range a shopper types in, rather than a fixed checklist.
flowchart TD
A[Price filter widget] --> B{Bracket boundaries fixed?}
B -->|Yes| C[Precompute priceBracket field<br/>facet on it directly]
B -->|No, user-defined| D[One TO-range query per bracket<br/>read estimatedTotalHits]
C --> E[One search request per page load]
D --> F[One request per bracket per page load]
{
"id": "ring-1042",
"metal": "gold",
"purity": "18k",
"stone": "diamond",
"price": 1250,
"priceBracket": "500-1500",
"inStock": true
}{
"facetDistribution": {
"priceBracket": {
"0-500": 96,
"500-1500": 42,
"1500-5000": 61,
"5000+": 18
}
}
}import { MeiliSearch } from "meilisearch";
const client = new MeiliSearch({
host: "http://localhost:7700",
apiKey: "<YOUR_API_TOKEN>",
});
const brackets = [
[0, 500],
[500, 1500],
[1500, 5000],
[5000, 100000],
];
for (const [min, max] of brackets) {
const result = await client.index("jewelry").search("", {
filter: `price ${min} TO ${max}`,
limit: 0,
});
console.log(`$${min}-$${max}: ${result.estimatedTotalHits}`);
}You should now see: the storefront’s price filter widget shows a count next to each bracket, such as “$500-$1,500 (42)”, instead of one facetDistribution entry per distinct price.
Combine equality filters and price ranges into one compound expression
The TO operator is inclusive on both ends and equivalent to combining >= and <=, per the filter expression reference (opens in a new tab). price 500 TO 1500 matches a product priced at exactly 500 or 1500, not just values strictly between them.
Equality, TO ranges and boolean checks combine with AND into a single expression:
{
"filter": "metal = gold AND price 500 TO 1500 AND inStock = true"
}Multiple selected values on the same facet combine with OR, wrapped in parentheses so they group correctly against the AND clauses around them:
{
"filter": "(metal = gold OR metal = platinum) AND price 500 TO 1500 AND inStock = true"
}Building this string from a storefront’s checkbox state is mechanical: join the selected values for each attribute with OR (wrapping in parentheses when there’s more than one), then join the per-attribute clauses with AND. A shopper who checks “gold” and “platinum”, drags the price slider to 500–1500, and switches the in-stock toggle on produces exactly the second filter above, ready to send as-is.
You should now see: a storefront checkbox state — two metals checked, a price slider range, in-stock toggle on — maps directly to one filter string and one search request.
Choose addDocuments over updateDocuments for full catalog syncs
addDocuments (POST /indexes/{index}/documents) fully replaces any existing document that shares its primary key, per add and update documents (opens in a new tab). Any field present in the old version but missing from the new payload is removed. updateDocuments (PUT /indexes/{index}/documents) instead merges only top-level fields into the existing document — nested objects and arrays are replaced wholesale, not deep-merged field by field.
That distinction matters for a jewelry document with a nested stones array. A sync job that sends a partial updateDocuments payload for a stock-quantity change, and happens to include the stones array in that same payload, truncates the array to whatever partial data the payload carried. The rest of the original stones are gone, because the merge isn’t recursive.
The safe default for a full catalog sync is addDocuments with the complete product payload every time. Reserve updateDocuments for fields that are genuinely top-level and never contain a nested array — a flat inStock boolean is a reasonable candidate; a stones or variants array is not.
flowchart TD
A[Sync payload for one product] --> B{Includes every field,<br/>including nested arrays?}
B -->|Yes| C[addDocuments: full replace, safe]
B -->|No, partial fields only| D{Do the partial fields<br/>include a nested array?}
D -->|No, top-level only| E[updateDocuments: safe merge]
D -->|Yes| F[addDocuments: updateDocuments would truncate the array]
{
"id": "ring-1042",
"metal": "gold",
"purity": "18k",
"price": 1250,
"priceBracket": "500-1500",
"inStock": true,
"stones": [
{ "type": "diamond", "carat": 0.75, "clarity": "VS1" },
{ "type": "diamond", "carat": 0.25, "clarity": "VS2" }
]
}{
"id": "ring-1042",
"inStock": false,
"stones": [{ "type": "diamond", "carat": 0.75, "clarity": "VS1" }]
}A PUT of that second payload leaves ring-1042 with one stone in the index, not two — the second stone is gone even though it still exists in the source database. Sending the same change through addDocuments with the full document from product-full.json, inStock flipped to false and both stones intact, updates the flag without losing data.
You should now see: for any given sync payload, whether it risks dropping nested fields, and which endpoint avoids that risk.
Poll task status so a failed sync doesn’t stay silent
Every document write against Meilisearch is asynchronous. The API returns a taskUid immediately, while the write itself is still enqueued, per working with tasks (opens in a new tab). A write is only confirmed once GET /tasks/{taskUid} reports status: succeeded — it can just as easily resolve to status: failed, in which case the response carries an error object with a message, code, type and a documentation link.
The pitfall is a sync job that checks only the initial response containing the taskUid and calls the batch done. Every batch logs as successful, because a 202-with-a-taskUid is what a healthy request also returns. Days later, products are missing from search results with nothing in the logs to explain why, because the failure happened after the job stopped watching.
sequenceDiagram
participant Job as Sync job
participant API as Meilisearch API
participant Task as Task queue
Job->>API: POST /indexes/jewelry/documents
API-->>Job: 202 Accepted, taskUid 42
Note over Job: Naive job stops here and logs success
API->>Task: enqueue task 42
Task-->>Task: process, validation fails
Note over Task: status becomes failed
Job->>API: GET /tasks/42 (only if polling)
API-->>Job: status failed, error object attached
A minimal fix is a polling loop: submit the write, read the taskUid, poll GET /tasks/{taskUid} on an interval until the status is succeeded or failed, and alert with the logged error object on failure. The official Node.js client wraps this in waitForTask.
import { MeiliSearch } from "meilisearch";
const client = new MeiliSearch({
host: "http://localhost:7700",
apiKey: "<YOUR_API_TOKEN>",
});
const products = [
{ id: "ring-1042", metal: "gold", purity: "18k", price: 1250, inStock: true },
{ id: "ring-1043", metal: "platinum", purity: "950", price: 3400, inStock: true },
];
const enqueued = await client.index("jewelry").addDocuments(products);
const task = await client.waitForTask(enqueued.taskUid);
if (task.status === "failed") {
console.error("sync task failed", task.error);
} else {
console.log(`synced ${products.length} documents, task ${task.uid} succeeded`);
}You should now see: a deliberately malformed test document — for example the wrong data type in a filterable numeric field like price — causes the sync job to log a specific task failure instead of a false success.
Recover from one bad document without re-running the whole batch
Per the auto-batching specification (opens in a new tab), if a single task fails inside a batch, only that task fails — the rest of the batch, and the batch itself, keep processing normally. A batch only fails wholesale on an internal, non-task-specific error, such as a disk or index-level failure, not because one document had a malformed field.
That means the common assumption — one bad row means the whole nightly reindex failed, so re-run the entire job — is wrong, and re-running a multi-thousand-document sync to fix one row wastes time for no benefit. The correct move is to find the specific failed task and re-submit only the document it names.
flowchart LR
A[Batch of 5000 documents] --> B[Task 1: ok]
A --> C[Task 2: ok]
A --> D[Task 3: malformed price field]
A --> E[Task 5000: ok]
D --> F[Task 3 marked failed]
B --> G[4999 tasks succeed normally]
C --> G
E --> G
F --> H[Re-submit only the document from task 3]
Query GET /tasks, filtered to the jewelry index and failed status, to get that list:
curl -G 'http://localhost:7700/tasks' \
-H "Authorization: Bearer <YOUR_API_TOKEN>" \
--data-urlencode 'indexUids=jewelry' \
--data-urlencode 'statuses=failed'Each failed task’s error and originating batch details carry enough information to identify the document. Extract the primary key and re-submit just that product:
import { MeiliSearch } from "meilisearch";
const client = new MeiliSearch({
host: "http://localhost:7700",
apiKey: "<YOUR_API_TOKEN>",
});
const products = [
{ id: "ring-1042", metal: "gold", purity: "18k", price: 1250, inStock: true },
{ id: "ring-1043", metal: "platinum", purity: "950", price: 3400, inStock: true },
];
const failedTasks = await client.tasks.getTasks({
indexUids: ["jewelry"],
statuses: ["failed"],
});
for (const task of failedTasks.results) {
const id = task.details?.primaryKey ?? task.error?.message;
console.error(`task ${task.uid} failed for document ${id}:`, task.error);
const fixed = products.find((p) => p.id === id);
if (fixed) {
await client.index("jewelry").addDocuments([fixed]);
}
}You should now see: after a batch with one deliberately malformed row, the logs show one failed task out of many, and only that product gets re-synced instead of the whole catalog.
Limitations
This covers filtering and faceting on a single Meilisearch index; it doesn’t cover multi-index federated search, geo-filtering or synonyms, which need separate configuration. The price-bracket strategy assumes boundaries fixed at indexing time — changing the brackets means re-deriving priceBracket on every document and re-syncing, not just a settings change. The polling and recovery patterns are written against the Node.js client’s waitForTask and tasks.getTasks methods; a sync job in another language needs to implement the same poll-until-terminal-status loop against the raw HTTP task API.
None of the request or response bodies above were run against a live index for this article. They are written to match the documented v1.48.2 API shape, and should be checked against a running instance before they go anywhere near production.