Claude Skill

api-search-meilisearch

Meilisearch search engine patterns -- client setup, indexing, search, filtering, facets, geo search, multi-tenancy, task management

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download agents-inc-skills-dist_plugins_api-search-meilisearch_skills_api-search-meilisearch-3a51ef5.zip · 23 KB
Part of agents-inc/skills — 130 skills

Install

skills CLI npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/api-search-meilisearch/skills/api-search-meilisearch
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
Git 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 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 -- 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: 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>

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.

No comments yet.

Reviews (0)

No reviews yet.

Related