Search

intermediate*

Full-text search with typo tolerance, filters, and facets using Meilisearch. Includes indexing utilities and React hooks.

searchmeilisearchfull-textfiltersfacets
Tested on201619TS5.9
$ bunx sinew add developer-experience/search
Interactive demo coming soon

1The Problem

Adding search to your app requires:

  • Fast full-text search with typo tolerance
  • Filtering and faceted navigation
  • Search highlighting
  • Proper indexing strategy

2The Solution

Use Meilisearch for typo-tolerant full-text search with filters, facets, and highlighting. Includes indexing utilities, a React hook for debounced search, and a search box component.

3Files

lib/search/client.ts

lib/search/client.tsTypeScript
import { MeiliSearch, Index } from "meilisearch";

// Admin client (master key) for indexing. Never expose this key to the browser.
const adminClient = new MeiliSearch({
  host: process.env.MEILISEARCH_HOST || "http://localhost:7700",
  apiKey: process.env.MEILISEARCH_API_KEY,
});

// Search-only client for query-time requests. Falls back to the admin key only
// if a search key is not configured.
const searchClient = new MeiliSearch({
  host: process.env.MEILISEARCH_HOST || "http://localhost:7700",
  apiKey: process.env.MEILISEARCH_SEARCH_API_KEY || process.env.MEILISEARCH_API_KEY,
});

export const INDEXES = {
  products: "products",
  articles: "articles",
  users: "users",
} as const;

export type IndexName = (typeof INDEXES)[keyof typeof INDEXES];

// Attributes a user may filter, sort, or facet on, per index. Shared with the
// index settings and used to guard the public search endpoint.
export const indexAttributes: Record<IndexName, { filterable: string[]; sortable: string[] }> = {
  products: {
    filterable: ["category", "brand", "price", "inStock"],
    sortable: ["price", "createdAt", "rating"],
  },
  articles: {
    filterable: ["author", "tags", "publishedAt", "category"],
    sortable: ["publishedAt", "views"],
  },
  users: {
    filterable: ["role", "createdAt"],
    sortable: ["createdAt", "name"],
  },
};

export const MAX_LIMIT = 50;
export const MAX_OFFSET = 10_000;

// Admin index for write operations.
export function getIndex<T extends Record<string, unknown>>(name: IndexName): Index<T> {
  return adminClient.index<T>(name);
}

export interface SearchOptions {
  limit?: number;
  offset?: number;
  filter?: string | string[];
  sort?: string[];
  facets?: string[];
}

// Clamp pagination and reject filter/sort over attributes outside the allowlist.
export function sanitizeSearchOptions(index: IndexName, options: SearchOptions): SearchOptions {
  const allowed = indexAttributes[index];
  const limit = Math.min(Math.max(1, options.limit ?? 20), MAX_LIMIT);
  const offset = Math.min(Math.max(0, options.offset ?? 0), MAX_OFFSET);

  const filterExpr = Array.isArray(options.filter)
    ? options.filter.join(" ")
    : (options.filter ?? "");
  for (const [, attr] of filterExpr.matchAll(
    /([a-zA-Z_][\w.]*)\s*(?:=|!=|>=|<=|>|<|IN|TO|EXISTS|CONTAINS|STARTS WITH)/gi
  )) {
    if (!allowed.filterable.includes(attr))
      throw new Error(`Filtering on "${attr}" is not allowed`);
  }

  for (const entry of options.sort ?? []) {
    const attr = entry.split(":")[0];
    if (!allowed.sortable.includes(attr)) throw new Error(`Sorting on "${attr}" is not allowed`);
  }

  return {
    limit,
    offset,
    filter: options.filter,
    sort: options.sort,
    facets: options.facets?.filter((f) => allowed.filterable.includes(f)),
  };
}

export async function search<T extends Record<string, unknown>>(
  index: IndexName,
  query: string,
  options: SearchOptions = {}
) {
  const safe = sanitizeSearchOptions(index, options);

  const results = await searchClient.index<T>(index).search(query, {
    limit: safe.limit,
    offset: safe.offset,
    filter: safe.filter,
    sort: safe.sort,
    facets: safe.facets,
    attributesToHighlight: ["*"],
    highlightPreTag: "<mark>",
    highlightPostTag: "</mark>",
  });

  return {
    hits: results.hits,
    query: results.query,
    processingTimeMs: results.processingTimeMs,
    estimatedTotalHits: results.estimatedTotalHits ?? results.hits.length,
    facetDistribution: results.facetDistribution,
  };
}

// Admin client for indexing operations.
export function getClient() {
  return adminClient;
}

lib/search/indexing.ts

lib/search/indexing.tsTypeScript
import { getClient, getIndex, indexAttributes, INDEXES, type IndexName } from "./client";

