Meilisearch delivers instant, typo-tolerant full-text search — new MeiliSearch({ host, apiKey }) initializes the client. client.index("products").search(query, { filter, sort, limit, attributesToHighlight }) searches with sub-50ms responses. index.addDocuments(docs) indexes documents (must have an id field by default). index.updateSettings({ searchableAttributes, filterableAttributes, sortableAttributes, rankingRules }) configures the index. Filters: filter: "category = 'shoes' AND price < 100", facets: facets: ["brand", "category"]. Sorting: sort: ["price:asc"]. Highlighting: attributesToHighlight: ["title", "description"] adds _formatted with <em> tags. Geosearch: documents with _geo: { lat, lng } enable filter: "_geoRadius(lat, lng, distance)" and sort: ["_geoPoint(lat,lng):asc"]. Synonyms: index.updateSynonyms({ tv: ["television", "screen"] }). React integration: <InstantSearch searchClient={searchClient} indexName="products"> with <SearchBox>, <Hits>, <RefinementList>, <Pagination> from react-instantsearch. Claude Code generates Meilisearch instant search UIs, faceted navigation, and multi-index federation.
CLAUDE.md for Meilisearch
## Meilisearch Stack
- Version: meilisearch >= 0.40, react-instantsearch >= 7.x (if using React UI)
- Init: const client = new MeiliSearch({ host: process.env.MEILISEARCH_HOST!, apiKey: process.env.MEILISEARCH_API_KEY! })
- Search: const results = await client.index("products").search(query, { limit: 20, attributesToHighlight: ["title"], filter: "inStock = true" })
- Index docs: await client.index("products").addDocuments(docs) — each doc needs a primary key (default: "id")
- Settings: await index.updateSettings({ searchableAttributes: ["title","description"], filterableAttributes: ["category","price"], sortableAttributes: ["price","createdAt"] })
- Results: results.hits (array), results.totalHits, results.processingTimeMs, results.facetDistribution
- Facets: add facets: ["category","brand"] to search options; results.facetDistribution gives counts
Meilisearch Client
// lib/meilisearch/client.ts — Meilisearch SDK with typed helpers
import { MeiliSearch, type SearchParams, type SearchResponse } from "meilisearch"
const client = new MeiliSearch({
host: process.env.MEILISEARCH_HOST!,
apiKey: process.env.MEILISEARCH_API_KEY!,
})
export type ProductDoc = {
id: string
title: string
description: string
category: string
brand: string
price: number
inStock: boolean
tags: string[]
_geo?: { lat: number; lng: number }
}
export type SearchOptions = {
query?: string
filters?: string
sort?: string[]
facets?: string[]
page?: number
hitsPerPage?: number
highlightFields?: string[]
}
export type SearchResult<T> = {
hits: (T & { _formatted?: Partial<T> })[]
totalHits: number
page: number
hitsPerPage: number
processingMs: number
facets?: Record<string, Record<string, number>>
}
/** Search an index with full options */
export async function searchIndex<T extends Record<string, unknown>>(
indexName: string,
options: SearchOptions = {},
): Promise<SearchResult<T>> {
const {
query = "",
filters,
sort,
facets,
page = 1,
hitsPerPage = 20,
highlightFields,
} = options
const params: SearchParams = {
limit: hitsPerPage,
offset: (page - 1) * hitsPerPage,
...(filters ? { filter: filters } : {}),
...(sort ? { sort } : {}),
...(facets ? { facets } : {}),
...(highlightFields
? { attributesToHighlight: highlightFields, highlightPreTag: "<mark>", highlightPostTag: "</mark>" }
: {}),
}
const index = client.index(indexName)
const response = (await index.search(query, params)) as SearchResponse<T>
return {
hits: response.hits,
totalHits: response.totalHits ?? response.hits.length,
page,
hitsPerPage,
processingMs: response.processingTimeMs,
facets: response.facetDistribution,
}
}
/** Add or replace documents */
export async function indexDocuments<T extends { id: string }>(
indexName: string,
documents: T[],
): Promise<void> {
const index = client.index(indexName)
const task = await index.addDocuments(documents)
await client.waitForTask(task.taskUid)
}
/** Update documents (partial) */
export async function updateDocuments<T extends { id: string }>(
indexName: string,
documents: Partial<T & { id: string }>[],
): Promise<void> {
const index = client.index(indexName)
const task = await index.updateDocuments(documents as any)
await client.waitForTask(task.taskUid)
}
/** Delete documents by IDs */
export async function deleteDocuments(indexName: string, ids: string[]): Promise<void> {
const index = client.index(indexName)
const task = await index.deleteDocuments(ids)
await client.waitForTask(task.taskUid)
}
/** Configure index settings */
export async function configureIndex(
indexName: string,
settings: {
searchable?: string[]
filterable?: string[]
sortable?: string[]
rankingRules?: string[]
synonyms?: Record<string, string[]>
stopWords?: string[]
typoTolerance?: { minWordSizeForTypos?: { oneTypo?: number; twoTypos?: number } }
},
): Promise<void> {
const index = client.index(indexName)
const tasks: number[] = []
if (settings.searchable || settings.filterable || settings.sortable || settings.rankingRules || settings.typoTolerance) {
const task = await index.updateSettings({
...(settings.searchable ? { searchableAttributes: settings.searchable } : {}),
...(settings.filterable ? { filterableAttributes: settings.filterable } : {}),
...(settings.sortable ? { sortableAttributes: settings.sortable } : {}),
...(settings.rankingRules ? { rankingRules: settings.rankingRules } : {}),
...(settings.typoTolerance ? { typoTolerance: settings.typoTolerance } : {}),
})
tasks.push(task.taskUid)
}
if (settings.synonyms) {
const task = await index.updateSynonyms(settings.synonyms)
tasks.push(task.taskUid)
}
if (settings.stopWords) {
const task = await index.updateStopWords(settings.stopWords)
tasks.push(task.taskUid)
}
await Promise.all(tasks.map((uid) => client.waitForTask(uid)))
}
/** Geosearch: find documents near a location */
export async function geoSearch<T extends Record<string, unknown>>(
indexName: string,
lat: number,
lng: number,
radiusMeters: number,
query = "",
limit = 20,
): Promise<SearchResult<T>> {
const index = client.index(indexName)
const response = (await index.search(query, {
filter: `_geoRadius(${lat}, ${lng}, ${radiusMeters})`,
sort: [`_geoPoint(${lat},${lng}):asc`],
limit,
})) as SearchResponse<T>
return {
hits: response.hits,
totalHits: response.totalHits ?? response.hits.length,
page: 1,
hitsPerPage: limit,
processingMs: response.processingTimeMs,
}
}
/** Multi-index federated search */
export async function federatedSearch(
query: string,
indexes: Array<{ name: string; limit?: number }>,
): Promise<Record<string, unknown[]>> {
const results = await Promise.all(
indexes.map(async ({ name, limit = 5 }) => {
const index = client.index(name)
const res = await index.search(query, { limit })
return [name, res.hits] as const
}),
)
return Object.fromEntries(results)
}
export { client }
Index Setup Script
// scripts/setup-meilisearch.ts — create and configure indexes
import { client, configureIndex, indexDocuments, type ProductDoc } from "../lib/meilisearch/client"
async function setup() {
// Create products index (will no-op if it exists)
await client.createIndex("products", { primaryKey: "id" }).catch(() => {})
// Configure settings
await configureIndex("products", {
searchable: ["title", "description", "brand", "tags"],
filterable: ["category", "brand", "price", "inStock", "_geo"],
sortable: ["price", "createdAt", "_geo"],
rankingRules: [
"words",
"typo",
"proximity",
"attribute",
"sort",
"exactness",
],
synonyms: {
tv: ["television", "monitor", "screen"],
phone: ["smartphone", "mobile", "cell phone"],
},
stopWords: ["the", "a", "an", "of", "and", "or"],
typoTolerance: {
minWordSizeForTypos: { oneTypo: 4, twoTypos: 8 },
},
})
// Seed sample documents
const products: ProductDoc[] = [
{
id: "prod-1",
title: "Wireless Noise-Canceling Headphones",
description: "Premium over-ear headphones with 30h battery",
category: "electronics",
brand: "SoundPro",
price: 299,
inStock: true,
tags: ["audio", "wireless", "anc"],
},
{
id: "prod-2",
title: "Running Shoes - Men's",
description: "Lightweight performance running shoes",
category: "shoes",
brand: "SpeedRun",
price: 129,
inStock: true,
tags: ["running", "fitness", "sport"],
},
]
await indexDocuments("products", products)
console.log("Meilisearch setup complete")
}
setup().catch(console.error)
React InstantSearch UI
// components/search/ProductSearch.tsx — react-instantsearch faceted search
"use client"
import { instantMeiliSearch } from "@meilisearch/instant-meilisearch"
import {
InstantSearch,
SearchBox,
Hits,
RefinementList,
SortBy,
Pagination,
Highlight,
useInstantSearch,
} from "react-instantsearch"
import type { Hit } from "instantsearch.js"
import type { ProductDoc } from "@/lib/meilisearch/client"
const searchClient = instantMeiliSearch(
process.env.NEXT_PUBLIC_MEILISEARCH_HOST!,
process.env.NEXT_PUBLIC_MEILISEARCH_SEARCH_KEY!, // public search-only key
)
function ProductHit({ hit }: { hit: Hit<ProductDoc> }) {
return (
<article className="rounded-lg border p-4 hover:shadow-md transition-shadow">
<h3 className="font-semibold text-gray-900">
<Highlight attribute="title" hit={hit} />
</h3>
<p className="text-sm text-gray-500 mt-1">
<Highlight attribute="description" hit={hit} />
</p>
<div className="flex items-center justify-between mt-3">
<span className="text-indigo-600 font-bold">${hit.price}</span>
<span
className={`text-xs px-2 py-1 rounded-full ${
hit.inStock ? "bg-green-100 text-green-700" : "bg-red-100 text-red-700"
}`}
>
{hit.inStock ? "In Stock" : "Out of Stock"}
</span>
</div>
</article>
)
}
function SearchStats() {
const { results } = useInstantSearch()
if (!results?.__isArtificial && results?.nbHits !== undefined) {
return (
<p className="text-sm text-gray-500">
{results.nbHits.toLocaleString()} results in {results.processingTimeMS}ms
</p>
)
}
return null
}
export default function ProductSearch() {
return (
<InstantSearch searchClient={searchClient} indexName="products" future={{ preserveSharedStateOnUnmount: true }}>
<div className="max-w-6xl mx-auto p-6">
{/* Search box */}
<SearchBox
classNames={{
root: "mb-6",
input: "w-full rounded-xl border border-gray-300 px-4 py-3 text-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent",
submitIcon: "hidden",
resetIcon: "hidden",
}}
placeholder="Search products…"
/>
<div className="flex gap-8">
{/* Sidebar facets */}
<aside className="w-56 shrink-0">
<div className="mb-6">
<h4 className="font-semibold text-gray-700 mb-2 text-sm uppercase tracking-wide">Category</h4>
<RefinementList
attribute="category"
classNames={{
item: "flex items-center gap-2 py-1",
label: "text-sm text-gray-600 cursor-pointer",
count: "ml-auto text-xs text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded",
checkbox: "rounded",
}}
/>
</div>
<div className="mb-6">
<h4 className="font-semibold text-gray-700 mb-2 text-sm uppercase tracking-wide">Brand</h4>
<RefinementList attribute="brand" limit={8} showMore />
</div>
</aside>
{/* Results */}
<main className="flex-1">
<div className="flex items-center justify-between mb-4">
<SearchStats />
<SortBy
items={[
{ label: "Relevance", value: "products" },
{ label: "Price: Low→High", value: "products:price:asc" },
{ label: "Price: High→Low", value: "products:price:desc" },
]}
classNames={{
select: "rounded-lg border border-gray-300 px-3 py-1.5 text-sm",
}}
/>
</div>
<Hits
hitComponent={ProductHit}
classNames={{ list: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4" }}
/>
<div className="mt-8 flex justify-center">
<Pagination
classNames={{
item: "px-3 py-1 rounded border mx-0.5",
selectedItem: "bg-indigo-600 text-white border-indigo-600",
}}
/>
</div>
</main>
</div>
</div>
</InstantSearch>
)
}
Next.js Search API Route
// app/api/search/products/route.ts — server-side Meilisearch with auth
import { NextResponse } from "next/server"
import { z } from "zod"
import { searchIndex } from "@/lib/meilisearch/client"
const SearchSchema = z.object({
q: z.string().default(""),
category: z.string().optional(),
brand: z.string().optional(),
minPrice: z.coerce.number().optional(),
maxPrice: z.coerce.number().optional(),
inStock: z.coerce.boolean().optional(),
sort: z.enum(["price:asc", "price:desc", "relevance"]).default("relevance"),
page: z.coerce.number().int().min(1).default(1),
})
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const params = SearchSchema.parse(Object.fromEntries(searchParams))
// Build Meilisearch filter string
const filters: string[] = []
if (params.category) filters.push(`category = "${params.category}"`)
if (params.brand) filters.push(`brand = "${params.brand}"`)
if (params.minPrice !== undefined) filters.push(`price >= ${params.minPrice}`)
if (params.maxPrice !== undefined) filters.push(`price <= ${params.maxPrice}`)
if (params.inStock !== undefined) filters.push(`inStock = ${params.inStock}`)
const results = await searchIndex("products", {
query: params.q,
filters: filters.join(" AND ") || undefined,
sort: params.sort === "relevance" ? undefined : [params.sort],
facets: ["category", "brand"],
highlightFields: ["title", "description"],
page: params.page,
hitsPerPage: 24,
})
return NextResponse.json(results)
}
For the Typesense alternative when needing on-premise deployment with a more permissive open-source license (GPL-3 vs Meilisearch’s SSPL), multi-tenant rate limiting per API key, or specific Typesense Cloud managed hosting — Typesense and Meilisearch are nearly identical in features, with Meilisearch having a slightly simpler configuration API and better geosearch, see the Typesense guide. For the Algolia alternative when needing a fully-managed enterprise search service with AI-powered ranking, A/B testing, personalization, and multi-region replication without operating any infrastructure — Algolia is the leading managed search SaaS while Meilisearch is the top open-source self-hosted option with an identical react-instantsearch integration, see the Algolia guide. The Claude Skills 360 bundle includes Meilisearch skill sets covering instant search, faceted navigation, and geosearch. Start with the free tier to try instant search generation.