Skip to content
← Search · beginner · 11 min · 03 / 06

Meilisearch

Self-hosting, indexing documents, typo tolerance, faceted search, and keeping Meilisearch in sync with your database.

Meilisearchtypo tolerancefacetsindexingself-hostedsync

Real-World Analogy

A specialist search consultant hired by the library: they set up a separate, purpose-built card system optimized purely for finding things fast — typo-tolerant, faceted by genre and year, with relevance tuning. The main catalogue (Postgres) remains authoritative; the consultant’s system is the search interface layered on top.

Why Meilisearch

Meilisearch is a Rust-based search engine optimized for developer experience:

  • Typo tolerance out of the box (1 typo for 5+ char words by default)
  • Sub-100ms search even on millions of documents
  • Facets with counts, filter, and sort — all configured per index
  • Simple JSON API — no query DSL to learn
  • Single binary, easy to self-host

Compared to Elasticsearch: Meilisearch is simpler, faster to set up, but less configurable. Elasticsearch handles petabyte-scale and complex aggregations; Meilisearch handles “search my product catalog” without a PhD in Lucene.

Running Meilisearch

# Docker
docker run -d \
  --name meilisearch \
  -p 7700:7700 \
  -e MEILI_MASTER_KEY="your-master-key" \
  -v $(pwd)/meili_data:/meili_data \
  getmeili/meilisearch:latest

# Docker Compose
services:
  meilisearch:
    image: getmeili/meilisearch:latest
    ports: ["7700:7700"]
    environment:
      MEILI_MASTER_KEY: "your-master-key"
      MEILI_ENV: production
      MEILI_DB_PATH: /meili_data
    volumes:
      - meili_data:/meili_data
    restart: unless-stopped
# Verify
curl http://localhost:7700/health
# {"status":"available"}

Creating an Index and Configuring Settings

import MeiliSearch from 'meilisearch';

const client = new MeiliSearch({
  host: 'http://localhost:7700',
  apiKey: 'your-master-key',
});

// Create index
const index = client.index('products');

// Configure settings (do this before indexing)
await index.updateSettings({
  // Which fields are searchable and their relative importance
  searchableAttributes: [
    'name',          // highest priority
    'brand',
    'description',
    'tags',
  ],

  // Which fields can be used to filter/facet
  filterableAttributes: ['category', 'brand', 'price_cents', 'in_stock', 'tags'],

  // Which fields can be sorted
  sortableAttributes: ['price_cents', 'created_at', 'popularity'],

  // Relevance ranking (order matters)
  rankingRules: [
    'words',        // documents with more query words rank higher
    'typo',         // fewer typos = higher rank
    'proximity',    // closer query words = higher rank
    'attribute',    // match in searchableAttributes[0] > [1] > [2]
    'sort',         // custom sort fields
    'exactness',    // exact match > prefix match
    'popularity:desc',  // custom ranking field (inject popularity score)
  ],

  // Typo tolerance configuration
  typoTolerance: {
    enabled: true,
    minWordSizeForTypos: {
      oneTypo: 5,    // allow 1 typo for words >= 5 chars
      twoTypos: 9,   // allow 2 typos for words >= 9 chars
    },
  },

  // Stop words (don't index/search these)
  stopWords: ['the', 'a', 'an', 'and', 'or', 'but'],

  // Synonyms
  synonyms: {
    'tv': ['television', 'screen'],
    'laptop': ['notebook', 'portable computer'],
  },
});

Indexing Documents

// Index documents (upsert — safe to re-run)
await index.addDocuments([
  {
    id: 'prod-123',           // required — Meilisearch's primary key
    name: 'MacBook Pro 14"',
    brand: 'Apple',
    category: 'laptops',
    description: 'M3 chip, 18GB RAM, 512GB SSD',
    price_cents: 199900,
    in_stock: true,
    tags: ['laptop', 'apple', 'pro'],
    popularity: 9500,
    created_at: '2024-01-01T00:00:00Z',
  },
  // ...
], { primaryKey: 'id' });

// Check indexing status
const task = await index.addDocuments(documents);
const status = await client.waitForTask(task.taskUid);
console.log(status.status);  // 'succeeded'

// Update single document
await index.updateDocuments([{ id: 'prod-123', price_cents: 189900 }]);

// Delete
await index.deleteDocument('prod-123');

Searching

// Basic search
const results = await index.search('macbook pro');
console.log(results.hits);
// [{ id: 'prod-123', name: 'MacBook Pro 14"', ... }]
console.log(results.processingTimeMs);  // e.g., 3ms

// With filters and facets
const results = await index.search('laptop', {
  filter: ['category = "laptops"', 'price_cents < 200000', 'in_stock = true'],
  sort: ['price_cents:asc'],
  limit: 20,
  offset: 0,
  facets: ['brand', 'category', 'tags'],
  attributesToHighlight: ['name', 'description'],
  highlightPreTag: '<mark>',
  highlightPostTag: '</mark>',
  attributesToCrop: ['description'],
  cropLength: 100,
});