// Filterable/sortable attributes come from the shared allowlist so the search
// endpoint and the index settings can never drift apart.
const indexSettings: Record<IndexName, object> = {
  products: {
    searchableAttributes: ["name", "description", "category", "brand"],
    filterableAttributes: indexAttributes.products.filterable,
    sortableAttributes: indexAttributes.products.sortable,
  },
  articles: {
    searchableAttributes: ["title", "content", "author", "tags"],
    filterableAttributes: indexAttributes.articles.filterable,
    sortableAttributes: indexAttributes.articles.sortable,
  },
  users: {
    searchableAttributes: ["name", "email", "bio"],
    filterableAttributes: indexAttributes.users.filterable,
    sortableAttributes: indexAttributes.users.sortable,
  },
};

export async function initializeIndex(name: IndexName): Promise<void> {
  const client = getClient();
  // createIndex enqueues a task; waitTask() blocks until it finishes so settings
  // and documents don't race index creation on the first run.
  await client.createIndex(name, { primaryKey: "id" }).waitTask();
  const index = getIndex(name);
  await index.updateSettings(indexSettings[name]);
}

export async function addDocuments<T extends { id: string }>(
  index: IndexName,
  documents: T[]
): Promise<void> {
  const idx = getIndex<T>(index);
  await idx.addDocuments(documents);
}

hooks/use-search.ts

hooks/use-search.tsTypeScript
"use client";

import { useState, useCallback, useEffect, useRef } from "react";

export function useSearch<T extends Record<string, unknown>>(
  index: string,
  options: { debounceMs?: number } = {}
) {
  const { debounceMs = 300 } = options;
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<{ hits: T[] } | null>(null);
  const [isLoading, setIsLoading] = useState(false);
  const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);

  const performSearch = useCallback(
    async (searchQuery: string) => {
      if (!searchQuery.trim()) {
        setResults(null);
        return;
      }

      setIsLoading(true);
      try {
        const response = await fetch("/api/search", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ index, query: searchQuery }),
        });
        const data = await response.json();
        setResults(data);
      } catch (error) {
        console.error("Search failed:", error);
      } finally {
        setIsLoading(false);
      }
    },
    [index]
  );

  useEffect(() => {
    if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
    debounceTimerRef.current = setTimeout(() => performSearch(query), debounceMs);
    return () => {
      if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
    };
  }, [query, debounceMs, performSearch]);

  return { query, setQuery, results, isLoading };
}

app/api/search/route.ts

app/api/search/route.tsTypeScript
import { NextRequest, NextResponse } from "next/server";
import {
  search,
  sanitizeSearchOptions,
  type IndexName,
  type SearchOptions,
  INDEXES,
} from "@/lib/search/client";

export async function POST(req: NextRequest) {
  const body = await req.json();
  const { index, query } = body;

  if (!Object.values(INDEXES).includes(index as IndexName)) {
    return NextResponse.json({ error: "Invalid index" }, { status: 400 });
  }
  if (typeof query !== "string") {
    return NextResponse.json({ error: "Query must be a string" }, { status: 400 });
  }

  // Only forward known option keys. Never spread the raw body into Meilisearch.
  const options: SearchOptions = {
    limit: typeof body.limit === "number" ? body.limit : undefined,
    offset: typeof body.offset === "number" ? body.offset : undefined,
    filter: body.filter,
    sort: Array.isArray(body.sort) ? body.sort : undefined,
    facets: Array.isArray(body.facets) ? body.facets : undefined,
  };

  try {
    sanitizeSearchOptions(index as IndexName, options);
  } catch (error) {
    return NextResponse.json(
      { error: error instanceof Error ? error.message : "Invalid search options" },
      { status: 400 }
    );
  }

  const results = await search(index as IndexName, query, options);
  return NextResponse.json(results);
}

4Dependencies

$ bun add meilisearch

5Configuration

Environment Variables

| Variable | Description | Required | | ---------------------------- | ---------------------------------------- | -------- | | MEILISEARCH_HOST | Meilisearch server URL | Yes | | MEILISEARCH_API_KEY | Admin/master key (indexing, server-only) | Yes | | MEILISEARCH_SEARCH_API_KEY | Search-only key used for query requests | No |

6Usage

Index Documents

import { addDocuments } from "@/lib/search/indexing";

await addDocuments("products", [
  { id: "1", name: "Widget", category: "Tools", price: 29.99 },
  { id: "2", name: "Gadget", category: "Electronics", price: 49.99 },
]);
TypeScript

Search with Filters

import { search } from "@/lib/search/client";

const results = await search("products", "widget", {
  filter: "category = Tools AND price < 50",
  sort: ["price:asc"],
  facets: ["category", "brand"],
});
TypeScript

7Alternatives

  • Algolia - Industry-leading search-as-a-service
  • Meilisearch - Open-source, easy to self-host
  • Typesense - Open-source Algolia alternative
  • Orama - Runs client-side in the browser

Related patterns