Skip to content

Fix: Meilisearch Not Working — Search Returns No Results, Index Not Found, or API Key Errors

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix Meilisearch issues — index setup, document indexing, search configuration, filtering, facets, typo tolerance, and self-hosted deployment.

The Problem

A Meilisearch search returns an empty hits array:

const results = await client.index('articles').search('typescript');
// { hits: [], query: 'typescript', processingTimeMs: 1, ... }

Or the client throws an index not found error:

MeilisearchApiError: index `articles` not found

Or document indexing fails with an API key error:

MeilisearchApiError: Invalid API key. The provided API key is invalid.

Why This Happens

Meilisearch is a self-hosted (or cloud) search engine that requires explicit setup before it returns results:

  • Indexes must be created before adding documents — unlike some databases, Meilisearch creates the index automatically when you first add documents, but only if you’re using the master key or an admin-level key.
  • Searchable attributes default to all fields — a fresh index searches every attribute. But if you’ve explicitly set searchableAttributes and omitted a field, searches won’t match on that field.
  • API keys have scopes — the master key is for administration. You should create separate keys for search (read-only) and indexing (write). Using a search-only key to add documents will fail.
  • Tasks are asynchronous — indexing operations return a task ID, not the result. Documents aren’t immediately available. You must wait for the task to complete.

Meilisearch’s typo tolerance is also a frequent surprise. Unlike a SQL LIKE query or a simple full-text index, Meilisearch ranks results across multiple signals: number of matched words, typo distance, word proximity, attribute priority, custom sort, and exactness. Each ranking rule fires in order, and you can override the order in index settings. If you expected an exact-match document at the top but got a fuzzy match instead, the attribute and exactness rules are usually not weighted the way you assumed.

Async tasks are the other concept that catches teams off guard. Every write operation — adding documents, updating settings, deleting — returns a taskUid immediately and runs in the background. The Meilisearch process applies tasks serially per index, so if you push 10,000 documents and immediately run a search, the index may still be enqueued. Always wait on the task before assertions in tests, and in production code don’t assume new documents are queryable the instant the HTTP call resolves.

Fix 1: Setup and API Keys

# Self-hosted — run with Docker
docker run -d \
  --name meilisearch \
  -p 7700:7700 \
  -e MEILI_MASTER_KEY='your-master-key-min-16-chars' \
  -v $(pwd)/meili_data:/meili_data \
  getmeili/meilisearch:latest

# Or with docker-compose
# docker-compose.yml
services:
  meilisearch:
    image: getmeili/meilisearch:latest
    ports:
      - "7700:7700"
    environment:
      MEILI_MASTER_KEY: "your-master-key-at-least-16-chars"
      MEILI_ENV: "production"
    volumes:
      - ./meili_data:/meili_data
// npm install meilisearch
import { MeiliSearch } from 'meilisearch';

// Master key client (admin — server-side only)
const adminClient = new MeiliSearch({
  host: process.env.MEILISEARCH_HOST!,       // e.g. http://localhost:7700
  apiKey: process.env.MEILISEARCH_MASTER_KEY!, // Master key
});

// Create API keys for specific uses
const searchKey = await adminClient.createKey({
  description: 'Search-only key for frontend',
  actions: ['search'],
  indexes: ['articles'],
  expiresAt: null,
});

const indexingKey = await adminClient.createKey({
  description: 'Indexing key for backend',
  actions: ['documents.add', 'documents.delete', 'indexes.create'],
  indexes: ['articles'],
  expiresAt: null,
});

console.log('Search key:', searchKey.key);
console.log('Indexing key:', indexingKey.key);
# .env.local
MEILISEARCH_HOST=http://localhost:7700
MEILISEARCH_MASTER_KEY=your-master-key
MEILISEARCH_SEARCH_KEY=your-search-only-key   # Safe for browser
MEILISEARCH_INDEX_KEY=your-indexing-key       # Server-side only

