How to Add Full-Text Search with Meilisearch 2026
How to Add Full-Text Search with Meilisearch
Meilisearch is the open-source search engine that's as fast as Algolia but free to self-host. Sub-50ms search with typo tolerance, filtering, and facets out of the box. This guide covers everything from installation to a production search UI.
Search is often an afterthought until it isn't. Users type "javasript" and get zero results. They search for "login" when your docs say "authentication". They filter by category and then can't sort by relevance. Getting search right means thinking beyond the basic query → results loop. Meilisearch's defaults handle the most common failure modes (typo tolerance, multi-word matching, attribute weighting), but production search requires understanding how the engine works, not just how to call the API.
What You'll Build
- Full-text search with typo tolerance
- Filtering and faceted navigation
- Search-as-you-type UI
- Document indexing and updates
- Self-hosted deployment
Prerequisites: Node.js 18+, Docker (for self-hosting).
1. Setup
Option A: Self-Hosted (Docker)
docker run -d --name meilisearch \
-p 7700:7700 \
-e MEILI_MASTER_KEY='your-master-key-here' \
-v $(pwd)/meili_data:/meili_data \
getmeili/meilisearch:latest
Option B: Meilisearch Cloud
Sign up at cloud.meilisearch.com — free tier available.
Install SDK
npm install meilisearch
Initialize Client
// lib/meilisearch.ts
import { MeiliSearch } from 'meilisearch';
// Admin client (server-side only — has write access)
export const meiliAdmin = new MeiliSearch({
host: process.env.MEILISEARCH_HOST || 'http://localhost:7700',
apiKey: process.env.MEILISEARCH_ADMIN_KEY,
});
// Search client (can be used client-side — read-only)
export const meiliSearch = new MeiliSearch({
host: process.env.NEXT_PUBLIC_MEILISEARCH_HOST || 'http://localhost:7700',
apiKey: process.env.NEXT_PUBLIC_MEILISEARCH_SEARCH_KEY,
});
Generate API Keys
// Create a search-only key (safe for client-side)
const keys = await meiliAdmin.getKeys();
// Use the "Default Search API Key" for client-side
// Use the "Default Admin API Key" for server-side indexing
2. Index Data
Create Index and Add Documents
// scripts/index-data.ts
import { meiliAdmin } from '../lib/meilisearch';
const products = [
{
id: 1,
name: 'React Query',
description: 'Powerful asynchronous state management for React',
category: 'Data Fetching',
tags: ['react', 'data', 'cache', 'typescript'],
stars: 38000,
downloads: 5200000,
},
{
id: 2,
name: 'Zustand',
description: 'Small, fast, scalable state management',
category: 'State Management',
tags: ['react', 'state', 'lightweight'],
stars: 42000,
downloads: 8100000,
},
// ... more products
];
// Add documents to index
const index = meiliAdmin.index('products');
const task = await index.addDocuments(products);
console.log('Indexing task:', task.taskUid);
// Wait for indexing to complete
await meiliAdmin.waitForTask(task.taskUid);
console.log('Indexing complete!');
Configure Index Settings
const index = meiliAdmin.index('products');
await index.updateSettings({
// Fields to search in (order = priority)
searchableAttributes: ['name', 'description', 'tags'],
// Fields to return in results
displayedAttributes: ['id', 'name', 'description', 'category', 'stars', 'downloads'],
// Fields available for filtering
filterableAttributes: ['category', 'tags', 'stars'],
// Fields available for sorting
sortableAttributes: ['stars', 'downloads', 'name'],
// Custom ranking rules
rankingRules: [
'words',
'typo',
'proximity',
'attribute',
'sort',
'exactness',
'stars:desc', // Boost high-star results
],
// Synonyms
synonyms: {
react: ['reactjs', 'react.js'],
vue: ['vuejs', 'vue.js'],
state: ['store', 'state management'],
},
});
3. Search
Basic Search
const index = meiliSearch.index('products');
const results = await index.search('react state', {
limit: 10,
attributesToHighlight: ['name', 'description'],
});
// results.hits = [
// {
// id: 2,
// name: 'Zustand',
// description: 'Small, fast, scalable state management',
// _formatted: {
// name: 'Zustand',
// description: 'Small, fast, scalable <em>state</em> management',
// },
// },
// ...
// ]
Search with Filters
const results = await index.search('data', {
filter: ['category = "Data Fetching"', 'stars > 10000'],
sort: ['stars:desc'],
limit: 20,
});
Faceted Search
const results = await index.search('', {
facets: ['category', 'tags'],
limit: 20,
});
// results.facetDistribution = {
// category: { 'Data Fetching': 5, 'State Management': 8, 'UI': 12 },
// tags: { react: 15, typescript: 10, vue: 8 },
// }
4. Search UI Component
// components/Search.tsx
'use client';
import { useState, useEffect } from 'react';
import { meiliSearch } from '@/lib/meilisearch';
interface Product {
id: number;
name: string;
description: string;
category: string;
stars: number;
_formatted?: {
name: string;
description: string;
};
}
export function Search() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Product[]>([]);
const [facets, setFacets] = useState<Record<string, Record<string, number>>>({});
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
useEffect(() => {
const search = async () => {
const index = meiliSearch.index('products');
const filters: string[] = [];
if (selectedCategory) {
filters.push(`category = "${selectedCategory}"`);
}
const response = await index.search(query, {
limit: 20,
attributesToHighlight: ['name', 'description'],
facets: ['category', 'tags'],
filter: filters.length > 0 ? filters : undefined,
});
setResults(response.hits as Product[]);
setFacets(response.facetDistribution ?? {});
};
const debounce = setTimeout(search, 150);
return () => clearTimeout(debounce);
}, [query, selectedCategory]);
return (
<div className="search-layout">
<aside className="filters">
<h3>Category</h3>
<button
onClick={() => setSelectedCategory(null)}
className={!selectedCategory ? 'active' : ''}
>
All
</button>
{Object.entries(facets.category ?? {}).map(([cat, count]) => (
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
className={selectedCategory === cat ? 'active' : ''}
>
{cat} ({count})
</button>
))}
</aside>
<main>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
autoFocus
/>
<div className="results">
{results.map((hit) => (
<article key={hit.id}>
<h3
dangerouslySetInnerHTML={{
__html: hit._formatted?.name ?? hit.name,
}}
/>
<p
dangerouslySetInnerHTML={{
__html: hit._formatted?.description ?? hit.description,
}}
/>
<span>{hit.category}</span>
<span>⭐ {hit.stars.toLocaleString()}</span>
</article>
))}
{results.length === 0 && query && (
<p>No results for "{query}"</p>
)}
</div>
</main>
</div>
);
}
5. Keep Data in Sync
Real-Time Updates
// When a product is created or updated
export async function syncProduct(product: Product) {
const index = meiliAdmin.index('products');
await index.addDocuments([product]); // Upserts by id
}
// When a product is deleted
export async function removeProduct(productId: number) {
const index = meiliAdmin.index('products');
await index.deleteDocument(productId);
}
// Batch update
export async function syncProducts(products: Product[]) {
const index = meiliAdmin.index('products');
await index.addDocuments(products); // Upserts all
}
6. Meilisearch vs Algolia
| Feature | Meilisearch | Algolia |
|---|---|---|
| Open source | ✅ | ❌ |
| Self-hosted | ✅ Free | ❌ |
| Cloud hosting | From $0 | From $0 |
| Search speed | <50ms | <50ms |
| Typo tolerance | ✅ | ✅ |
| Facets/filters | ✅ | ✅ |
| Geo search | ✅ | ✅ |
| Free tier (cloud) | 10K docs, 10K searches | 10K records, 10K searches |
| Pricing at scale | Much cheaper (or free self-hosted) | Expensive at volume |
Production Deployment
Docker Compose
version: '3.8'
services:
meilisearch:
image: getmeili/meilisearch:latest
ports:
- "7700:7700"
environment:
- MEILI_MASTER_KEY=your-production-master-key
- MEILI_ENV=production
volumes:
- meili_data:/meili_data
volumes:
meili_data:
Production Checklist
| Item | Notes |
|---|---|
Set MEILI_ENV=production | Disables dashboard, requires API keys |
| Set strong master key | At least 16 characters |
| Use search-only key for client | Never expose admin key |
| Set up backups | Snapshot Meilisearch data directory |
| Put behind reverse proxy | nginx/Caddy for SSL + rate limiting |
| Monitor disk space | Index size grows with data |
Common Mistakes
| Mistake | Impact | Fix |
|---|---|---|
| Exposing admin key to client | Data can be deleted | Generate and use search-only key |
| Not setting searchableAttributes | Searches all fields (slow) | Explicitly list searchable fields |
| Indexing too much data per document | Large index, slow updates | Only index fields needed for search |
| Not waiting for indexing tasks | Searching before data is indexed | Use waitForTask() or check task status |
| No typo tolerance testing | Users can't find "javasript" | Meilisearch handles this by default |
Handling Large Datasets and Pagination
Meilisearch's default limit is 20 results, and the maximum is 1,000. For datasets over 100K documents, you need to think carefully about both indexing throughput and search pagination.
Indexing performance: addDocuments() accepts up to 1,000 documents per call. For large initial imports, batch your documents and track the task UIDs. Meilisearch processes indexing tasks asynchronously — a task with status enqueued means the documents are queued but not yet searchable. Use waitForTask() or poll getTask(taskUid) to confirm completion before running your smoke tests. Expect roughly 500-2,000 documents/second indexing throughput depending on document size and server specs.
Search pagination: Meilisearch supports two pagination strategies. The limit/offset approach works for most cases: index.search(q, { limit: 20, offset: 40 }) returns page 3 of 20 results per page. For page-number UI (1, 2, 3...), use the page/hitsPerPage parameters instead — they return totalPages and totalHits metadata. Note that totalHits is an estimate for large result sets (Meilisearch caps expensive exact counts); configure pagination.maxTotalHits in index settings if you need accurate counts.
Index size and disk: Meilisearch's index is typically 2-4x the raw document size due to inverted index structures. A dataset of 1M product records averaging 1KB each will produce a 2-4GB index. Monitor disk space, especially when you're updating documents frequently — Meilisearch writes new index segments on each update batch and compacts them in the background. An SSD is strongly recommended for production deployments.
Tuning Search Relevance
Meilisearch works well out of the box, but production search quality usually requires some tuning once you see real user queries.
Ranking rules are the most impactful lever. The default order (words, typo, proximity, attribute, sort, exactness) prioritizes documents that match more query words, have fewer typos, and match in adjacent fields. Adding a custom rule like stars:desc as shown in the code above biases results toward popular items — useful for package directories or app stores, but wrong if you want recent results to surface first.
Typo tolerance configuration is worth understanding. By default, Meilisearch allows 1 typo for words of 5+ characters and 2 typos for words of 9+ characters. For proper nouns, technical terms, or domain-specific jargon (API names, package names), you may want to tighten this. A search for "redux" shouldn't match "react" via typo tolerance. Use disableOnWords in typo tolerance settings to protect specific terms.
Stop words remove noise from queries. Words like "the", "a", "an", "with", and "how" don't carry semantic meaning in most search contexts. Adding them as stop words means a search for "how to add authentication" finds the same results as "add authentication" — the noise words don't affect ranking. Meilisearch's built-in stop words list is a good starting point; add domain-specific stop words (like "api", "library", or "npm" for a package search) as you analyze real query logs.
Synonym expansion handles vocabulary mismatches between what users type and what your content says. If your docs call it "authentication" but users search for "login" or "sign in", synonyms bridge that gap. Start with a small list of known mismatches from your query analytics, then expand as you discover more. Synonyms are one-way by default in Meilisearch — if you want "javascript" and "js" to be equivalent in both directions, define them as multi-way synonyms.
Meilisearch vs Typesense: The Other Open-Source Option
Algolia is the reference point for comparison, but Typesense is a direct Meilisearch competitor worth evaluating when you're choosing an open-source search engine.
Both are open-source, written in C++/Rust respectively (Typesense in C++, Meilisearch in Rust), and target the sub-50ms search experience. The practical differences:
Meilisearch strengths: Better typo tolerance out of the box, simpler initial setup, more intuitive ranking rules syntax. The dashboard (available in development mode) is genuinely useful for debugging index state. Document updates are immediate — no re-indexing required. The cloud offering (Meilisearch Cloud) integrates tightly with the self-hosted workflow.
Typesense strengths: Better multi-tenant support via scoped API keys, more mature vector search integration (for hybrid semantic + keyword search), and stronger query-time field weighting. Typesense Cloud's pricing model is slightly more predictable at high query volume. For applications that need both traditional keyword search and vector similarity search (e.g., semantic search over documentation), Typesense's hybrid search is more production-ready as of 2026.
For most use cases — e-commerce product search, documentation search, internal tools — Meilisearch is the easier path to a working implementation and has a larger community of self-hosting tutorials and Docker configurations. If you need semantic search capabilities or complex multi-tenant architectures, benchmark both before committing. The migration cost between them is low (both use similar index/search patterns), so starting with Meilisearch and switching to Typesense later is a viable path if requirements change.
Methodology
This guide targets Meilisearch v1.x (current as of 2026) with the meilisearch npm SDK v0.40+. The search UI component uses React with hooks and a 150ms debounce — appropriate for most applications; reduce to 0ms for instant search on fast connections or increase to 300ms for slower networks. The Docker Compose configuration uses the latest tag for simplicity; pin to a specific version tag (e.g., v1.7.0) in production to avoid unexpected breaking changes on container restarts. Benchmark data comparing Meilisearch to Algolia latency assumes similar hardware and index sizes — results vary significantly by document schema complexity and filter cardinality. For the Typesense comparison, we compared feature documentation and community benchmarks from the respective GitHub repositories rather than running independent benchmarks — test both engines against your specific dataset and query patterns before committing to either in production. The dangerouslySetInnerHTML pattern in the React component is safe when the content comes from Meilisearch's _formatted field, which wraps matched text in <em> tags only — but sanitize with DOMPurify if there is any possibility of user-controlled content making it into your search index.
Adding search? Compare Meilisearch vs Algolia vs Typesense on APIScout — open-source vs hosted, pricing, and feature comparison.
Related: How to Add Algolia Search to Your Website, Algolia vs Meilisearch: Search-as-a-Service Compared, Best Search APIs (2026)