Meilisearch
Self-hosting, indexing documents, typo tolerance, faceted search, and keeping Meilisearch in sync with your database.
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.