Skip to main content

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'],
  },
});
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,
});
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

FeatureMeilisearchAlgolia
Open source
Self-hosted✅ Free
Cloud hostingFrom $0From $0
Search speed<50ms<50ms
Typo tolerance
Facets/filters
Geo search
Free tier (cloud)10K docs, 10K searches10K records, 10K searches
Pricing at scaleMuch 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

ItemNotes
Set MEILI_ENV=productionDisables dashboard, requires API keys
Set strong master keyAt least 16 characters
Use search-only key for clientNever expose admin key
Set up backupsSnapshot Meilisearch data directory
Put behind reverse proxynginx/Caddy for SSL + rate limiting
Monitor disk spaceIndex size grows with data

Common Mistakes

MistakeImpactFix
Exposing admin key to clientData can be deletedGenerate and use search-only key
Not setting searchableAttributesSearches all fields (slow)Explicitly list searchable fields
Indexing too much data per documentLarge index, slow updatesOnly index fields needed for search
Not waiting for indexing tasksSearching before data is indexedUse waitForTask() or check task status
No typo tolerance testingUsers 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.

Comments