
Api Search Meilisearch
- 41 installs
- 19 repo stars
- Updated July 19, 2026
- agents-inc/skills
api-search-meilisearch is a Claude Code skill that integrates the Meilisearch search engine via its TypeScript client for full-text, faceted, and geo search.
About
A Claude Code skill for adding Meilisearch full-text search to an application with the meilisearch TypeScript client for Meilisearch v1.x. It covers document indexing with async task handling, filtering, faceted navigation, geo search, index settings like ranking rules and typo tolerance, and tenant tokens for multi-tenancy. A developer uses it when building product or content search, autocomplete, or faceted filters. It stresses that filterable and sortable attributes must be configured before use or queries silently fail.
- Meilisearch client setup and async task handling with the meilisearch v0.56+ TypeScript client
- Faceted search, filtering, sorting, and geo search (_geoRadius, _geoBoundingBox)
- Multi-tenant search via tenant tokens and multi-search across indexes
Api Search Meilisearch by the numbers
- 41 all-time installs (skills.sh)
- Ranked #3,278 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 1, 2026 (Skillselion catalog sync)
api-search-meilisearch capabilities & compatibility
- Capabilities
- full text search · faceted search · geo search · multi tenancy · typo tolerance
- Use cases
- api development · web search
- Pricing
- Free
What api-search-meilisearch says it does
You MUST configure `filterableAttributes` on the index BEFORE using `filter` in search queries -- filters silently return no results if the attribute is not in `filterableAttributes`
Meilisearch is a **search engine**, not a database. It indexes documents for fast retrieval but is not the source of truth.
npx skills add https://github.com/agents-inc/skills --skill api-search-meilisearchAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 41 |
|---|---|
| repo stars | ★ 19 |
| Last updated | July 19, 2026 |
| Repository | agents-inc/skills ↗ |
What it does
Add typo-tolerant Meilisearch full-text search with facets, geo search, and multi-tenant tokens.
Who is it for?
Product and content search, faceted navigation, geo-aware search, and multi-tenant search sharing one index.
Skip if: Log aggregation or analytics queries, vector-only semantic search, or datasets under about 1,000 documents.
When should I use this skill?
You are adding typo-tolerant instant search, facets, or multi-tenant search and need correct filterable/sortable attribute setup.
What you get
A working Meilisearch integration with async task handling, facets, geo search, and tenant-scoped tokens.
By the numbers
- Targets meilisearch v0.56+ client for Meilisearch v1.x
- Recommends against use below ~1,000 documents
Files
Meilisearch Patterns
Quick Guide: Usemeilisearch(v0.56+) as the TypeScript client for Meilisearch v1.x. All write operations (document adds, setting changes, index creation) are asynchronous -- they return anEnqueuedTaskPromiseand are processed in a background queue. You MUST configurefilterableAttributesandsortableAttributeson the index before using filter/sort in search queries -- this triggers a full re-index. Useclient.index("name")for a lazy reference (no network call) vsclient.getIndex("name")which fetches from server. Use.waitTask()onEnqueuedTaskPromiseonly in scripts/seeds/tests -- never in request handlers.
---
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering, import type, named constants)(You MUST configure `filterableAttributes` on the index BEFORE using `filter` in search queries -- filters silently return no results if the attribute is not in `filterableAttributes`)
(You MUST configure `sortableAttributes` on the index BEFORE using `sort` in search queries -- sort on unconfigured attributes is silently ignored)
(You MUST NOT call `.waitTask()` in production request handlers -- it blocks the event loop polling Meilisearch until the task completes; use it only in scripts, seeds, and tests)
(You MUST set the primary key explicitly when documents lack an `id` field -- Meilisearch auto-infers primary key only on first document add, and wrong inference causes indexing failures on subsequent batches)
</critical_requirements>
---
Examples
- Core Patterns -- Client setup, document operations, search basics, task management, TypeScript integration
- Filtering & Facets -- Filter syntax, faceted search, geo search, sortable attributes
- Index Settings -- Ranking rules, typo tolerance, synonyms, stop words, searchable attributes, pagination
- Security & Multi-Tenancy -- API keys, tenant tokens, search rules, multi-tenant patterns
Additional resources:
- reference.md -- Search parameter cheat sheet, settings defaults, decision frameworks, anti-patterns
---
Auto-detection: Meilisearch, meilisearch, MeiliSearch, meilisearch-js, client.index, addDocuments, updateDocuments, multiSearch, filterableAttributes, sortableAttributes, searchableAttributes, rankingRules, typoTolerance, tenant token, generateTenantToken, EnqueuedTaskPromise, waitTask, facets, \_geoRadius, \_geoBoundingBox, \_geoPoint, instantsearch
When to use:
- Adding full-text search to an application (product search, content search, autocomplete)
- Implementing faceted navigation (category filters, price ranges, attribute counts)
- Building geo-aware search (find nearby, sort by distance)
- Multi-tenant search where tenants share an index but see only their documents
- Search across multiple indexes simultaneously (multi-search, federated search)
- Real-time document indexing with typo-tolerant instant search
Key patterns covered:
- Client initialization and connection management
- Document CRUD operations with async task handling
- Search with filtering, sorting, facets, and highlighting
- Geo search with
_geoRadius,_geoBoundingBox, and distance sorting - Multi-search and federated search across indexes
- Index settings configuration (ranking rules, typo tolerance, synonyms, stop words)
- Tenant tokens for multi-tenant access control
- TypeScript generics for typed search results
When NOT to use:
- Full-text search on a relational database (use your database's built-in full-text search for simple cases)
- Log aggregation or analytics queries (use a dedicated log/analytics search engine)
- Vector-only semantic search without keyword component (use a dedicated vector database)
- Searching fewer than ~1,000 documents (client-side filtering is simpler)
---
<philosophy>
Philosophy
Meilisearch is a search engine, not a database. It indexes documents for fast retrieval but is not the source of truth. The core principles:
1. Async everything -- All write operations (documents, settings, index management) are queued and processed asynchronously. The API returns a task ID immediately. Design your application to not depend on instant indexing. 2. Configure before search -- Filterable attributes, sortable attributes, and searchable attributes must be configured BEFORE they can be used in search queries. This triggers a re-index of all documents. 3. Typo tolerance by default -- Meilisearch handles typos out of the box. Tune typoTolerance settings to disable it for specific fields (product codes, serial numbers) rather than trying to implement exact matching manually. 4. Primary key matters -- Every document needs a unique primary key. Meilisearch auto-infers it from the first document, but explicit is better than implicit. Set it on index creation. 5. Search, don't query -- Meilisearch is optimized for human search queries (typo-tolerant, prefix matching, ranking). It is not a SQL replacement. Use filters for structured queries, search for natural language.
</philosophy>
---
<patterns>
Core Patterns
Pattern 1: Client Setup
Initialize the client with host and API key. Use client.index() for a lazy local reference (no network call) -- prefer this over client.getIndex() which hits the server.
// Good Example -- Typed client setup
import { Meilisearch } from "meilisearch";
function createSearchClient(): Meilisearch {
const host = process.env.MEILISEARCH_URL;
const apiKey = process.env.MEILISEARCH_API_KEY;
if (!host) {
throw new Error("MEILISEARCH_URL environment variable is required");
}
return new Meilisearch({ host, apiKey });
}
export { createSearchClient };Why good: Environment variable validation, named export, apiKey is optional (Meilisearch allows unauthenticated access in development)
// Bad Example -- Hardcoded credentials
import { Meilisearch } from "meilisearch";
const client = new Meilisearch({
host: "http://localhost:7700",
apiKey: "masterKey123",
});Why bad: Hardcoded host and API key leak in version control, master key exposed (use scoped API keys in production)
See examples/core.md for health checks, AbortController usage, and custom request configuration.
---
Pattern 2: Document Indexing (Async)
All document operations return EnqueuedTaskPromise. The documents are NOT searchable immediately -- they enter a task queue.
// Good Example -- Add documents with explicit primary key
interface Product {
productId: string;
name: string;
description: string;
price: number;
categories: string[];
}
const index = client.index<Product>("products");
// First add: set primary key explicitly
const task = await index.addDocuments(products, { primaryKey: "productId" });
// task.taskUid: number -- use this to track progressWhy good: Explicit primary key prevents auto-inference issues, TypeScript generic provides type safety on document shape
// Bad Example -- Relying on auto-inference
const index = client.index("products");
await index.addDocuments(products); // No primary key specified
// If first document has both 'id' and 'productId', Meilisearch guesses wrongWhy bad: Meilisearch infers primary key from the first document -- if it guesses wrong, all subsequent adds may fail with primary key conflicts
See examples/core.md for update, delete, batching, and task management patterns.
---
Pattern 3: Search with Filters
Filters require filterableAttributes to be configured first. Filter syntax uses SQL-like operators with AND/OR/NOT.
// Good Example -- Search with filter and sort
const MIN_PRICE = 10;
const MAX_PRICE = 100;
const results = await index.search("wireless headphones", {
filter: `price >= ${MIN_PRICE} AND price <= ${MAX_PRICE} AND categories = "electronics"`,
sort: ["price:asc"],
limit: 20,
});
// results.hits: Product[], results.estimatedTotalHits: numberWhy good: Named constants for filter values, combined text search with structured filtering, explicit limit
// Bad Example -- Filtering without configuring filterableAttributes
const index = client.index("products");
// MISSING: await index.updateFilterableAttributes(["price", "categories"])
const results = await index.search("headphones", {
filter: "price < 50", // Returns 0 results -- silently fails!
});Why bad: Filters return empty results without error when the attribute is not in filterableAttributes -- this is the most common Meilisearch gotcha
See examples/filtering.md for faceted search, geo filters, and advanced filter syntax.
---
Pattern 4: Multi-Search
Search across multiple indexes in a single request. Federated search merges results into a unified list.
// Good Example -- Multi-search across indexes
const results = await client.multiSearch({
queries: [
{ indexUid: "products", q: "laptop", limit: 5 },
{ indexUid: "articles", q: "laptop review", limit: 5 },
],
});
// results.results[0].hits -- products
// results.results[1].hits -- articles
// Federated search -- merged results
const federated = await client.multiSearch({
federation: {},
queries: [
{ indexUid: "products", q: "laptop" },
{ indexUid: "articles", q: "laptop" },
],
});
// federated.hits -- single merged list sorted by relevanceWhy good: Single network request for multiple index searches, federated search provides unified ranking across indexes
See examples/core.md for federated search with query weighting.
---
Pattern 5: Task Management
Write operations are async. Use task UIDs to track progress. Use .waitTask() only in scripts and tests.
// Good Example -- Task tracking in a seed script
const task = await index.addDocuments(products, {
primaryKey: "productId",
});
// In scripts/seeds: wait for completion
const completed = await task.waitTask();
if (completed.status === "failed") {
throw new Error(`Indexing failed: ${completed.error?.message}`);
}
console.log(`Indexed ${completed.details?.indexedDocuments} documents`);Why good: .waitTask() used in seed script (not request handler), error status checked, task details inspected
// Bad Example -- Waiting in a request handler
app.post("/products", async (req, res) => {
const task = await index.addDocuments([req.body]);
await task.waitTask(); // BLOCKS the request until Meilisearch processes the task!
res.json({ success: true });
});Why bad: .waitTask() polls Meilisearch repeatedly, blocking the request handler -- tasks may take seconds or minutes depending on queue depth
See examples/core.md for batch task management and task status polling.
---
Pattern 6: Index Settings Configuration
Settings changes trigger a full re-index. Configure settings BEFORE adding documents to avoid re-indexing.
// Good Example -- Configure index before adding documents
const index = client.index("products");
// Step 1: Configure settings (triggers re-index)
await index
.updateSettings({
filterableAttributes: ["price", "categories", "brand", "inStock"],
sortableAttributes: ["price", "createdAt"],
searchableAttributes: ["name", "description", "brand"],
rankingRules: [
"words",
"typo",
"proximity",
"attribute",
"sort",
"exactness",
"price:asc",
],
typoTolerance: {
disableOnAttributes: ["sku", "barcode"],
},
synonyms: {
phone: ["smartphone", "mobile"],
laptop: ["notebook"],
},
})
.waitTask(); // OK in setup script
// Step 2: Add documents (indexes with correct settings)
await index.addDocuments(products, { primaryKey: "productId" });Why good: Settings configured before documents avoids double re-index, typo tolerance disabled on exact-match fields, synonyms defined for common aliases
See examples/settings.md for all settings options, stop words, and pagination configuration.
</patterns>
---
<decision_framework>
Decision Framework
Which Search Approach?
What kind of search do I need?
-- Single index, text query? -> index.search(query, options)
-- Multiple indexes, separate results? -> client.multiSearch({ queries })
-- Multiple indexes, merged results? -> client.multiSearch({ federation: {}, queries })
-- Browse/filter without text? -> index.search("", { filter, sort }) (placeholder search)Filter vs Search?
How should users find data?
-- Natural language, typo-tolerant? -> Use the `q` parameter (search)
-- Exact attribute matching? -> Use `filter` parameter
-- Both? -> Combine: search("query", { filter: "category = 'X'" })
-- Browsing without a query? -> Placeholder search: search("", { filter, sort })Pagination Strategy?
How should I paginate results?
-- Infinite scroll / load more? -> Use offset + limit (default)
-- Page numbers (page 1, 2, 3)? -> Use page + hitsPerPage
-- NOTE: Default maxTotalHits is 1000 -- increase in pagination settings if neededTask Management Strategy?
How should I handle async operations?
-- Seed script / migration? -> .waitTask() is fine
-- Test setup? -> .waitTask() to ensure data is ready
-- API request handler? -> Fire-and-forget, return task UID to client
-- Need confirmation? -> Return taskUid, let client poll GET /tasks/:uid</decision_framework>
---
<red_flags>
RED FLAGS
High Priority Issues:
- Filtering or sorting without first configuring
filterableAttributes/sortableAttributes-- filters silently return empty results, sorts are silently ignored - Using
.waitTask()in production request handlers -- blocks the event loop, causes request timeouts under load - Using the master key in client-side code -- exposes full admin access; use search-only API keys or tenant tokens
- Not setting the primary key explicitly -- Meilisearch auto-infers from the first document and may pick the wrong field, causing all subsequent indexing to fail
Medium Priority Issues:
- Configuring settings AFTER adding documents -- triggers a full re-index of all documents, which can take minutes on large datasets
- Exceeding the default
maxTotalHits: 1000pagination limit -- search silently caps results at 1000; increase viapagination.maxTotalHitsin settings if you need deeper pagination - Using
AND/ORin filters without parentheses --ANDhas higher precedence thanOR, leading to unexpected filter results - Not handling task failures -- failed tasks leave the index unchanged but the error is only visible by checking the task status
Gotchas & Edge Cases:
filterableAttributesmust include_geofor geo search -- adding documents with_geofields is not enough, the attribute must be explicitly listedclient.index("name")does NOT create the index or verify it exists -- it returns a local reference; useclient.createIndex("name")to actually create it- Empty string search (
search("")) is a valid "placeholder search" -- returns all documents matching filters, useful for browsing/faceted navigation - Meilisearch task queue has a ~10 GiB limit -- if the queue fills up, new write operations fail with
no_space_left_on_device; delete finished tasks periodically - Synonyms do NOT apply to filters -- filtering by "phone" will not match documents with "smartphone" even if they are configured as synonyms
_geofield format is strict: must be{ lat: number, lng: number }--longitudeinstead oflngcausesinvalid_document_geo_fielderrors- Setting changes (filterableAttributes, etc.) queue as tasks too -- they are not instant; wait for the task to complete before relying on the new settings
hitsPerPageandpageparameters overrideoffset/limit-- do not mix both pagination styles in the same query- Default
maxTotalHitsis 1000 -- even with offset-based pagination, you cannot access documents beyond position 1000 without increasing this setting
</red_flags>
---
<critical_reminders>
CRITICAL REMINDERS
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering, import type, named constants)(You MUST configure `filterableAttributes` on the index BEFORE using `filter` in search queries -- filters silently return no results if the attribute is not in `filterableAttributes`)
(You MUST configure `sortableAttributes` on the index BEFORE using `sort` in search queries -- sort on unconfigured attributes is silently ignored)
(You MUST NOT call `.waitTask()` in production request handlers -- it blocks the event loop polling Meilisearch until the task completes; use it only in scripts, seeds, and tests)
(You MUST set the primary key explicitly when documents lack an `id` field -- Meilisearch auto-infers primary key only on first document add, and wrong inference causes indexing failures on subsequent batches)
Failure to follow these rules will cause silent search failures, request timeouts, and indexing errors.
</critical_reminders>
Meilisearch -- Core Pattern Examples
Client setup, document operations, search basics, task management, and TypeScript integration. Reference from SKILL.md.
Related examples:
- filtering.md -- Filters, facets, geo search
- settings.md -- Ranking rules, typo tolerance, synonyms
- security.md -- API keys, tenant tokens, multi-tenancy
---
Client Setup with Health Check
import { Meilisearch } from "meilisearch";
const HEALTH_TIMEOUT_MS = 3000;
function createSearchClient(): Meilisearch {
const host = process.env.MEILISEARCH_URL;
const apiKey = process.env.MEILISEARCH_API_KEY;
if (!host) {
throw new Error("MEILISEARCH_URL environment variable is required");
}
return new Meilisearch({ host, apiKey });
}
async function verifyConnection(client: Meilisearch): Promise<boolean> {
try {
const health = await Promise.race([
client.health(),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("Health check timed out")),
HEALTH_TIMEOUT_MS,
),
),
]);
return health.status === "available";
} catch {
return false;
}
}
export { createSearchClient, verifyConnection };Why good: Health check with timeout via Promise.race prevents hanging on unresponsive server (note: client.health() accepts no parameters), named constant for timeout duration, graceful false return on failure
---
Document Operations
Adding Documents
import type { Meilisearch } from "meilisearch";
interface Product {
productId: string;
name: string;
description: string;
price: number;
categories: string[];
brand: string;
inStock: boolean;
}
const INDEX_NAME = "products";
const PRIMARY_KEY = "productId";
async function indexProducts(
client: Meilisearch,
products: Product[],
): Promise<number> {
const index = client.index<Product>(INDEX_NAME);
const task = await index.addDocuments(products, {
primaryKey: PRIMARY_KEY,
});
return task.taskUid;
}
export { indexProducts };
export type { Product };Why good: Explicit primary key, typed index with generic, returns task UID for tracking, named constants for index name and primary key
Updating Documents (Partial)
// updateDocuments merges fields -- only specified fields are updated
async function updateProductPrice(
client: Meilisearch,
productId: string,
newPrice: number,
): Promise<number> {
const index = client.index<Product>(INDEX_NAME);
const task = await index.updateDocuments([{ productId, price: newPrice }]);
return task.taskUid;
}
export { updateProductPrice };Why good: updateDocuments performs a partial merge -- only price is updated, other fields are preserved. Compare with addDocuments which replaces the entire document.
Deleting Documents
// Delete by ID
async function deleteProduct(
client: Meilisearch,
productId: string,
): Promise<number> {
const index = client.index<Product>(INDEX_NAME);
const task = await index.deleteDocument(productId);
return task.taskUid;
}
// Delete by filter (batch deletion)
async function deleteDiscontinuedProducts(
client: Meilisearch,
): Promise<number> {
const index = client.index<Product>(INDEX_NAME);
const task = await index.deleteDocuments({
filter: "inStock = false",
});
return task.taskUid;
}
export { deleteProduct, deleteDiscontinuedProducts };Why good: Delete by filter allows batch deletion without knowing individual IDs, both methods return task UIDs for tracking
Important: Delete by filter requires inStock to be in filterableAttributes. The filter-based delete follows the same rules as search filters.
---
Search Patterns
Basic Search with Highlighting
import type { Meilisearch, SearchResponse } from "meilisearch";
const DEFAULT_SEARCH_LIMIT = 20;
async function searchProducts(
client: Meilisearch,
query: string,
options?: { limit?: number },
): Promise<SearchResponse<Product>> {
const index = client.index<Product>(INDEX_NAME);
return index.search(query, {
limit: options?.limit ?? DEFAULT_SEARCH_LIMIT,
attributesToHighlight: ["name", "description"],
highlightPreTag: "<mark>",
highlightPostTag: "</mark>",
});
}
// Usage:
// const results = await searchProducts(client, "wireless headphones");
// results.hits[0].name -- original
// results.hits[0]._formatted.name -- "wireless <mark>headphones</mark>"
export { searchProducts };Why good: Named constant for default limit, typed search response, highlighting configured with custom tags, _formatted field contains highlighted versions
Placeholder Search (Browse Mode)
// Empty query returns all documents matching filters -- useful for category browsing
async function browseProducts(
client: Meilisearch,
filters: { category?: string; brand?: string; sort?: string },
): Promise<SearchResponse<Product>> {
const index = client.index<Product>(INDEX_NAME);
const filterParts: string[] = [];
if (filters.category) {
filterParts.push(`categories = "${filters.category}"`);
}
if (filters.brand) {
filterParts.push(`brand = "${filters.brand}"`);
}
return index.search("", {
filter: filterParts.length > 0 ? filterParts.join(" AND ") : undefined,
sort: filters.sort ? [filters.sort] : undefined,
limit: DEFAULT_SEARCH_LIMIT,
});
}
export { browseProducts };Why good: Empty string query is a valid "placeholder search" that returns all documents matching the filter, useful for browse/filter-only UIs
---
Task Management
Seed Script with waitTask
import { Meilisearch } from "meilisearch";
const INDEX_NAME = "products";
const PRIMARY_KEY = "productId";
async function seedSearchIndex(
client: Meilisearch,
products: Product[],
): Promise<void> {
const index = client.index<Product>(INDEX_NAME);
// Step 1: Configure settings and wait for completion
const settingsTask = await index
.updateSettings({
filterableAttributes: ["price", "categories", "brand", "inStock"],
sortableAttributes: ["price", "createdAt"],
searchableAttributes: ["name", "description", "brand"],
})
.waitTask();
if (settingsTask.status === "failed") {
throw new Error(`Settings update failed: ${settingsTask.error?.message}`);
}
// Step 2: Add documents and wait for completion
const docsTask = await index
.addDocuments(products, { primaryKey: PRIMARY_KEY })
.waitTask();
if (docsTask.status === "failed") {
throw new Error(`Document indexing failed: ${docsTask.error?.message}`);
}
console.log(
`Indexed ${docsTask.details?.indexedDocuments} of ${docsTask.details?.receivedDocuments} documents`,
);
}
export { seedSearchIndex };Why good: Settings configured BEFORE documents (avoids re-index), .waitTask() used in seed script (not request handler), task failure checked, indexing statistics logged
Fire-and-Forget in API Handlers
// In request handlers: return task UID, don't wait
async function handleProductCreate(
client: Meilisearch,
product: Product,
): Promise<{ taskUid: number }> {
const index = client.index<Product>(INDEX_NAME);
const task = await index.addDocuments([product], {
primaryKey: PRIMARY_KEY,
});
// Do NOT call .waitTask() here -- return immediately
return { taskUid: task.taskUid };
}
export { handleProductCreate };Why good: Returns task UID immediately without blocking, client can poll task status separately if needed
Batch Operations with Multiple Tasks
async function reindexAll(
client: Meilisearch,
products: Product[],
): Promise<void> {
const index = client.index<Product>(INDEX_NAME);
// Delete all existing documents
const deleteTask = await index.deleteAllDocuments().waitTask();
if (deleteTask.status === "failed") {
throw new Error(`Delete failed: ${deleteTask.error?.message}`);
}
// Re-add all documents
const addTask = await index
.addDocuments(products, { primaryKey: PRIMARY_KEY })
.waitTask();
if (addTask.status === "failed") {
throw new Error(`Reindex failed: ${addTask.error?.message}`);
}
}
export { reindexAll };Why good: Sequential task execution in a script, each task waited and checked before proceeding
---
Multi-Search
Standard Multi-Search (Separate Results)
import type { Meilisearch } from "meilisearch";
async function globalSearch(
client: Meilisearch,
query: string,
): Promise<{
products: Product[];
articles: Article[];
}> {
const results = await client.multiSearch({
queries: [
{
indexUid: "products",
q: query,
limit: 5,
attributesToRetrieve: ["productId", "name", "price"],
},
{
indexUid: "articles",
q: query,
limit: 5,
attributesToRetrieve: ["articleId", "title", "summary"],
},
],
});
return {
products: results.results[0].hits as Product[],
articles: results.results[1].hits as Article[],
};
}
export { globalSearch };Why good: Single network request for searching two indexes, each query has independent parameters, results array order matches queries array order
Federated Search (Merged Results)
// Federated search merges results from multiple indexes into one ranked list
async function federatedSearch(
client: Meilisearch,
query: string,
): Promise<unknown[]> {
const results = await client.multiSearch({
federation: {},
queries: [
{ indexUid: "products", q: query },
{ indexUid: "articles", q: query },
],
});
// results.hits is a single merged list
return results.hits;
}
export { federatedSearch };Why good: federation: {} triggers merged results, single ranked list across all indexes
---
TypeScript Integration
Typed Search Results
import type { Meilisearch, SearchResponse, Hits } from "meilisearch";
interface Movie {
id: string;
title: string;
genres: string[];
releaseDate: number;
rating: number;
}
// Generic type flows through to hits
async function searchMovies(
client: Meilisearch,
query: string,
): Promise<Hits<Movie>> {
const index = client.index<Movie>("movies");
const response = await index.search(query, {
filter: "rating > 7",
limit: 10,
});
// response.hits is typed as Hits<Movie>
return response.hits;
}
export { searchMovies };
export type { Movie };Why good: client.index<Movie>("movies") propagates the type to search() results, Hits<Movie> type used for return value
---
AbortController for Cancellable Search
// Useful for autocomplete: cancel previous search when user types again
function createCancellableSearch(client: Meilisearch) {
let controller: AbortController | null = null;
return async function search(
query: string,
): Promise<SearchResponse<Product> | null> {
// Cancel previous in-flight request
if (controller) {
controller.abort();
}
controller = new AbortController();
try {
const index = client.index<Product>(INDEX_NAME);
return await index.search(
query,
{ limit: 10 },
{
signal: controller.signal,
},
);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
return null; // Request was cancelled -- expected
}
throw err;
}
};
}
export { createCancellableSearch };Why good: Each new search cancels the previous in-flight request, AbortError handled gracefully (not thrown), closure maintains controller state
---
_Full skill documentation: SKILL.md | Quick reference: reference.md_
Meilisearch -- Filtering & Facets Examples
Filter syntax, faceted search, geo search, and sorting patterns. Reference from SKILL.md.
Prerequisites: Understand client setup and document operations from core.md first.
Related examples:
- core.md -- Client setup, document operations, search basics
- settings.md -- Configuring filterableAttributes, sortableAttributes
- security.md -- Tenant tokens restrict filters per user
---
Configuring Filterable and Sortable Attributes
This must happen BEFORE any filter or sort is used in search. Changes trigger a re-index.
import type { Meilisearch } from "meilisearch";
const INDEX_NAME = "products";
async function configureProductIndex(client: Meilisearch): Promise<void> {
const index = client.index(INDEX_NAME);
await index
.updateSettings({
filterableAttributes: [
"price",
"categories",
"brand",
"inStock",
"rating",
"_geo", // Required for geo search
],
sortableAttributes: [
"price",
"rating",
"createdAt",
"_geo", // Required for geo sort
],
})
.waitTask(); // OK in setup/seed script
}
export { configureProductIndex };Why good: _geo explicitly listed in both filterable and sortable (required for geo search), .waitTask() used in setup script, all filter/sort attributes declared upfront
---
Filter Syntax Examples
String Equality
// Exact match (case-sensitive for filters)
const results = await index.search("shoes", {
filter: 'brand = "Nike"',
});
// Multiple values with IN
const results2 = await index.search("shoes", {
filter: 'brand IN ["Nike", "Adidas", "Puma"]',
});Numeric Comparison
const MIN_PRICE = 50;
const MAX_PRICE = 200;
const MIN_RATING = 4;
const results = await index.search("headphones", {
filter: `price >= ${MIN_PRICE} AND price <= ${MAX_PRICE} AND rating >= ${MIN_RATING}`,
});Boolean and Existence
// Boolean filter
const results = await index.search("laptop", {
filter: "inStock = true",
});
// Existence check -- documents where field exists
const results2 = await index.search("", {
filter: "discount EXISTS",
});
// Null check
const results3 = await index.search("", {
filter: "deletedAt IS NULL",
});Combining with Parentheses
// AND has higher precedence than OR -- always use parentheses
// Correct: electronics or computers, both under $500
const results = await index.search("", {
filter:
'(categories = "electronics" OR categories = "computers") AND price < 500',
});
// WITHOUT parentheses: "electronics" OR ("computers" AND price < 500)
// This is almost certainly NOT what you wantWhy good: Explicit parentheses prevent precedence bugs, named constants for filter values
Negation
// Exclude specific values
const results = await index.search("phone", {
filter: 'NOT brand = "Apple"',
});
// Combine negation with other filters
const results2 = await index.search("", {
filter: 'categories = "electronics" AND NOT brand IN ["Apple", "Samsung"]',
});---
Faceted Search
Facets return counts of matching documents per attribute value. Useful for building filter UIs.
import type { Meilisearch, SearchResponse } from "meilisearch";
interface FacetedSearchResult {
hits: Product[];
facetDistribution: Record<string, Record<string, number>>;
totalHits: number;
}
async function facetedSearch(
client: Meilisearch,
query: string,
activeFilters?: { categories?: string; brand?: string },
): Promise<FacetedSearchResult> {
const index = client.index<Product>("products");
const filterParts: string[] = [];
if (activeFilters?.categories) {
filterParts.push(`categories = "${activeFilters.categories}"`);
}
if (activeFilters?.brand) {
filterParts.push(`brand = "${activeFilters.brand}"`);
}
const response = await index.search(query, {
facets: ["categories", "brand", "inStock"],
filter: filterParts.length > 0 ? filterParts.join(" AND ") : undefined,
limit: 20,
});
return {
hits: response.hits,
facetDistribution: response.facetDistribution ?? {},
totalHits: response.estimatedTotalHits ?? 0,
};
}
// Usage:
// const result = await facetedSearch(client, "laptop");
// result.facetDistribution.brand == { "Apple": 12, "Dell": 8, "Lenovo": 6 }
// result.facetDistribution.categories == { "electronics": 20, "computers": 15 }
export { facetedSearch };Why good: facets parameter returns count distribution per attribute value, facet counts reflect the CURRENT filter state (applying a category filter updates brand counts), handles null facetDistribution
Important: Faceted attributes must be in filterableAttributes. The facets parameter only controls which attribute counts are returned -- it does not enable filtering.
---
Sorting
// Sort by single attribute
const results = await index.search("laptop", {
sort: ["price:asc"],
});
// Sort by multiple attributes (tiebreaker)
const results2 = await index.search("laptop", {
sort: ["rating:desc", "price:asc"],
});
// Sort by distance (geo)
const results3 = await index.search("restaurant", {
sort: ["_geoPoint(48.8566, 2.3522):asc"], // Sort by distance from Paris
});Important: Sort attributes must be in sortableAttributes. The sort ranking rule must be present in rankingRules (it is by default).
---
Geo Search
Configuring Geo Data
Documents with geographic coordinates must use the _geo field:
interface Restaurant {
id: string;
name: string;
cuisine: string;
_geo: {
lat: number;
lng: number; // Must be "lng", NOT "longitude"
};
}
const restaurants: Restaurant[] = [
{
id: "r1",
name: "Chez Pierre",
cuisine: "french",
_geo: { lat: 48.8566, lng: 2.3522 },
},
];Important: The _geo field must use exactly lat and lng as keys. Using latitude/longitude causes an invalid_document_geo_field error and the document fails to index.
Filtering by Radius
const SEARCH_RADIUS_METERS = 5000; // 5km
// Find restaurants within 5km of a point
const results = await index.search("", {
filter: `_geoRadius(48.8566, 2.3522, ${SEARCH_RADIUS_METERS})`,
});Filtering by Bounding Box
// Find within a rectangular area
// _geoBoundingBox([topLeftLat, topLeftLng], [bottomRightLat, bottomRightLng])
const results = await index.search("", {
filter: "_geoBoundingBox([48.90, 2.25], [48.80, 2.42])",
});Sorting by Distance
// Sort results by distance from user's location
async function searchNearby(
client: Meilisearch,
query: string,
userLat: number,
userLng: number,
): Promise<SearchResponse<Restaurant>> {
const index = client.index<Restaurant>("restaurants");
return index.search(query, {
sort: [`_geoPoint(${userLat}, ${userLng}):asc`],
limit: 20,
});
}
// Each hit includes _geoDistance (meters from the point) in the response
// results.hits[0]._geoDistance == 342
export { searchNearby };Why good: _geoPoint(lat, lng):asc sorts by proximity, _geoDistance automatically included in results when geo sorting
Combining Geo with Other Filters
const NEARBY_RADIUS_METERS = 2000;
const results = await index.search("pizza", {
filter: `_geoRadius(48.8566, 2.3522, ${NEARBY_RADIUS_METERS}) AND cuisine = "italian"`,
sort: ["_geoPoint(48.8566, 2.3522):asc"],
});Why good: Geo filter (radius) combined with attribute filter (cuisine), sorted by proximity
---
Distinct Attribute
Deduplicate results by a field -- useful when the same product appears in multiple variants.
// Return only one result per product (even if multiple color variants exist)
const results = await index.search("sneakers", {
distinct: "productGroupId",
});Important: The distinct attribute must be in filterableAttributes to work.
---
_Full skill documentation: SKILL.md | Quick reference: reference.md_
Meilisearch -- Security & Multi-Tenancy Examples
API keys, tenant tokens, search rules, and multi-tenant patterns. Reference from SKILL.md.
Prerequisites: Understand client setup from core.md first.
Related examples:
- core.md -- Client setup, document operations
- filtering.md -- Filter syntax (tenant tokens use filters to restrict access)
---
API Key Types
Meilisearch has three tiers of API keys:
| Key Type | Access Level | Use Where |
|---|---|---|
| Master key | Full admin access (all operations) | Server-side only, environment var |
| Admin key | Index management, documents, settings | Server-side backend only |
| Search key | Search only (read-only) | Can be exposed to frontend |
| Tenant token | Search + per-user filter restrictions | Frontend, multi-tenant apps |
---
Creating Scoped API Keys
import type { Meilisearch } from "meilisearch";
async function createSearchOnlyKey(client: Meilisearch): Promise<string> {
const key = await client.createKey({
description: "Public search key for frontend",
actions: ["search"],
indexes: ["products", "articles"], // Restrict to specific indexes
expiresAt: new Date("2026-12-31"),
});
return key.key;
}
async function createAdminKey(client: Meilisearch): Promise<string> {
const key = await client.createKey({
description: "Backend admin key for indexing",
actions: [
"documents.add",
"documents.delete",
"settings.update",
"indexes.create",
],
indexes: ["products"],
expiresAt: null, // No expiration
});
return key.key;
}
export { createSearchOnlyKey, createAdminKey };Why good: Principle of least privilege -- frontend gets search-only access to specific indexes, backend gets only the actions it needs, expiration dates on keys
---
Tenant Tokens for Multi-Tenancy
Tenant tokens restrict which documents a user can see within a shared index. The token is a JWT generated server-side and passed to the frontend.
How Tenant Tokens Work
1. All tenants' documents live in a single index with a tenantId field 2. tenantId must be in filterableAttributes 3. Server generates a JWT with a filter rule: tenantId = "tenant-123" 4. Frontend uses this JWT as its API key 5. Meilisearch automatically applies the filter to every search
Generating Tenant Tokens (Server-Side)
import { generateTenantToken } from "meilisearch/token";
const SEARCH_API_KEY = process.env.MEILISEARCH_SEARCH_KEY!;
const SEARCH_API_KEY_UID = process.env.MEILISEARCH_SEARCH_KEY_UID!;
async function createTenantSearchToken(tenantId: string): Promise<string> {
const TOKEN_EXPIRY_HOURS = 24;
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + TOKEN_EXPIRY_HOURS);
const token = await generateTenantToken({
apiKey: SEARCH_API_KEY,
apiKeyUid: SEARCH_API_KEY_UID,
searchRules: {
products: {
filter: `tenantId = "${tenantId}"`,
},
},
expiresAt,
});
return token;
}
export { createTenantSearchToken };Why good: Short-lived tokens (24 hours), filter scoped to specific tenant, uses search-only API key (not master key), apiKeyUid is the UID of the search key (not the key itself)
Using Tenant Tokens (Client-Side)
import { Meilisearch } from "meilisearch";
// Token received from your authentication endpoint
function createTenantClient(tenantToken: string): Meilisearch {
return new Meilisearch({
host: "https://search.example.com",
apiKey: tenantToken, // JWT token acts as the API key
});
}
// All searches through this client are automatically filtered to the tenant
// Even if the user manipulates the search query, they cannot see other tenants' data
export { createTenantClient };Why good: Token used as API key -- Meilisearch validates and extracts the filter rule, the filter cannot be bypassed by the frontend
Search Rules Patterns
// Restrict to specific index with filter
const singleIndexRule = {
products: {
filter: `tenantId = "${tenantId}"`,
},
};
// Restrict to multiple indexes
const multiIndexRule = {
products: {
filter: `tenantId = "${tenantId}"`,
},
orders: {
filter: `customerId = "${tenantId}"`,
},
};
// Wildcard: apply to all indexes
const wildcardRule = {
"*": {
filter: `organizationId = "${orgId}"`,
},
};
// No filter, just index access restriction
const indexAccessOnly = {
products: null, // Full access to products index, no filter
};---
Multi-Tenant Index Setup
import type { Meilisearch } from "meilisearch";
interface TenantDocument {
id: string;
tenantId: string; // Required for multi-tenancy
[key: string]: unknown;
}
const INDEX_NAME = "products";
async function setupMultiTenantIndex(client: Meilisearch): Promise<void> {
const index = client.index(INDEX_NAME);
await index
.updateSettings({
// tenantId MUST be filterable for tenant tokens to work
filterableAttributes: ["tenantId", "price", "categories", "brand"],
// tenantId should NOT be searchable (users shouldn't search for tenant IDs)
searchableAttributes: ["name", "description", "brand"],
})
.waitTask();
}
export { setupMultiTenantIndex };Why good: tenantId is filterable (required for tenant tokens) but NOT searchable (prevents leaking tenant IDs in search results)
---
API Key Rotation
import type { Meilisearch, Key } from "meilisearch";
async function rotateSearchKey(
client: Meilisearch,
oldKeyUid: string,
): Promise<Key> {
// 1. Create new key with same permissions
const newKey = await client.createKey({
description: "Search key (rotated)",
actions: ["search"],
indexes: ["products", "articles"],
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year
});
// 2. Update your application to use the new key
// ... deploy with new key ...
// 3. Delete old key after grace period
// await client.deleteKey(oldKeyUid);
return newKey;
}
export { rotateSearchKey };Important: Deleting an API key immediately invalidates all tenant tokens signed with that key. Always ensure a grace period where both old and new keys are valid.
---
Common Security Mistakes
// BAD: Master key in frontend code
const client = new Meilisearch({
host: "https://search.example.com",
apiKey: "master-key-abc123", // Exposes full admin access
});
// BAD: Generating tenant token with master key
const token = await generateTenantToken({
apiKey: "master-key-abc123", // Tokens MUST be signed with a search API key
apiKeyUid: "...",
searchRules: { products: { filter: `tenantId = "t1"` } },
});
// BAD: Token without expiration in multi-tenant app
const token2 = await generateTenantToken({
apiKey: searchKey,
apiKeyUid: searchKeyUid,
searchRules: { products: { filter: `tenantId = "t1"` } },
// No expiresAt -- token valid forever, cannot be revoked
});Why bad: Master key in frontend exposes admin access, tenant tokens must be signed with a search-only API key (not master key), tokens without expiration cannot be revoked if compromised
---
_Full skill documentation: SKILL.md | Quick reference: reference.md_
Meilisearch -- Index Settings Examples
Ranking rules, typo tolerance, synonyms, stop words, searchable attributes, and pagination configuration. Reference from SKILL.md.
Prerequisites: Understand client setup and document operations from core.md first.
Related examples:
- core.md -- Client setup, document operations
- filtering.md -- filterableAttributes, sortableAttributes
- security.md -- API keys, tenant tokens
---
Complete Index Setup
Configure all settings in a single call before adding documents. This avoids multiple re-indexes.
import type { Meilisearch } from "meilisearch";
const INDEX_NAME = "products";
async function configureProductIndex(client: Meilisearch): Promise<void> {
const index = client.index(INDEX_NAME);
await index
.updateSettings({
// Fields to search (order = weight: first field has highest relevance)
searchableAttributes: [
"name", // Highest weight
"brand",
"description", // Lowest weight
],
// Fields available for filtering and facets
filterableAttributes: [
"price",
"categories",
"brand",
"inStock",
"rating",
],
// Fields available for sorting
sortableAttributes: ["price", "rating", "createdAt"],
// Ranking rules (order matters -- first rule has highest priority)
rankingRules: [
"words", // Documents containing more query terms rank higher
"typo", // Fewer typos rank higher
"proximity", // Query terms closer together rank higher
"attribute", // Matches in higher-weight searchableAttributes rank higher
"sort", // Custom sort (only active when sort parameter is used)
"exactness", // Exact matches rank higher than prefix/typo matches
],
// Typo tolerance configuration
typoTolerance: {
enabled: true,
minWordSizeForTypos: {
oneTypo: 5, // Words < 5 chars: no typos allowed
twoTypos: 9, // Words < 9 chars: max 1 typo
},
disableOnAttributes: ["sku", "barcode", "partNumber"],
disableOnWords: ["iPhone", "MacBook"],
},
// Synonyms
synonyms: {
phone: ["smartphone", "mobile", "cell phone"],
laptop: ["notebook", "portable computer"],
tv: ["television", "monitor", "screen"],
},
// Stop words (ignored in search queries)
stopWords: ["the", "a", "an", "is", "at", "of", "on"],
// Pagination limits
pagination: {
maxTotalHits: 5000, // Default is 1000
},
// Faceting limits
faceting: {
maxValuesPerFacet: 200, // Default is 100
},
})
.waitTask(); // OK in setup script
}
export { configureProductIndex };Why good: Single updateSettings call avoids multiple re-indexes, searchableAttributes ordered by relevance weight, typo tolerance disabled on exact-match fields, pagination limit increased from default 1000
---
Ranking Rules
Default Ranking Rules
The default order is: words > typo > proximity > attribute > sort > exactness. Meilisearch applies these in sequence as tiebreakers.
| Rule | What it does |
|---|---|
words | Documents containing more query terms rank higher |
typo | Documents with fewer typos rank higher |
proximity | Documents where query terms appear closer together rank higher |
attribute | Matches in higher-weight searchableAttributes rank higher |
sort | Applies custom sort (only when sort parameter is used) |
exactness | Exact matches rank higher than prefix or typo matches |
Custom Ranking Rules
Add custom attribute-based sorting to the ranking pipeline:
// Boost products by rating, then by number of reviews
await index
.updateRankingRules([
"words",
"typo",
"proximity",
"attribute",
"sort",
"exactness",
"rating:desc", // Custom: higher rating ranks higher
"reviewCount:desc", // Custom: more reviews ranks higher
])
.waitTask();Important: Custom ranking rules (attribute:asc or attribute:desc) act as tiebreakers AFTER all built-in rules. Place them at the end.
Gotcha: If you move sort before attribute, the user's explicit sort parameter takes priority over attribute weight matching. This is rarely what you want.
---
Searchable Attributes
Order determines relevance weight -- first attribute has the highest weight.
// Good: ordered by relevance weight
await index
.updateSearchableAttributes([
"title", // Highest weight -- title matches are most relevant
"author", // Medium weight
"description", // Lowest weight -- description matches are less relevant
])
.waitTask();
// Bad: using ["*"] (default) -- all fields have equal weight
// A match in "internalNotes" ranks equally with a match in "title"Gotcha: searchableAttributes with ["*"] (the default) indexes ALL fields with equal weight, including fields you may not want searched (internal IDs, timestamps, metadata). Always set this explicitly.
---
Typo Tolerance
Disabling for Specific Fields
// Disable typo tolerance on fields that require exact matching
await index
.updateTypoTolerance({
enabled: true, // Keep global typo tolerance on
disableOnAttributes: [
"sku", // Product codes must match exactly
"barcode", // Barcodes must match exactly
"partNumber", // Part numbers must match exactly
"email", // Email addresses must match exactly
],
})
.waitTask();Why good: Typo tolerance stays enabled for natural language fields (name, description) but disabled for structured identifiers
Disabling for Specific Words
// Prevent typo corrections on brand names
await index
.updateTypoTolerance({
disableOnWords: [
"iPhone", // Don't correct "iPhone" to "iPhobe"
"MacBook", // Don't correct "MacBook" to "MacBoot"
"PlayStation",
],
})
.waitTask();Adjusting Word Size Thresholds
// Make typo tolerance stricter for short words
await index
.updateTypoTolerance({
minWordSizeForTypos: {
oneTypo: 6, // Words shorter than 6 chars: no typos (default: 5)
twoTypos: 12, // Words shorter than 12 chars: max 1 typo (default: 9)
},
})
.waitTask();When to use: When too many irrelevant results appear due to typo corrections on short common words.
---
Synonyms
Synonyms expand search queries -- searching for "phone" also returns results containing "smartphone".
await index
.updateSynonyms({
// One-way synonyms: searching "phone" matches "smartphone" and "mobile"
// But searching "smartphone" does NOT match "phone"
phone: ["smartphone", "mobile"],
// For bidirectional: define both directions
smartphone: ["phone", "mobile"],
mobile: ["phone", "smartphone"],
// Abbreviations
tv: ["television"],
television: ["tv"],
})
.waitTask();Gotcha: Synonyms are NOT bidirectional by default. Defining phone: ["smartphone"] means searching "phone" matches "smartphone", but NOT the reverse. Define both directions explicitly.
Gotcha: Synonyms do NOT apply to filters. Filtering by brand = "phone" will NOT match documents where brand = "smartphone", even if they are defined as synonyms.
---
Stop Words
Stop words are ignored during search indexing and queries.
// Common English stop words
const ENGLISH_STOP_WORDS = [
"the",
"a",
"an",
"is",
"are",
"was",
"were",
"be",
"been",
"being",
"have",
"has",
"had",
"do",
"does",
"did",
"will",
"would",
"could",
"should",
"may",
"might",
"shall",
"can",
"at",
"by",
"for",
"in",
"of",
"on",
"to",
"with",
];
await index.updateStopWords(ENGLISH_STOP_WORDS).waitTask();When to use: When common words pollute search results (searching "the matrix" returns too many results containing just "the").
When NOT to use: Domain-specific applications where common words are meaningful (medical: "the" is part of "The Lancet").
---
Pagination Settings
Increasing maxTotalHits
const MAX_TOTAL_HITS = 10000;
// Default is 1000 -- increase if you need deep pagination
await index
.updateSettings({
pagination: { maxTotalHits: MAX_TOTAL_HITS },
})
.waitTask();Why this matters: By default, Meilisearch caps searchable results at 1000. Even with offset: 1500, you will get 0 results. Increase maxTotalHits to paginate deeper.
Tradeoff: Higher maxTotalHits increases memory usage and search latency for large datasets. Only increase as far as your use case requires.
---
Faceting Settings
const MAX_FACET_VALUES = 500;
await index
.updateSettings({
faceting: {
maxValuesPerFacet: MAX_FACET_VALUES, // Default: 100
sortFacetValuesBy: {
"*": "alpha", // Default: alphabetical for all facets
price: "count", // Sort price facet by frequency (most common first)
},
},
})
.waitTask();When to use: When a faceted attribute has more than 100 unique values and you need all of them in the facet distribution.
---
_Full skill documentation: SKILL.md | Quick reference: reference.md_
# yaml-language-server: $schema=https://raw.githubusercontent.com/agents-inc/cli/main/src/schemas/metadata.schema.json
category: api-search
slug: meilisearch
domain: api
author: "@vince"
displayName: Meilisearch
cliDescription: Fast, typo-tolerant search engine with instant results and faceted filtering
usageGuidance: Use when adding search to applications with Meilisearch -- instant typo-tolerant search, faceted filtering, geo search, multi-index search, and real-time document indexing.
Meilisearch Quick Reference
Search parameters, settings defaults, client methods, decision frameworks, and anti-patterns. See SKILL.md for core concepts and examples/ for code examples.
---
Search Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
q | `string \ | null` | null |
offset | number | 0 | Number of results to skip (use with limit) |
limit | number | 20 | Max results to return (use with offset) |
page | number | undefined | Page number (1-indexed, use with hitsPerPage) |
hitsPerPage | number | undefined | Results per page (use with page) |
filter | `string \ | array` | undefined |
sort | string[] | undefined | Sort expressions: ["price:asc", "date:desc"] |
facets | string[] | undefined | Attributes to return facet counts for |
attributesToRetrieve | string[] | ["*"] | Fields to include in results |
attributesToHighlight | string[] | undefined | Fields to highlight matches in _formatted |
attributesToCrop | string[] | undefined | Fields to crop in _formatted |
cropLength | number | 10 | Max words in cropped values |
cropMarker | string | "..." | String marking crop boundaries |
highlightPreTag | string | "<em>" | String inserted before highlighted terms |
highlightPostTag | string | "</em>" | String inserted after highlighted terms |
showRankingScore | boolean | false | Include _rankingScore (0.0-1.0) per hit |
showRankingScoreDetails | boolean | false | Include per-rule score breakdown |
rankingScoreThreshold | number | undefined | Exclude results below this score (0.0-1.0) |
matchingStrategy | string | "last" | "last" \ |
attributesToSearchOn | string[] | undefined | Restrict search to specific fields only |
showMatchesPosition | boolean | false | Include byte offsets of matches |
distinct | string | undefined | Return one document per distinct value of this attribute |
locales | string[] | undefined | ISO-639 locale codes for language-specific tokenization |
hybrid | object | undefined | { embedder, semanticRatio } for hybrid search |
---
Index Settings Defaults
| Setting | Default Value |
|---|---|
displayedAttributes | ["*"] (all fields) |
searchableAttributes | ["*"] (all fields, equal weight) |
filterableAttributes | [] (no filtering possible) |
sortableAttributes | [] (no sorting possible) |
rankingRules | ["words", "typo", "proximity", "attribute", "sort", "exactness"] |
distinctAttribute | null |
stopWords | [] |
synonyms | {} |
typoTolerance.enabled | true |
typoTolerance.minWordSizeForTypos | { oneTypo: 5, twoTypos: 9 } |
pagination.maxTotalHits | 1000 |
faceting.maxValuesPerFacet | 100 |
faceting.sortFacetValuesBy | { "*": "alpha" } |
proximityPrecision | "byWord" |
searchCutoffMs | null (defaults to 1500ms) |
---
Client Method Quick Reference
Meilisearch (Client)
| Method | Returns | Description |
|---|---|---|
index<T>(uid) | Index<T> | Local reference (no network call) |
getIndex<T>(uid) | Promise<Index<T>> | Fetch index from server |
getIndexes(params?) | Promise<IndexesResults> | List all indexes |
createIndex(uid, options?) | EnqueuedTaskPromise | Create index (async) |
deleteIndex(uid) | EnqueuedTaskPromise | Delete index (async) |
swapIndexes(params) | EnqueuedTaskPromise | Atomic index swap |
multiSearch({ queries, federation? }) | Promise<...> | Search multiple indexes |
health() | Promise<Health> | Server health check |
getVersion() | Promise<Version> | Server version info |
getKeys(params?) | Promise<KeysResults> | List API keys |
createKey(options) | Promise<Key> | Create API key |
Index
| Method | Returns | Description |
|---|---|---|
search<D, S>(query?, options?) | Promise<SearchResponse> | Search documents |
addDocuments(docs, options?) | EnqueuedTaskPromise | Add or replace documents |
updateDocuments(docs, options?) | EnqueuedTaskPromise | Partial update documents |
deleteDocument(id) | EnqueuedTaskPromise | Delete single document |
deleteDocuments(params) | EnqueuedTaskPromise | Delete by IDs or filter |
deleteAllDocuments() | EnqueuedTaskPromise | Delete all documents |
getSettings() | Promise<Settings> | Get all index settings |
updateSettings(settings) | EnqueuedTaskPromise | Update multiple settings at once |
updateFilterableAttributes(attrs) | EnqueuedTaskPromise | Set filterable attributes |
updateSortableAttributes(attrs) | EnqueuedTaskPromise | Set sortable attributes |
updateSearchableAttributes(attrs) | EnqueuedTaskPromise | Set searchable attributes (order = weight) |
updateRankingRules(rules) | EnqueuedTaskPromise | Set ranking rules |
updateSynonyms(synonyms) | EnqueuedTaskPromise | Set synonym mappings |
updateStopWords(words) | EnqueuedTaskPromise | Set stop words |
updateTypoTolerance(config) | EnqueuedTaskPromise | Set typo tolerance config |
EnqueuedTaskPromise
| Method / Property | Returns | Description |
|---|---|---|
await task | EnqueuedTask | Get task UID and status |
.waitTask({ timeOutMs?, intervalMs? }) | Promise<Task> | Poll until task completes (blocks!) |
.taskUid | number | Unique task identifier |
---
Filter Syntax
Comparison: attribute = value, attribute != value, attribute > 10, attribute >= 10
String match: genre = "science fiction" (quotes required for multi-word values)
Exists: attribute EXISTS, attribute NOT EXISTS
IS NULL: attribute IS NULL, attribute IS NOT NULL
IS EMPTY: attribute IS EMPTY, attribute IS NOT EMPTY
IN: attribute IN ["value1", "value2"]
Logical: expression AND expression, expression OR expression, NOT expression
Grouping: (expression OR expression) AND expression
Geo: _geoRadius(lat, lng, radius_m), _geoBoundingBox([lat, lng], [lat, lng])Precedence: AND binds tighter than OR -- always use parentheses when combining:
// Correct
(category = "electronics" OR category = "computers") AND price < 500
// Wrong -- AND binds first, giving unexpected results
category = "electronics" OR category = "computers" AND price < 500---
Task Statuses
| Status | Meaning | Mutable? |
|---|---|---|
enqueued | Waiting in queue to be processed | Yes |
processing | Currently being processed | Yes |
succeeded | Completed successfully, changes applied | No |
failed | Error occurred, index unchanged | No |
canceled | Canceled before processing completed | No |
---
Anti-Patterns
Filtering Without Configuration
// ANTI-PATTERN: filterableAttributes not configured
const results = await index.search("laptop", {
filter: "price < 1000", // Silently returns 0 results
});Why it's wrong: filterableAttributes defaults to an empty array. Filtering on an unconfigured attribute returns no results without any error.
What to do instead: Configure settings first (once, before any search):
await index
.updateFilterableAttributes(["price", "category", "brand"])
.waitTask();---
waitTask in Request Handlers
// ANTI-PATTERN: Blocking request on task completion
app.post("/products", async (req, res) => {
const task = await index.addDocuments([req.body]);
await task.waitTask(); // Polls until done -- may take seconds or minutes
res.json({ indexed: true });
});Why it's wrong: .waitTask() repeatedly polls Meilisearch until the task completes. Under load, the task queue may have hundreds of pending tasks, causing this to block for minutes.
What to do instead: Return the task UID immediately:
app.post("/products", async (req, res) => {
const task = await index.addDocuments([req.body]);
res.json({ taskUid: task.taskUid, status: "enqueued" });
});---
Master Key in Frontend
// ANTI-PATTERN: Master key in client-side code
const client = new Meilisearch({
host: "https://search.example.com",
apiKey: "master-key-abc123", // Full admin access!
});Why it's wrong: The master key grants full access to create/delete indexes, manage keys, and modify all data. Exposing it in client-side code is a critical security vulnerability.
What to do instead: Create a search-only API key or use tenant tokens. See examples/security.md.
---
Wrong Primary Key Inference
// ANTI-PATTERN: Relying on auto-inference
const index = client.index("products");
await index.addDocuments([
{ productId: "p1", sku: "SKU001", name: "Widget" },
// Meilisearch picks "productId" or "sku" -- unpredictable
]);Why it's wrong: Meilisearch infers the primary key from the first document's fields. If the document has multiple candidate fields (anything ending in id or Id), the choice is arbitrary.
What to do instead: Set primary key explicitly:
await index.addDocuments(products, { primaryKey: "productId" });---
Production Checklist
Security
- [ ] Search-only API key or tenant tokens for client-side search (never master key)
- [ ] Master key stored in environment variable, not in code
- [ ] Tenant tokens with appropriate search rules for multi-tenant access
- [ ] API key rotation plan in place
Index Configuration
- [ ]
filterableAttributesconfigured for all fields used in filters and facets - [ ]
sortableAttributesconfigured for all fields used in sort - [ ]
searchableAttributesordered by importance (first = highest weight) - [ ]
typoTolerancedisabled on exact-match fields (SKUs, barcodes, codes) - [ ]
pagination.maxTotalHitsincreased if deep pagination is needed (default: 1000) - [ ] Settings configured BEFORE adding documents (avoids double re-index)
Operations
- [ ] Health check endpoint hitting
client.health()orclient.isHealthy() - [ ] Task queue monitoring -- delete finished tasks periodically (queue limit ~10 GiB)
- [ ] Graceful handling of Meilisearch downtime (search is a feature, not a dependency)
- [ ] Document sync strategy to keep Meilisearch in sync with primary database
---
_Full skill documentation: SKILL.md | Examples: examples/_
Related skills
FAQ
Why do my filters return no results?
You must configure filterableAttributes on the index before using filter in search queries, otherwise filters silently return no results.
Should I call waitTask in a request handler?
No - waitTask blocks the event loop polling Meilisearch; use it only in scripts, seeds, and tests.