NEXT_PUBLIC_MEILISEARCH_HOST=http://localhost:7700
NEXT_PUBLIC_MEILISEARCH_SEARCH_KEY=your-search-only-key

Fix 2: Adding Documents

import { MeiliSearch } from 'meilisearch';

const client = new MeiliSearch({
  host: process.env.MEILISEARCH_HOST!,
  apiKey: process.env.MEILISEARCH_INDEX_KEY!,
});

// Add documents — Meilisearch creates the index if it doesn't exist
const task = await client.index('articles').addDocuments([
  {
    id: 1,              // Every document MUST have an 'id' field (or configure the primary key)
    title: 'Getting Started with TypeScript',
    content: 'TypeScript is a typed superset of JavaScript...',
    tags: ['typescript', 'javascript'],
    publishedAt: 1700000000,  // Unix timestamp for numeric filtering
    author: 'Jane Smith',
  },
  {
    id: 2,
    title: 'Next.js App Router Deep Dive',
    content: 'The App Router in Next.js 13+ changes how...',
    tags: ['nextjs', 'react'],
    publishedAt: 1710000000,
    author: 'John Doe',
  },
]);

console.log('Task ID:', task.taskUid);

// Wait for the indexing task to complete
const completedTask = await client.waitForTask(task.taskUid);
console.log('Task status:', completedTask.status); // 'succeeded' or 'failed'

if (completedTask.status === 'failed') {
  console.error('Indexing failed:', completedTask.error);
}
// Custom primary key (if your ID field isn't named 'id')
await client.createIndex('articles', { primaryKey: 'slug' });
// or
await client.index('articles').addDocuments(documents, { primaryKey: 'slug' });

// Update documents (full replacement of matching records)
await client.index('articles').updateDocuments([{ id: 1, title: 'Updated Title' }]);

// Partial update (only specified fields)
await client.index('articles').updateDocumentsInBatches(
  [{ id: 1, title: 'Updated Title' }],
  1000
);

// Delete documents
await client.index('articles').deleteDocument(1);
await client.index('articles').deleteDocuments([1, 2, 3]);
await client.index('articles').deleteAllDocuments();
// Bulk index from a database — common pattern
async function syncToMeilisearch() {
  const client = new MeiliSearch({
    host: process.env.MEILISEARCH_HOST!,
    apiKey: process.env.MEILISEARCH_MASTER_KEY!,
  });

  const articles = await db.query.articles.findMany({
    where: eq(articles.status, 'published'),
  });

  const documents = articles.map((a) => ({
    id: a.id,
    title: a.title,
    content: a.excerpt ?? a.content.slice(0, 500),
    tags: a.tags,
    publishedAt: Math.floor(new Date(a.publishedAt).getTime() / 1000),
    author: a.author.name,
  }));

  // addDocuments in batches of 1000
  const task = await client.index('articles').addDocumentsInBatches(documents, 1000);
  console.log(`Queued ${documents.length} documents`);
}

Fix 3: Index Settings

const index = client.index('articles');

// Configure settings — run once during setup
await index.updateSettings({
  // Which fields are searchable (order = priority)
  searchableAttributes: ['title', 'tags', 'author', 'content'],

  // Fields available for filtering and faceting
  filterableAttributes: ['tags', 'author', 'publishedAt'],

  // Fields available for sorting
  sortableAttributes: ['publishedAt', 'title'],

  // Custom ranking rules (applied after built-in rules)
  rankingRules: [
    'words',
    'typo',
    'proximity',
    'attribute',
    'sort',
    'exactness',
    // Custom: 'asc(field)' or 'desc(field)'
  ],

  // Typo tolerance
  typoTolerance: {
    enabled: true,
    minWordSizeForTypos: {
      oneTypo: 5,   // Minimum 5 chars to allow 1 typo
      twoTypos: 9,  // Minimum 9 chars to allow 2 typos
    },
  },

  // Stop words (ignored in searches)
  stopWords: ['the', 'a', 'an', 'in', 'on', 'at'],

  // Synonyms
  synonyms: {
    javascript: ['js'],
    typescript: ['ts'],
    react: ['reactjs', 'react.js'],
  },

  // Faceting
  faceting: {
    maxValuesPerFacet: 50,
  },

  // Pagination
  pagination: {
    maxTotalHits: 1000,
  },
});

