Full-Text Search
Add search to your API with Elasticsearch, Meilisearch, Algolia, and MongoDB Atlas Search.
6 min read
Search Options#
| Solution | Best For | Complexity | Price |
|---|---|---|---|
| MongoDB Atlas Search | Already on MongoDB | Low | Included |
| Meilisearch | Simple, fast setup | Low | Free/Open source |
| Elasticsearch | Enterprise, complex | High | Free/Managed |
| Algolia | Instant search UX | Low | Paid |
| Typesense | Meilisearch alternative | Low | Free/Open source |
Option 1: MongoDB Atlas Search#
If you're using MongoDB Atlas, search is built-in:
Create Search Index (Atlas UI)#
json
{
"mappings": {
"dynamic": true,
"fields": {
"title": {
"type": "string",
"analyzer": "lucene.standard"
},
"description": {
"type": "string",
"analyzer": "lucene.standard"
},
"tags": {
"type": "string",
"analyzer": "lucene.keyword"
}
}
}
}
Search Query#
javascript
// src/services/productService.js
export async function searchProducts(query, options = {}) {
const { page = 1, limit = 20, filters = {} } = options;
const pipeline = [
{
$search: {
index: 'products', // Your index name
compound: {
must: [
{
text: {
query,
path: ['title', 'description'],
fuzzy: { maxEdits: 1 },
},
},
],
filter: filters.category ? [
{ text: { query: filters.category, path: 'category' } }
] : [],
},
},
},
{
$addFields: {
score: { $meta: 'searchScore' },
},
},
{ $skip: (page - 1) * limit },
{ $limit: limit },
];
const results = await Product.aggregate(pipeline);
// Get total count
const countPipeline = [
{ $search: { index: 'products', text: { query, path: ['title', 'description'] } } },
{ $count: 'total' },
];
const [countResult] = await Product.aggregate(countPipeline);
return {
items: results,
total: countResult?.total || 0,
page,
limit,
};
}
Autocomplete#
javascript
// Create autocomplete index
{
"mappings": {
"fields": {
"title": [
{ "type": "string" },
{ "type": "autocomplete", "tokenization": "edgeGram", "minGrams": 2, "maxGrams": 15 }
]
}
}
}
// Query
export async function autocomplete(prefix) {
return Product.aggregate([
{
$search: {
index: 'products_autocomplete',
autocomplete: {
query: prefix,
path: 'title',
fuzzy: { maxEdits: 1 },
},
},
},
{ $limit: 10 },
{ $project: { title: 1, _id: 1 } },
]);
}
Option 2: Meilisearch#
Fast, easy to set up, great for most use cases:
bash
# Run with Docker
docker run -p 7700:7700 getmeili/meilisearch:latest
npm install meilisearch
Setup#
javascript
// src/services/search.js
import { MeiliSearch } from 'meilisearch';
const client = new MeiliSearch({
host: process.env.MEILISEARCH_HOST || 'http://localhost:7700',
apiKey: process.env.MEILISEARCH_API_KEY,
});
// Initialize index
export async function initializeSearch() {
const index = client.index('products');
// Configure searchable attributes
await index.updateSearchableAttributes([
'title',
'description',
'category',
'tags',
]);
// Configure filterable attributes
await index.updateFilterableAttributes([
'category',
'price',
'inStock',
]);
// Configure sortable attributes
await index.updateSortableAttributes(['price', 'createdAt']);
console.log('Meilisearch configured');
}
Index Documents#
javascript
// Add/update documents
export async function indexProducts(products) {
const index = client.index('products');
// Transform for Meilisearch (needs 'id' field)
const documents = products.map(p => ({
id: p._id.toString(),
title: p.title,
description: p.description,
category: p.category,
tags: p.tags,
price: p.price,
inStock: p.inStock,
image: p.image,
}));
await index.addDocuments(documents);
}
// Remove document
export async function removeFromIndex(productId) {
const index = client.index('products');
await index.deleteDocument(productId);
}
// Sync on database changes
Product.schema.post('save', async function(doc) {
await indexProducts([doc]);
});
Product.schema.post('findOneAndDelete', async function(doc) {
if (doc) await removeFromIndex(doc._id.toString());
});
Search#
javascript
export async function searchProducts(query, options = {}) {
const { page = 1, limit = 20, filters = {}, sort } = options;
const index = client.index('products');
const searchOptions = {
offset: (page - 1) * limit,
limit,
attributesToRetrieve: ['id', 'title', 'description', 'price', 'image'],
attributesToHighlight: ['title', 'description'],
};
// Build filters
const filterArray = [];
if (filters.category) filterArray.push(`category = "${filters.category}"`);
if (filters.minPrice) filterArray.push(`price >= ${filters.minPrice}`);
if (filters.maxPrice) filterArray.push(`price <= ${filters.maxPrice}`);
if (filters.inStock) filterArray.push('inStock = true');
if (filterArray.length > 0) {
searchOptions.filter = filterArray.join(' AND ');
}
// Sort
if (sort) {
searchOptions.sort = [sort]; // e.g., 'price:asc'
}
const results = await index.search(query, searchOptions);
return {
items: results.hits,
total: results.estimatedTotalHits,
page,
limit,
processingTimeMs: results.processingTimeMs,
};
}
API Endpoint#
javascript
// src/routes/search.js
router.get('/search', async (req, res) => {
const { q, category, minPrice, maxPrice, inStock, sort, page, limit } = req.query;
if (!q) {
return res.status(400).json({ error: 'Query required' });
}
const results = await searchProducts(q, {
page: parseInt(page) || 1,
limit: parseInt(limit) || 20,
filters: { category, minPrice, maxPrice, inStock: inStock === 'true' },
sort,
});
res.json({ data: results });
});
// Autocomplete endpoint
router.get('/autocomplete', async (req, res) => {
const { q } = req.query;
const index = client.index('products');
const results = await index.search(q, {
limit: 5,
attributesToRetrieve: ['id', 'title'],
});
res.json({ data: results.hits });
});
Option 3: Elasticsearch#
For complex search requirements:
bash
npm install @elastic/elasticsearch
javascript
import { Client } from '@elastic/elasticsearch';
const client = new Client({
node: process.env.ELASTICSEARCH_URL,
auth: {
apiKey: process.env.ELASTICSEARCH_API_KEY,
},
});
// Index document
export async function indexProduct(product) {
await client.index({
index: 'products',
id: product._id.toString(),
document: {
title: product.title,
description: product.description,
category: product.category,
price: product.price,
},
});
}
// Search
export async function searchProducts(query, options = {}) {
const { page = 1, limit = 20 } = options;
const response = await client.search({
index: 'products',
from: (page - 1) * limit,
size: limit,
query: {
multi_match: {
query,
fields: ['title^2', 'description'], // title weighted higher
fuzziness: 'AUTO',
},
},
highlight: {
fields: {
title: {},
description: {},
},
},
});
return {
items: response.hits.hits.map(hit => ({
...hit._source,
id: hit._id,
score: hit._score,
highlights: hit.highlight,
})),
total: response.hits.total.value,
};
}
Option 4: Algolia (Managed)#
bash
npm install algoliasearch
javascript
import algoliasearch from 'algoliasearch';
const client = algoliasearch(
process.env.ALGOLIA_APP_ID,
process.env.ALGOLIA_API_KEY
);
const index = client.initIndex('products');
// Index
export async function indexProduct(product) {
await index.saveObject({
objectID: product._id.toString(),
...product,
});
}
// Search
export async function searchProducts(query, options = {}) {
const { page = 0, hitsPerPage = 20, filters } = options;
return index.search(query, {
page,
hitsPerPage,
filters, // e.g., 'category:electronics AND price < 100'
});
}
Comparison#
| Feature | Atlas Search | Meilisearch | Elasticsearch | Algolia |
|---|---|---|---|---|
| Setup | Easy | Easy | Complex | Easy |
| Self-hosted | No | Yes | Yes | No |
| Speed | Good | Excellent | Good | Excellent |
| Typo tolerance | Yes | Yes | Yes | Yes |
| Faceted search | Yes | Yes | Yes | Yes |
| Price | Included | Free | Free/Paid | Paid |
Key Takeaways#
- Start with Atlas Search - If already on MongoDB
- Meilisearch for most cases - Fast, easy, free
- Index on write - Keep search in sync
- Use fuzzy matching - Handle typos
- Add autocomplete - Better UX
Continue Learning
Ready to level up your skills?
Explore more guides and tutorials to deepen your understanding and become a better developer.