Skip to main content
Technology & EngineeringSearch Services291 lines

Meilisearch

"Meilisearch: self-hosted search engine, typo tolerance, faceting, filtering, sorting, REST API, JavaScript SDK"

Quick Summary18 lines
Meilisearch is an open-source, self-hosted search engine designed for instant, relevant results with minimal configuration. Its guiding principles are:

## Key Points

- **Instant search out of the box** — sub-50ms responses with typo tolerance, prefix matching, and ranking baked in.
- **Simple REST API** — every operation is an HTTP call. No query DSL to learn.
- **Convention over configuration** — sensible defaults for ranking, typo tolerance, and tokenization. Override only when needed.
- **Developer experience first** — a single binary to deploy, a dashboard UI included, and official SDKs for every major language.
- **Document-oriented** — data is stored as JSON documents with a primary key. Think of each index as a collection.
- **Use `waitForTask` in scripts, not in request handlers.** Indexing is asynchronous. In web servers, return the task UID and let clients poll or use webhooks.
- **Set `filterableAttributes` before indexing.** Adding filterable attributes later triggers a full re-index.
- **Use tenant tokens for multi-tenant apps.** They embed filters server-side so the frontend cannot bypass tenant isolation.
- **Keep documents under 1 MB each.** Large documents slow indexing. Store blobs externally and index only searchable/filterable fields.
- **Batch document additions.** Send thousands of documents per call instead of one at a time.
- **Set `displayedAttributes`** to avoid returning internal fields (like tenant IDs) in search results.
- **Pin the Meilisearch version** in production. Major versions can include breaking changes to ranking or API behavior.
skilldb get search-services-skills/MeilisearchFull skill: 291 lines
Paste into your CLAUDE.md or agent config

Meilisearch

Core Philosophy

Meilisearch is an open-source, self-hosted search engine designed for instant, relevant results with minimal configuration. Its guiding principles are:

  • Instant search out of the box — sub-50ms responses with typo tolerance, prefix matching, and ranking baked in.
  • Simple REST API — every operation is an HTTP call. No query DSL to learn.
  • Convention over configuration — sensible defaults for ranking, typo tolerance, and tokenization. Override only when needed.
  • Developer experience first — a single binary to deploy, a dashboard UI included, and official SDKs for every major language.
  • Document-oriented — data is stored as JSON documents with a primary key. Think of each index as a collection.

Meilisearch excels at end-user-facing search (e-commerce, docs, content sites) where speed and tolerance for messy input matter more than complex analytics or aggregation.

Setup

Run Meilisearch and connect from TypeScript:

// npm install meilisearch

import { MeiliSearch } from "meilisearch";

const client = new MeiliSearch({
  host: "http://127.0.0.1:7700",
  apiKey: "YOUR_MASTER_KEY",
});

interface Movie {
  id: number;
  title: string;
  overview: string;
  genres: string[];
  releaseDate: string;
  rating: number;
  poster: string;
}

// Create an index and add documents
async function setupIndex() {
  // Create or get the index with a primary key
  const index = client.index<Movie>("movies");

  // Add documents — Meilisearch infers the schema
  const task = await index.addDocuments(movies, { primaryKey: "id" });

  // Tasks are asynchronous; wait for completion
  await client.waitForTask(task.taskUid);
  console.log("Documents indexed");
}

Configure index settings for filtering, sorting, and faceting:

async function configureIndex() {
  const index = client.index<Movie>("movies");

  await index.updateSettings({
    // Fields that can appear in filter/facet expressions
    filterableAttributes: ["genres", "rating", "releaseDate"],

    // Fields that can be used for sorting
    sortableAttributes: ["rating", "releaseDate"],

    // Control which fields are searchable and their priority
    searchableAttributes: ["title", "overview"],

    // Fields returned in search results
    displayedAttributes: ["id", "title", "overview", "poster", "rating"],

    // Ranking rules in order of priority
    rankingRules: [
      "words",
      "typo",
      "proximity",
      "attribute",
      "sort",
      "exactness",
      "rating:desc", // custom ranking rule
    ],

    // Define synonyms
    synonyms: {
      film: ["movie", "picture"],
      sci-fi: ["science fiction"],
    },
  });
}

Key Techniques

Basic Search with Filters

async function searchMovies(query: string, genre?: string) {
  const index = client.index<Movie>("movies");

  const results = await index.search(query, {
    limit: 20,
    offset: 0,
    filter: genre ? `genres = "${genre}"` : undefined,
    sort: ["rating:desc"],
    attributesToHighlight: ["title", "overview"],
    highlightPreTag: "<mark>",
    highlightPostTag: "</mark>",
  });

  console.log(`Found ${results.estimatedTotalHits} results in ${results.processingTimeMs}ms`);
  return results.hits;
}

Faceted Search

async function facetedSearch(query: string, filters?: string) {
  const index = client.index<Movie>("movies");

  const results = await index.search(query, {
    filter: filters,
    facets: ["genres", "rating"],
    limit: 20,
  });

  // facetDistribution shows count per facet value
  // e.g. { genres: { Action: 42, Comedy: 31 }, rating: { ... } }
  const { facetDistribution, hits } = results;
  return { facetDistribution, hits };
}