// Wait for settings to apply
const task = await index.updateSettings({ searchableAttributes: ['title'] });
await client.waitForTask(task.taskUid);

Fix 4: Search Queries

// Basic search
const results = await client.index('articles').search('typescript');
console.log(results.hits);      // Array of matching documents
console.log(results.nbHits);    // Total number of matches
console.log(results.processingTimeMs); // How long the search took

// Search with options
const results = await client.index('articles').search('react', {
  limit: 10,
  offset: 0,
  attributesToRetrieve: ['id', 'title', 'author', 'publishedAt'],
  attributesToHighlight: ['title', 'content'],
  attributesToCrop: ['content'],
  cropLength: 30, // Words around the match
  highlightPreTag: '<mark>',
  highlightPostTag: '</mark>',
});

// results.hits[0]._formatted.title = "Getting Started with <mark>React</mark>"

// Filtering
const results = await client.index('articles').search('tutorial', {
  filter: 'tags = "react" AND publishedAt > 1700000000',
  // Filter syntax: =, !=, >, <, >=, <=, IN [a, b, c], NOT, AND, OR
});

// Multiple filters with OR
const results = await client.index('articles').search('', {
  filter: ['tags = "react" OR tags = "nextjs"', 'publishedAt > 1700000000'],
  // Array = AND between elements; string arrays can represent OR with 'OR'
});

// Sorting
const results = await client.index('articles').search('', {
  sort: ['publishedAt:desc'],
  filter: 'tags = "typescript"',
});

// Facets
const results = await client.index('articles').search('', {
  facets: ['tags', 'author'],
});
console.log(results.facetDistribution);
// { tags: { react: 42, typescript: 38, ... }, author: { ... } }
// Multi-index search
const multiResults = await client.multiSearch({
  queries: [
    { indexUid: 'articles', q: 'typescript', limit: 5 },
    { indexUid: 'tutorials', q: 'typescript', limit: 5 },
  ],
});

Fix 5: React Integration

npm install meilisearch react-meilisearch-hooks-web
# or for InstantSearch compatibility:
npm install @meilisearch/instant-meilisearch react-instantsearch
// Using InstantSearch with Meilisearch adapter
'use client';

import { instantMeiliSearch } from '@meilisearch/instant-meilisearch';
import {
  InstantSearch,
  SearchBox,
  Hits,
  Highlight,
  RefinementList,
  Pagination,
  Configure,
} from 'react-instantsearch';

const { searchClient } = instantMeiliSearch(
  process.env.NEXT_PUBLIC_MEILISEARCH_HOST!,
  process.env.NEXT_PUBLIC_MEILISEARCH_SEARCH_KEY!,
  {
    primaryKey: 'id',
    finitePagination: true,
  }
);

function Hit({ hit }: { hit: any }) {
  return (
    <article className="p-4 border rounded mb-3">
      <h2>
        <Highlight attribute="title" hit={hit} />
      </h2>
      <p className="text-sm text-gray-600">
        <Highlight attribute="content" hit={hit} />
      </p>
    </article>
  );
}

export default function SearchPage() {
  return (
    <InstantSearch indexName="articles" searchClient={searchClient}>
      <Configure hitsPerPage={10} />
      <SearchBox placeholder="Search articles..." />
      <div className="flex gap-6 mt-4">
        <RefinementList attribute="tags" />
        <div className="flex-1">
          <Hits hitComponent={Hit} />
          <Pagination />
        </div>
      </div>
    </InstantSearch>
  );
}
// Direct hook usage (simpler for basic search)
'use client';

import { useState } from 'react';
import { MeiliSearch } from 'meilisearch';

