api-search-meilisearch
Meilisearch search engine patterns -- client setup, indexing, search, filtering, facets, geo search, multi-tenancy, task management
Install
npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/api-search-meilisearch/skills/api-search-meilisearch
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
git clone https://github.com/agents-inc/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Meilisearch Patterns
Quick Guide: Use
meilisearch(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)
<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 needed
Task 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>
Files (skills)
-
examples
-
core.md 11.7 KB
# Meilisearch -- Core Pattern Examples > Client setup, document operations, search basics, task management, and TypeScript integration. Reference from [SKILL.md](../SKILL.md). **Related examples:** - [filtering.md](filtering.md) -- Filters, facets, geo search - [settings.md](settings.md) -- Ranking rules, typo tolerance, synonyms - [security.md](security.md) -- API keys, tenant tokens, multi-tenancy --- ## Client Setup with Health Check ```typescript 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 ```typescript 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) ```typescript // 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 ```typescript // 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 ```typescript 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) ```typescript // 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 ```typescript 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 ```typescript // 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 ```typescript 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) ```typescript 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) ```typescript // 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 ```typescript 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 ```typescript // 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](../SKILL.md) | Quick reference: [reference.md](../reference.md)_ -
filtering.md 8.1 KB
# Meilisearch -- Filtering & Facets Examples > Filter syntax, faceted search, geo search, and sorting patterns. Reference from [SKILL.md](../SKILL.md). **Prerequisites:** Understand client setup and document operations from [core.md](core.md) first. **Related examples:** - [core.md](core.md) -- Client setup, document operations, search basics - [settings.md](settings.md) -- Configuring filterableAttributes, sortableAttributes - [security.md](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. ```typescript 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 ```typescript // 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 ```typescript 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 ```typescript // 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 ```typescript // 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 want ``` **Why good:** Explicit parentheses prevent precedence bugs, named constants for filter values ### Negation ```typescript // 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. ```typescript 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 ```typescript // 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: ```typescript 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 ```typescript 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 ```typescript // 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 ```typescript // 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 ```typescript 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. ```typescript // 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](../SKILL.md) | Quick reference: [reference.md](../reference.md)_ -
security.md 7.4 KB
# Meilisearch -- Security & Multi-Tenancy Examples > API keys, tenant tokens, search rules, and multi-tenant patterns. Reference from [SKILL.md](../SKILL.md). **Prerequisites:** Understand client setup from [core.md](core.md) first. **Related examples:** - [core.md](core.md) -- Client setup, document operations - [filtering.md](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 ```typescript 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) ```typescript 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) ```typescript 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 ```typescript // 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 ```typescript 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 ```typescript 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 ```typescript // 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](../SKILL.md) | Quick reference: [reference.md](../reference.md)_ -
settings.md 9.4 KB
# Meilisearch -- Index Settings Examples > Ranking rules, typo tolerance, synonyms, stop words, searchable attributes, and pagination configuration. Reference from [SKILL.md](../SKILL.md). **Prerequisites:** Understand client setup and document operations from [core.md](core.md) first. **Related examples:** - [core.md](core.md) -- Client setup, document operations - [filtering.md](filtering.md) -- filterableAttributes, sortableAttributes - [security.md](security.md) -- API keys, tenant tokens --- ## Complete Index Setup Configure all settings in a single call before adding documents. This avoids multiple re-indexes. ```typescript 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: ```typescript // 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. ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript // 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". ```typescript 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. ```typescript // 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 ```typescript 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 ```typescript 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](../SKILL.md) | Quick reference: [reference.md](../reference.md)_
-
-
reference.md 14.1 KB
# Meilisearch Quick Reference > Search parameters, settings defaults, client methods, decision frameworks, and anti-patterns. See [SKILL.md](SKILL.md) for core concepts and [examples/](examples/) for code examples. --- ## Search Parameters | Parameter | Type | Default | Description | | ------------------------- | ----------------- | ----------- | ---------------------------------------------------------- | | `q` | `string \| null` | `null` | Search query (prefix-matched, typo-tolerant) | | `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` | Filter expression (requires `filterableAttributes`) | | `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"` \| `"all"` \| `"frequency"` -- how to match terms | | `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 ```typescript // 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): ```typescript await index .updateFilterableAttributes(["price", "category", "brand"]) .waitTask(); ``` --- ### waitTask in Request Handlers ```typescript // 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: ```typescript app.post("/products", async (req, res) => { const task = await index.addDocuments([req.body]); res.json({ taskUid: task.taskUid, status: "enqueued" }); }); ``` --- ### Master Key in Frontend ```typescript // 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](examples/security.md). --- ### Wrong Primary Key Inference ```typescript // 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: ```typescript 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 - [ ] `filterableAttributes` configured for all fields used in filters and facets - [ ] `sortableAttributes` configured for all fields used in sort - [ ] `searchableAttributes` ordered by importance (first = highest weight) - [ ] `typoTolerance` disabled on exact-match fields (SKUs, barcodes, codes) - [ ] `pagination.maxTotalHits` increased if deep pagination is needed (default: 1000) - [ ] Settings configured BEFORE adding documents (avoids double re-index) ### Operations - [ ] Health check endpoint hitting `client.health()` or `client.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](SKILL.md) | Examples: [examples/](examples/)_ -
SKILL.md 17.2 KB
--- name: api-search-meilisearch description: Meilisearch search engine patterns -- client setup, indexing, search, filtering, facets, geo search, multi-tenancy, task management --- # Meilisearch Patterns > **Quick Guide:** Use `meilisearch` (v0.56+) as the TypeScript client for Meilisearch v1.x. All write operations (document adds, setting changes, index creation) are **asynchronous** -- they return an `EnqueuedTaskPromise` and are processed in a background queue. You MUST configure `filterableAttributes` and `sortableAttributes` on the index **before** using filter/sort in search queries -- this triggers a full re-index. Use `client.index("name")` for a lazy reference (no network call) vs `client.getIndex("name")` which fetches from server. Use `.waitTask()` on `EnqueuedTaskPromise` only 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](examples/core.md) -- Client setup, document operations, search basics, task management, TypeScript integration - [Filtering & Facets](examples/filtering.md) -- Filter syntax, faceted search, geo search, sortable attributes - [Index Settings](examples/settings.md) -- Ranking rules, typo tolerance, synonyms, stop words, searchable attributes, pagination - [Security & Multi-Tenancy](examples/security.md) -- API keys, tenant tokens, search rules, multi-tenant patterns **Additional resources:** - [reference.md](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. ```typescript // 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) ```typescript // 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](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. ```typescript // 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 progress ``` **Why good:** Explicit primary key prevents auto-inference issues, TypeScript generic provides type safety on document shape ```typescript // 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 wrong ``` **Why 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](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`. ```typescript // 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: number ``` **Why good:** Named constants for filter values, combined text search with structured filtering, explicit limit ```typescript // 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](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. ```typescript // 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 relevance ``` **Why good:** Single network request for multiple index searches, federated search provides unified ranking across indexes See [examples/core.md](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. ```typescript // 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 ```typescript // 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](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. ```typescript // 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](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 needed ``` ### Task 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: 1000` pagination limit -- search silently caps results at 1000; increase via `pagination.maxTotalHits` in settings if you need deeper pagination - Using `AND`/`OR` in filters without parentheses -- `AND` has higher precedence than `OR`, 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:** - `filterableAttributes` must include `_geo` for geo search -- adding documents with `_geo` fields is not enough, the attribute must be explicitly listed - `client.index("name")` does NOT create the index or verify it exists -- it returns a local reference; use `client.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 - `_geo` field format is strict: must be `{ lat: number, lng: number }` -- `longitude` instead of `lng` causes `invalid_document_geo_field` errors - Setting changes (filterableAttributes, etc.) queue as tasks too -- they are not instant; wait for the task to complete before relying on the new settings - `hitsPerPage` and `page` parameters override `offset`/`limit` -- do not mix both pagination styles in the same query - Default `maxTotalHits` is 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>
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.