// Complex filter expressions
async function complexFilter() {
  const index = client.index<Movie>("movies");

  return index.search("", {
    filter: [
      "genres = Action OR genres = Thriller",
      "rating >= 7.5",
      "releaseDate > 2020-01-01",
    ],
    // Array items are ANDed together; use OR within a string
    sort: ["releaseDate:desc"],
  });
}

Multi-Index Search

async function multiSearch(query: string) {
  const results = await client.multiSearch({
    queries: [
      { indexUid: "movies", q: query, limit: 5 },
      { indexUid: "actors", q: query, limit: 5 },
      { indexUid: "directors", q: query, limit: 3 },
    ],
  });

  return {
    movies: results.results[0].hits,
    actors: results.results[1].hits,
    directors: results.results[2].hits,
  };
}

Document Management

async function manageDocuments() {
  const index = client.index<Movie>("movies");

  // Add or replace documents (upsert by primary key)
  const addTask = await index.addDocuments(newMovies);
  await client.waitForTask(addTask.taskUid);

  // Partial update — only specified fields are changed
  const updateTask = await index.updateDocuments([
    { id: 42, rating: 8.1 },
    { id: 99, rating: 7.3 },
  ]);
  await client.waitForTask(updateTask.taskUid);

  // Delete specific documents
  const deleteTask = await index.deleteDocuments([42, 99]);
  await client.waitForTask(deleteTask.taskUid);

  // Delete documents matching a filter
  const filterDeleteTask = await index.deleteDocuments({
    filter: "rating < 3.0",
  });
  await client.waitForTask(filterDeleteTask.taskUid);
}

API Key Management

async function setupApiKeys() {
  // Create a search-only key for the frontend
  const searchKey = await client.createKey({
    description: "Frontend search key",
    actions: ["search"],
    indexes: ["movies", "actors"],
    expiresAt: new Date("2027-01-01"),
  });

  // Create an admin key for indexing
  const adminKey = await client.createKey({
    description: "Backend indexing key",
    actions: ["documents.add", "documents.delete", "settings.update"],
    indexes: ["*"],
    expiresAt: null, // never expires
  });

  // Generate a tenant token for multi-tenant search
  const tenantToken = client.generateTenantToken(
    searchKey.uid,
    {
      movies: { filter: `tenantId = ${tenantId}` },
    },
    { apiKey: searchKey.key, expiresAt: new Date(Date.now() + 3600_000) }
  );

  return { searchKey: searchKey.key, adminKey: adminKey.key, tenantToken };
}

Express Integration

import express from "express";

const app = express();
app.use(express.json());

// Proxy search for cases where direct frontend access is not desired
app.get("/api/search", async (req, res) => {
  const { q = "", genre, page = "0" } = req.query as Record<string, string>;
  const index = client.index<Movie>("movies");

  const results = await index.search(q, {
    filter: genre ? `genres = "${genre}"` : undefined,
    facets: ["genres"],
    limit: 20,
    offset: parseInt(page) * 20,
  });

  res.json({
    hits: results.hits,
    total: results.estimatedTotalHits,
    facets: results.facetDistribution,
  });
});

// Webhook to sync data changes into Meilisearch
app.post("/api/webhooks/movie-updated", async (req, res) => {
  const movie: Movie = req.body;
  const index = client.index<Movie>("movies");
  const task = await index.addDocuments([movie]);
  res.json({ taskUid: task.taskUid });
});

Best Practices

  • Use waitForTask in scripts, not in request handlers. Indexing is asynchronous. In web servers, return the task UID and let clients poll or use webhooks.
  • Set filterableAttributes before indexing. Adding filterable attributes later triggers a full re-index.
  • Use tenant tokens for multi-tenant apps. They embed filters server-side so the frontend cannot bypass tenant isolation.
  • Keep documents under 1 MB each. Large documents slow indexing. Store blobs externally and index only searchable/filterable fields.
  • Batch document additions. Send thousands of documents per call instead of one at a time.
  • Set displayedAttributes to avoid returning internal fields (like tenant IDs) in search results.
  • Pin the Meilisearch version in production. Major versions can include breaking changes to ranking or API behavior.

Anti-Patterns

  • Using Meilisearch for analytics or aggregation. It is not a database. Use it for user-facing search only.
  • Polling task status in a tight loop. Use waitForTask with a reasonable interval or check task status on demand.
  • Indexing raw HTML or Markdown. Strip tags before indexing. Markup fragments pollute search results and highlighting.
  • Storing the master key in frontend code. Use a search-only API key or tenant token for client-side access.
  • Relying on estimatedTotalHits for exact pagination. The count is an estimate. Do not display exact page counts.
  • Skipping filterableAttributes configuration. Filters on unconfigured attributes silently return no results, causing confusing bugs.
  • Running without authentication in production. Always start Meilisearch with --master-key set and create scoped keys.

Install this skill directly: skilldb add search-services-skills

Get CLI access →