How to Add Full-Text Search with Meilisearch
·APIScout Team
meilisearchsearchfull text searchtutorialapi integration
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.
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 |
Adding search? Compare Meilisearch vs Algolia vs Typesense on APIScout — open-source vs hosted, pricing, and feature comparison.