console.log(results.facetDistribution);
// {
//   brand: { Apple: 45, Dell: 23, HP: 18 },
//   category: { laptops: 86 },
//   tags: { laptop: 86, apple: 45, pro: 32 }
// }

// Typo tolerance in action
const results2 = await index.search('macbok pro');  // "macbok" typo
// Still finds MacBook Pro — typo tolerance at work
// API route
app.get('/search', async (req, res) => {
  const { q = '', category, minPrice, maxPrice, sort = 'popularity:desc', page = 1 } = req.query;

  const filters: string[] = [];
  if (category) filters.push(`category = "${category}"`);
  if (minPrice) filters.push(`price_cents >= ${Number(minPrice) * 100}`);
  if (maxPrice) filters.push(`price_cents <= ${Number(maxPrice) * 100}`);
  filters.push('in_stock = true');

  const results = await index.search(q as string, {
    filter: filters.join(' AND '),
    sort: [sort as string],
    facets: ['brand', 'category'],
    limit: 20,
    offset: (Number(page) - 1) * 20,
  });

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

Keeping Meilisearch in Sync

Meilisearch is a read-optimized secondary index. Postgres is authoritative. Sync strategies:

1. Write-through (sync on every write):

async function createProduct(product: Product): Promise<Product> {
  const saved = await db.create(product);
  
  // Index in Meilisearch (fire-and-forget — ok if it fails, background job retries)
  index.addDocuments([productToSearchDoc(saved)]).catch(err =>
    log.error({ err, productId: saved.id }, 'Meilisearch sync failed')
  );
  
  return saved;
}

Simple but fragile — if Meilisearch is down, the sync is lost.

2. Outbox pattern (reliable sync):

// On write: record intent to sync
await db.transaction(async (tx) => {
  const product = await tx.create(product);
  await tx.query(
    'INSERT INTO search_sync_queue (entity_type, entity_id, operation) VALUES ($1, $2, $3)',
    ['product', product.id, 'upsert']
  );
});

// Background worker: process sync queue
async function processSyncQueue() {
  const batch = await db.query(
    `SELECT * FROM search_sync_queue
     WHERE processed_at IS NULL
     ORDER BY created_at
     LIMIT 100
     FOR UPDATE SKIP LOCKED`
  );

  if (batch.rows.length === 0) return;

  const toUpsert = batch.rows.filter(r => r.operation === 'upsert');
  const toDelete = batch.rows.filter(r => r.operation === 'delete');

  if (toUpsert.length > 0) {
    const products = await db.query(
      'SELECT * FROM products WHERE id = ANY($1)',
      [toUpsert.map(r => r.entity_id)]
    );
    await index.addDocuments(products.rows.map(productToSearchDoc));
  }

  if (toDelete.length > 0) {
    await index.deleteDocuments(toDelete.map(r => r.entity_id));
  }

  await db.query(
    'UPDATE search_sync_queue SET processed_at = NOW() WHERE id = ANY($1)',
    [batch.rows.map(r => r.id)]
  );
}

setInterval(processSyncQueue, 5000);

3. Full reindex (scheduled):

async function fullReindex() {
  let offset = 0;
  const batchSize = 1000;

  while (true) {
    const products = await db.query(
      'SELECT * FROM products ORDER BY id LIMIT $1 OFFSET $2',
      [batchSize, offset]
    );
    if (products.rows.length === 0) break;

    await index.addDocuments(products.rows.map(productToSearchDoc));
    offset += batchSize;
  }

  log.info({ offset }, 'Full reindex complete');
}

// Run nightly as a safety net
new CronJob('0 2 * * *', fullReindex).start();

Multi-Tenant Search

Separate index per tenant (small tenants) or filter by tenant ID (large tenants):

// Small SaaS: one index per tenant
function getTenantIndex(tenantId: string) {
  return client.index(`products_${tenantId}`);
}

// Large SaaS: shared index with tenant filter
const results = await index.search(query, {
  filter: [`tenant_id = "${tenantId}"`],
  // tenant_id must be in filterableAttributes
});

Separate indexes provide perfect isolation but multiply operational overhead. Shared index with filter is simpler but requires careful access control (server-side filter injection, never trust client-provided filters).

Meilisearch API Keys for Frontend

Never expose the master key to clients. Create scoped API keys:

const searchKey = await client.createKey({
  description: 'Frontend search — read only',
  actions: ['search'],
  indexes: ['products'],
  expiresAt: null,  // no expiry for production search key
});

console.log(searchKey.key);  // give this to the frontend

Frontend can call Meilisearch directly — zero latency from bypassing your backend — while the master key stays server-side.