Databases and Search Tutorial Intermediate

Faceted Jewelry Search with Meilisearch: Metal, Carat, Price and Stock

Configure Meilisearch filterable and sortable attributes for a jewelry catalog, bucket numeric price into ranges, and sync writes reliably from a relational database.

Tested with

Meilisearch
v1.48.2
Node
24.4.0

Before you start

  • A running Meilisearch v1.48.2 instance reachable over HTTP, with an admin API key
  • Node.js 24.4.0 and the official meilisearch JS client (or curl) available for issuing requests
  • An existing product catalog in a relational database with columns for metal, purity, stone, carat, price and stock quantity
  • A Meilisearch index already created with product documents using a stable primary key such as SKU
On this page

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.

filterable-attributes.json
[
  {
    "attributePatterns": ["metal", "purity", "stone"],
    "features": { "facetSearch": true, "filter": { "equality": true, "comparison": false } }
  },
  {
    "attributePatterns": ["price"],
    "features": { "facetSearch": true, "filter": { "equality": true, "comparison": true } }
  },
  "inStock"
]
bash
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.json

You 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.

search-request.json
{
  "q": "engagement ring",
  "filter": "inStock = true",
  "facets": ["metal", "purity", "stone", "inStock"],
  "sort": ["price:asc"],
  "sortFacetValuesBy": { "metal": "count" }
}
search-response-shape.json
{
  "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]
product-with-bracket.json
{
  "id": "ring-1042",
  "metal": "gold",
  "purity": "18k",
  "stone": "diamond",
  "price": 1250,
  "priceBracket": "500-1500",
  "inStock": true
}
facet-distribution-priceBracket.json
{
  "facetDistribution": {
    "priceBracket": {
      "0-500": 96,
      "500-1500": 42,
      "1500-5000": 61,
      "5000+": 18
    }
  }
}
price-brackets-dynamic.ts
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-single-select.json
{
  "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-multi-select.json
{
  "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]
product-full.json
{
  "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" }
  ]
}
update-truncates-stones.json
{
  "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.

sync-with-poll.ts
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:

bash
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:

resync-failed-only.ts
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.

Frequently asked questions

How often should a jewelry catalog resync with Meilisearch?

It depends on how fast stock and price change — a nightly batch sync via addDocuments is enough for catalogs that update prices daily, but a stock-sensitive storefront should push an updateDocuments call from the same transaction or event that changes stock in the database, so the in-stock facet doesn't lag behind checkout.

Does the facetSearch feature do anything different from filter on the same attribute?

Yes. facetSearch powers the /indexes/{index}/facet-search endpoint, which does typo-tolerant search within a facet's own values, such as matching "gld" to "gold" in a facet dropdown, while filter controls whether that attribute can be used in a filter expression or the search endpoint's facets array — enabling one doesn't enable the other.

Sources

  1. Meilisearch documentation Meilisearch Primary source
  2. Search with facets Meilisearch Primary source
  3. Settings API reference Meilisearch Primary source
  4. Filter expression reference Meilisearch Primary source
  5. Working with tasks Meilisearch Primary source
  6. Add and update documents Meilisearch Primary source
  7. Documents — core concepts Meilisearch Primary source
  8. Auto-batching specification (0096) Meilisearch Primary source
  9. Filterable Attributes Setting API specification (0123) Meilisearch Primary source
  10. Meilisearch 1.14 release notes Meilisearch Primary source
  11. Meilisearch v1.48.2 release notes Meilisearch (GitHub) Primary source
  12. Node.js v24.4.0 release notes Node.js Primary source
8 min read