| name | meilisearch |
| description | Meilisearch search engine integration with the JavaScript/TypeScript SDK (v0.44+). Use when working with Meilisearch indexes, search queries, filters, facets, sorting, multi-search, tenant isolation, document sync, or search settings configuration. Triggers on: meilisearch client setup, index creation, addDocuments, search with filters, facetDistribution, tenant tokens, multi-search, ranking rules, filterable/sortable/searchable attributes. Use this skill any time the user mentions Meilisearch, search indexing, full-text search in their app, or asks about tenant-scoped search — even for simple tasks like 'add search to this page', because the skill contains critical multi-tenancy and filter syntax patterns that prevent data leaks. |
Meilisearch JS/TS SDK
This skill covers the meilisearch npm package for building search features with the Meilisearch engine. It focuses on TypeScript patterns, multi-tenant safety, and the gotchas that cause real production issues.
Client Setup
import { MeiliSearch } from "meilisearch";
const client = new MeiliSearch({
host: "http://localhost:7700",
apiKey: "your-api-key",
});
- The
host must include the protocol (http:// or https://).
- In production, never use the master key as
apiKey — create scoped API keys (search-only, admin) via the Meilisearch API or dashboard.
Core Concepts
Indexes
An index holds documents. Think of it like a database table optimized for search.
const task = await client.createIndex("inventory", { primaryKey: "id" });
await client.waitForTask(task.taskUid);
const index = client.index("inventory");
Gotcha: client.index("foo") is synchronous and returns a reference even if the index doesn't exist. It will fail only when you try to use it. Use client.createIndex() to ensure it exists.
Tasks Are Async
Almost every write operation (add documents, update settings, delete) returns an EnqueuedTask with a taskUid. The operation is NOT complete when the function returns — it's enqueued.
const { taskUid } = await index.addDocuments(docs);
await client.waitForTask(taskUid);
For settings changes (filterable attributes, ranking rules), always waitForTask before searching — queries against partially-configured indexes produce wrong results.
Documents
Adding / Updating
await index.addDocuments([
{ id: "1", name: "10x10 Booth", tenant_id: "tenant-abc", status: "available" },
{ id: "2", name: "Pop-up Banner", tenant_id: "tenant-abc", status: "in_use" },
]);
await index.updateDocuments([
{ id: "1", status: "reserved" },
]);
addDocuments vs updateDocuments: addDocuments replaces the entire document if the primary key exists. updateDocuments merges — it only overwrites fields you provide. Use updateDocuments for partial updates.
Deleting
await index.deleteDocument("1");
await index.deleteDocuments(["1", "2"]);
await index.deleteDocuments({ filter: 'status = "archived"' });
await index.deleteAllDocuments();
Fetching (Non-Search)
const doc = await index.getDocument("1");
const { results, total } = await index.getDocuments({ limit: 20, offset: 0 });
Search
Basic Search
const results = await index.search("booth", {
limit: 20,
offset: 0,
attributesToRetrieve: ["id", "name", "status"],
attributesToHighlight: ["name"],
});
Filters
Filters let you narrow results by exact values, ranges, or boolean logic. Attributes must be declared as filterableAttributes before they can be used in filters.
await index.updateFilterableAttributes(["tenant_id", "status", "category", "price"]);
Filter syntax:
filter: 'status = "available"'
filter: 'price > 100'
filter: 'price >= 50 AND price <= 200'
filter: 'status IN ["available", "reserved"]'
filter: 'status = "available" AND category = "booth"'
filter: 'status = "available" OR status = "reserved"'
filter: 'NOT status = "archived"'
filter: '(status = "available" OR status = "reserved") AND category = "booth"'
filter: ['tenant_id = "abc"', 'status = "available"']
Gotcha: String values in filters MUST be wrapped in double quotes. Numeric values must NOT be quoted. status = available is invalid — it must be status = "available". But price > 100 is correct (no quotes around 100).
Sorting
await index.updateSortableAttributes(["name", "created_at", "price"]);
const results = await index.search("booth", {
sort: ["price:asc"],
sort: ["price:asc", "name:asc"],
});
Facets
Facets return value distributions for filterable attributes — used for building filter UIs (sidebar counts).
const results = await index.search("", {
facets: ["category", "status"],
filter: 'tenant_id = "abc"',
});
Only attributes listed in filterableAttributes can be used as facets.
Pagination
Two modes: offset/limit (default) and finite pagination (page/hitsPerPage).
await index.search("booth", { offset: 20, limit: 10 });
await index.search("booth", { page: 3, hitsPerPage: 10 });
Gotcha: You cannot mix the two modes in the same query. If you pass page, don't also pass offset.
Multi-Tenant Search (Critical)
In multi-tenant apps, every search MUST be scoped to a tenant. There are two approaches, and they can be layered for defense-in-depth.
Approach 1: Application-Level Filter (Simplest)
Always append tenant_id to the filter. This is what you control in your application code.
async function searchWithTenant<T extends Record<string, unknown>>(
indexName: string,
query: string,
tenantId: string,
options?: Omit<SearchParams, "filter">,
) {
const index = client.index<T>(indexName);
return index.search(query, {
...options,
filter: `tenant_id = "${tenantId}"`,
});
}
Gotcha: If you accept additional user-provided filters, combine them carefully:
const results = await index.search(query, { filter: userFilter });
const filter = userFilter
? `tenant_id = "${tenantId}" AND (${userFilter})`
: `tenant_id = "${tenantId}"`;
Approach 2: Tenant Tokens (Defense-in-Depth)
Meilisearch tenant tokens embed filter rules into the JWT itself, so even if the application filter is bypassed, the engine enforces tenant isolation.
const tenantToken = client.generateTenantToken(
searchApiKeyUid,
{
"inventory": { filter: `tenant_id = "${tenantId}"` },
"*": { filter: `tenant_id = "${tenantId}"` },
},
{
apiKey: searchApiKey,
expiresAt: new Date(Date.now() + 3600 * 1000),
},
);
This token can be sent to the frontend for client-side search without exposing the real API key.
Document Sync Safety
When syncing documents to search indexes, always validate tenant_id presence:
function syncToSearch<T extends { tenant_id: string }>(
indexName: string,
documents: T[],
) {
for (const doc of documents) {
if (!doc.tenant_id) {
throw new Error(`Document missing tenant_id: ${JSON.stringify(doc)}`);
}
}
return client.index(indexName).addDocuments(documents);
}
Multi-Search
Search across multiple indexes in a single HTTP request. Two modes: independent results or federated (merged into one list).
const results = await client.multiSearch({
queries: [
{ indexUid: "inventory", q: "booth", filter: `tenant_id = "${tenantId}"` },
{ indexUid: "events", q: "booth", filter: `tenant_id = "${tenantId}"` },
],
});
const federated = await client.multiSearch({
federation: {},
queries: [
{ indexUid: "inventory", q: "booth", filter: `tenant_id = "${tenantId}"` },
{ indexUid: "events", q: "booth", filter: `tenant_id = "${tenantId}"`,
federationOptions: { weight: 1.2 } },
],
});
Gotcha: In federated mode, each hit includes _federation.indexUid so you know which index it came from. Apply tenant filters to every query — federation does NOT inherit filters across queries.
Index Settings
Configure these at index creation/setup time, not per-query:
const index = client.index("inventory");
await index.updateSearchableAttributes(["name", "description", "tags"]);
await index.updateFilterableAttributes(["tenant_id", "category", "status", "condition"]);
await index.updateSortableAttributes(["name", "created_at", "updated_at"]);
await index.updateDisplayedAttributes(["id", "name", "status", "category"]);
await index.updateRankingRules([
"words",
"typo",
"proximity",
"attribute",
"sort",
"exactness",
]);
await index.updateDistinctAttribute("product_id");
await index.updateSynonyms({
booth: ["stand", "exhibit"],
banner: ["sign", "display"],
});
Gotcha: Settings changes are async. Always waitForTask after updating settings before running searches — otherwise you search against stale config.
API Keys
const key = await client.createKey({
description: "Search key for client app",
actions: ["search"],
indexes: ["inventory", "events"],
expiresAt: new Date("2027-01-01"),
});
const { results } = await client.getKeys();
await client.deleteKey(key.uid);
Key hierarchy: Master key > Admin keys (all actions) > Search keys (search only). Never expose the master key to clients.
Common Pitfalls
-
Forgetting waitForTask — The #1 source of "my documents aren't showing up" bugs. Write operations are enqueued, not immediate.
-
Filter on non-filterable attribute — Meilisearch silently ignores the filter rather than erroring. Your search returns unfiltered results and you might not notice. Always verify filterableAttributes are set.
-
Missing tenant_id filter — In a multi-tenant app, a search without tenant scoping leaks data across tenants. Centralize tenant filtering in a wrapper function, never rely on callers to remember.
-
String values without quotes in filters — status = available silently fails. Must be status = "available".
-
Mixing pagination modes — Using both offset and page in the same query causes unpredictable behavior.
-
Large batch imports without chunking — For >10K documents, chunk into batches of 5K-10K to avoid timeout issues. Meilisearch processes them sequentially anyway.
-
Updating settings in production without waitForTask — Settings changes temporarily make the index unavailable for that feature. Always wait for completion.
Reference Docs
For deeper topics, read these files in references/:
references/advanced-search.md — Highlighting, cropping, matching strategy, attributes to search on, show ranking score
references/error-handling.md — Task error codes, MeiliSearchApiError handling, retry patterns