const searchClient = new MeiliSearch({
  host: process.env.NEXT_PUBLIC_MEILISEARCH_HOST!,
  apiKey: process.env.NEXT_PUBLIC_MEILISEARCH_SEARCH_KEY!,
});

export function SearchBar() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<any[]>([]);

  const handleSearch = async (q: string) => {
    setQuery(q);
    if (!q.trim()) { setResults([]); return; }

    const { hits } = await searchClient.index('articles').search(q, { limit: 5 });
    setResults(hits);
  };

  return (
    <div className="relative">
      <input
        value={query}
        onChange={(e) => handleSearch(e.target.value)}
        placeholder="Search..."
        className="border rounded px-3 py-2 w-full"
      />
      {results.length > 0 && (
        <ul className="absolute top-full left-0 right-0 bg-white border rounded shadow z-10">
          {results.map((hit) => (
            <li key={hit.id} className="px-4 py-2 hover:bg-gray-50">
              <a href={`/blog/${hit.slug}`}>{hit.title}</a>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Fix 6: Task Monitoring and Debugging

// Check task status (indexing is async)
const task = await client.index('articles').addDocuments(documents);

// Option 1: wait synchronously
const result = await client.waitForTask(task.taskUid, {
  timeOutMs: 10000,   // 10 second timeout
  intervalMs: 200,    // Poll every 200ms
});

if (result.status === 'failed') {
  console.error('Indexing failed:', result.error?.message);
}

// Option 2: check manually
const taskInfo = await client.getTask(task.taskUid);
console.log(taskInfo.status); // 'enqueued' | 'processing' | 'succeeded' | 'failed'

// Get all tasks
const tasks = await client.getTasks({ indexUids: ['articles'], statuses: ['failed'] });
console.log('Failed tasks:', tasks.results);

// Check index stats
const stats = await client.index('articles').getStats();
console.log('Number of documents:', stats.numberOfDocuments);
console.log('Is indexing:', stats.isIndexing);

Meilisearch vs Other Search Engines: When to Pick What

Search is a crowded space and the right engine depends on dataset size, query patterns, and how much infrastructure you want to run. A short comparison helps frame Meilisearch’s strengths and weaknesses.

Meilisearch vs Algolia. Algolia is a hosted SaaS. You pay per record and per search operation, but you never touch a server. Meilisearch is self-hosted (or paid Meilisearch Cloud) and trades operational overhead for predictable cost. Both ship typo tolerance, faceting, and InstantSearch widgets. Algolia’s ranking is more battle-tested for e-commerce relevance out of the box; Meilisearch is faster to start with on small to medium datasets. Pick Algolia if you have budget but no ops team. Pick Meilisearch if you want to control data residency or your dataset is in the hundreds of millions of documents. See Fix: Algolia Not Working when migrating between them.

Meilisearch vs Typesense. Typesense is the closest direct competitor — same self-hosted niche, same typo tolerance focus, similar API shape. Typesense has stronger native vector search (HNSW) and a slightly more mature multi-search/federated search API. Meilisearch added vector search later but ships with a richer ranking-rule customization model. For most CRUD apps with a “search box on top” use case, either works. Benchmark both with your actual documents — they win on different shapes of data.

Meilisearch vs Elasticsearch/OpenSearch. Elasticsearch is a different category. It’s a distributed analytics and search platform — log aggregation, observability, complex aggregations, geo queries on billion-document indexes. Meilisearch is a focused full-text search engine. If you need pipelines, scripted queries, or terabyte-scale ingestion, Elasticsearch (or its fork OpenSearch) is the right tool. If you only need fast, typo-tolerant document search with minimal config, Meilisearch is dramatically simpler. Elasticsearch’s JSON DSL alone has a steeper learning curve than Meilisearch’s entire API.

Meilisearch vs Pagefind/Stork. Pagefind and Stork are static-site search tools. They generate a search index at build time and run it entirely in the browser via WebAssembly. No server, no API, zero infrastructure. For documentation sites or marketing sites under a few thousand pages, they obliterate the operational story of any server-side engine. They lose at: large datasets, real-time indexing, faceted filtering, multi-tenant search. Pick Pagefind for static content under 10MB of indexable text. Pick Meilisearch when content updates frequently or you need filters and facets.

Quick decision table:

NeedPick
Small static site, no backendPagefind / Stork
Hosted, no-ops, premium budgetAlgolia
Self-hosted, simple full-text + typosMeilisearch or Typesense
Strong built-in vector searchTypesense (or Meilisearch 1.6+ with hybrid search)
Analytics, logs, complex queriesElasticsearch / OpenSearch
Multi-tenant SaaS with billions of recordsElasticsearch / OpenSearch

The “typo tolerance” axis is worth highlighting. Meilisearch, Algolia, and Typesense all do prefix + edit-distance matching at query time with no extra configuration. Elasticsearch can do it via fuzzy queries but requires tuning fuzziness, prefix_length, and analyzer chains. Pagefind handles short typos but doesn’t expose tuning knobs. If “typos just work” is a hard requirement, the first three engines deliver it far more cheaply than rolling it on top of Elasticsearch.

Still Not Working?

“index not found” — the index was never created. This happens when you call search() before adding any documents. Add at least one document first (which creates the index), or create it explicitly: await client.createIndex('articles', { primaryKey: 'id' }).

Search returns 0 hits despite documents existing — check searchableAttributes in index settings. If set to a list that excludes the field you’re searching, results will be empty. Run await client.index('articles').getSettings() and inspect the searchableAttributes array.

“Invalid API key” — the key you’re using doesn’t have permission for that action. Search-only keys can’t add documents. Use the master key or a key with documents.add in its actions for indexing.

Documents are there but filters return nothing — the filtered attribute must be in filterableAttributes. Add it: await client.index('articles').updateFilterableAttributes(['tags', 'author']) and wait for the task to complete.

Typos not matching — Meilisearch requires a minimum word length for typo tolerance (5 chars for 1 typo by default). Short words like “ts” won’t fuzzy match “typescript”. Add synonyms instead: { ts: ['typescript'] }.

Search is slow on large datasets — check the host machine. Meilisearch loads index data into memory; if the index is larger than available RAM, the OS swaps and latency spikes. The single biggest knob is RAM. Reduce index size by limiting searchableAttributes to only the fields you actually search, and shrink attributesToRetrieve so the response payload is small. For datasets above tens of millions of documents, evaluate Typesense’s clustering or OpenSearch.

Highlighting works but _formatted is empty in client code — you didn’t request the field. Use attributesToHighlight and read hit._formatted.title rather than hit.title. The original field stays plain; the highlighted copy lives on _formatted. The same gotcha exists in the React Hooks integration when you forget to render the Highlight component.

Browser shows CORS errors hitting Meilisearch — Meilisearch allows all origins by default in development, but if you’ve fronted it with nginx or a CDN, the proxy strips the Access-Control-Allow-Origin header. Configure the proxy to pass through CORS headers, or terminate CORS at the proxy level itself. The class of error matches the patterns covered in Fix: nginx 502 Bad Gateway — wrong reverse-proxy config breaks the response chain in similar ways.

Task queue stuck after a deploy — the task DB lives on disk. If you upgraded Meilisearch between minor versions and the disk format changed, old tasks may sit in enqueued forever. Stop Meilisearch, snapshot meili_data/, and start the new version. The dumps API (POST /dumps) is the supported migration path between major versions. If Meilisearch is running in Docker and the container can’t reach a sibling service, the symptom is usually networking, not Meilisearch — see Fix: docker-compose networking not working before assuming the index is corrupt.

For client-side caching of search results when results don’t change between renders, see Fix: TanStack Query Not Working — it pairs well with Meilisearch’s fast responses to keep typing-driven UIs snappy.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles