Build Instant Search with Meilisearch and Next.js

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

Prerequisites

Before getting started, make sure you have:

  • Node.js 20+ installed on your machine
  • Docker and Docker Compose installed
  • Basic knowledge of Next.js and TypeScript
  • pnpm or npm as your package manager
  • A code editor like VS Code

What You'll Build

In this tutorial, you'll create a complete instant search application featuring:

  • A Meilisearch engine deployed via Docker
  • A Next.js API to index and query data
  • A search interface with real-time results (under 50ms)
  • Faceted filters to narrow down results
  • Highlighting of matched search terms
  • A sorting system by relevance, date, or popularity
  • Meilisearch's native typo tolerance

Why Meilisearch?

Meilisearch is an open-source search engine designed to deliver an instant search experience. Unlike Elasticsearch which requires complex configuration, Meilisearch is ready to use in minutes.

Key advantages:

  • Ultra-fast: responses under 50ms, even with millions of documents
  • Typo tolerance: understands typos automatically
  • Filters and facets: refined search without complex setup
  • Open source: self-hostable, no cloud dependency
  • Simple RESTful API: easy integration with any framework

Step 1: Deploy Meilisearch with Docker

Let's start by setting up Meilisearch locally with Docker Compose.

Create a docker-compose.yml file at your project root:

version: "3.8"
 
services:
  meilisearch:
    image: getmeili/meilisearch:v1.12
    container_name: meilisearch
    ports:
      - "7700:7700"
    environment:
      - MEILI_MASTER_KEY=your_secret_key_here
      - MEILI_ENV=development
      - MEILI_DB_PATH=/meili_data
    volumes:
      - meilisearch_data:/meili_data
    restart: unless-stopped
 
volumes:
  meilisearch_data:

Start the container:

docker compose up -d

Verify that Meilisearch is running:

curl http://localhost:7700/health
# {"status":"available"}

You can also access the built-in dashboard at http://localhost:7700 in your browser.

Step 2: Initialize the Next.js Project

Create a new Next.js project with TypeScript:

npx create-next-app@latest meilisearch-app --typescript --tailwind --app --src-dir
cd meilisearch-app

Install the required dependencies:

pnpm add meilisearch react-instantsearch @meilisearch/instant-meilisearch
  • meilisearch: official JavaScript client for interacting with the server
  • react-instantsearch: React components for building search interfaces
  • @meilisearch/instant-meilisearch: adapter connecting Meilisearch to InstantSearch

Step 3: Configure Environment Variables

Create a .env.local file at the project root:

MEILISEARCH_HOST=http://localhost:7700
MEILISEARCH_ADMIN_KEY=your_secret_key_here
NEXT_PUBLIC_MEILISEARCH_HOST=http://localhost:7700
NEXT_PUBLIC_MEILISEARCH_SEARCH_KEY=

The MEILISEARCH_ADMIN_KEY is used server-side for indexing data. The NEXT_PUBLIC_MEILISEARCH_SEARCH_KEY will be generated in the next step — it is limited to search only and can be safely exposed on the client side.

Step 4: Create the Meilisearch Client

Create src/lib/meilisearch.ts:

import { MeiliSearch } from "meilisearch";
 
// Admin client (server-side only)
export const meiliAdmin = new MeiliSearch({
  host: process.env.MEILISEARCH_HOST!,
  apiKey: process.env.MEILISEARCH_ADMIN_KEY!,
});
 
// Function to get or create the search key
export async function getSearchKey(): Promise<string> {
  const keys = await meiliAdmin.getKeys();
  const searchKey = keys.results.find(
    (key) =>
      key.actions.includes("search") && key.actions.length === 1
  );
 
  if (searchKey) {
    return searchKey.key;
  }
 
  // Create a key limited to search only
  const newKey = await meiliAdmin.createKey({
    description: "Public search key",
    actions: ["search"],
    indexes: ["*"],
    expiresAt: null,
  });
 
  return newKey.key;
}

Step 5: Define the Data Schema

For this tutorial, we'll create an index of blog articles. Create src/lib/types.ts:

export interface Article {
  id: string;
  title: string;
  summary: string;
  content: string;
  author: string;
  category: string;
  tags: string[];
  publishedAt: string;
  readingTime: number;
  views: number;
}

Step 6: Create the Seeding Script

Create src/lib/seed.ts to populate Meilisearch with demo data:

import { meiliAdmin } from "./meilisearch";
import type { Article } from "./types";
 
const sampleArticles: Article[] = [
  {
    id: "1",
    title: "Introduction to TypeScript 5.5",
    summary:
      "Discover the new features of TypeScript 5.5 and how they improve your workflow.",
    content:
      "TypeScript 5.5 introduces several groundbreaking features...",
    author: "Sarah Chen",
    category: "TypeScript",
    tags: ["typescript", "javascript", "web"],
    publishedAt: "2026-03-15",
    readingTime: 8,
    views: 2450,
  },
  {
    id: "2",
    title: "Building REST APIs with Hono and Bun",
    summary:
      "Practical guide to creating fast APIs with the Hono framework on Bun.",
    content:
      "Hono is an ultra-lightweight web framework designed for edge functions...",
    author: "Marc Dubois",
    category: "Backend",
    tags: ["hono", "bun", "api", "rest"],
    publishedAt: "2026-03-10",
    readingTime: 12,
    views: 1890,
  },
  {
    id: "3",
    title: "Next.js 15: The Complete PPR Guide",
    summary:
      "Everything you need to know about Partial Prerendering in Next.js 15.",
    content:
      "Partial Prerendering combines the best of SSR and SSG...",
    author: "Anis Marrouchi",
    category: "Frontend",
    tags: ["nextjs", "react", "ssr", "performance"],
    publishedAt: "2026-02-28",
    readingTime: 15,
    views: 3200,
  },
  {
    id: "4",
    title: "Docker Compose for Developers",
    summary:
      "Master Docker Compose to orchestrate your development environments.",
    content:
      "Docker Compose simplifies multi-container management...",
    author: "Fatma Ben Ali",
    category: "DevOps",
    tags: ["docker", "devops", "containers"],
    publishedAt: "2026-03-20",
    readingTime: 10,
    views: 1540,
  },
  {
    id: "5",
    title: "Modern Authentication with Passkeys",
    summary:
      "Implement passwordless authentication with WebAuthn and Passkeys.",
    content:
      "Passkeys represent the future of web authentication...",
    author: "Karim Mansour",
    category: "Security",
    tags: ["auth", "security", "passkeys", "webauthn"],
    publishedAt: "2026-03-25",
    readingTime: 14,
    views: 4100,
  },
];
 
async function seedMeilisearch() {
  console.log("Configuring articles index...");
 
  // Create or update the index
  const index = meiliAdmin.index("articles");
 
  // Configure filterable and sortable attributes
  await index.updateSettings({
    filterableAttributes: [
      "category",
      "tags",
      "author",
      "readingTime",
    ],
    sortableAttributes: [
      "publishedAt",
      "views",
      "readingTime",
    ],
    searchableAttributes: [
      "title",
      "summary",
      "content",
      "author",
      "tags",
    ],
    // Attributes shown in results (exclude full content)
    displayedAttributes: [
      "id",
      "title",
      "summary",
      "author",
      "category",
      "tags",
      "publishedAt",
      "readingTime",
      "views",
    ],
  });
 
  console.log("Indexing articles...");
 
  // Add documents
  const response = await index.addDocuments(sampleArticles);
  console.log("Indexing task created:", response.taskUid);
 
  // Wait for indexing to complete
  await meiliAdmin.waitForTask(response.taskUid);
  console.log("Indexing completed successfully!");
 
  // Verify
  const stats = await index.getStats();
  console.log(`${stats.numberOfDocuments} documents indexed`);
}
 
seedMeilisearch().catch(console.error);

Add a script to your package.json:

{
  "scripts": {
    "seed": "npx tsx src/lib/seed.ts"
  }
}

Run it:

pnpm seed

Step 7: Create the Search API Route

Create src/app/api/search/route.ts for server-side search:

import { meiliAdmin } from "@/lib/meilisearch";
import { NextRequest, NextResponse } from "next/server";
 
export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const query = searchParams.get("q") || "";
  const category = searchParams.get("category") || null;
  const sort = searchParams.get("sort") || null;
  const page = parseInt(searchParams.get("page") || "1");
  const limit = parseInt(searchParams.get("limit") || "10");
 
  try {
    const index = meiliAdmin.index("articles");
 
    const filters: string[] = [];
    if (category) {
      filters.push(`category = "${category}"`);
    }
 
    const results = await index.search(query, {
      filter: filters.length > 0 ? filters.join(" AND ") : undefined,
      sort: sort ? [sort] : undefined,
      limit,
      offset: (page - 1) * limit,
      attributesToHighlight: ["title", "summary"],
      highlightPreTag: '<mark class="bg-yellow-200">',
      highlightPostTag: "</mark>",
      attributesToCrop: ["summary"],
      cropLength: 150,
    });
 
    return NextResponse.json({
      hits: results.hits,
      query: results.query,
      processingTimeMs: results.processingTimeMs,
      totalHits: results.estimatedTotalHits,
      page,
      totalPages: Math.ceil(
        (results.estimatedTotalHits || 0) / limit
      ),
    });
  } catch (error) {
    console.error("Search error:", error);
    return NextResponse.json(
      { error: "Search failed" },
      { status: 500 }
    );
  }
}

Step 8: Build the Search Component with InstantSearch

Now let's create a rich search interface using React InstantSearch. Create src/components/Search.tsx:

"use client";
 
import { instantMeiliSearch } from "@meilisearch/instant-meilisearch";
import {
  InstantSearch,
  SearchBox,
  Hits,
  RefinementList,
  Pagination,
  Stats,
  Highlight,
  SortBy,
  ClearRefinements,
  Configure,
} from "react-instantsearch";
 
const { searchClient } = instantMeiliSearch(
  process.env.NEXT_PUBLIC_MEILISEARCH_HOST!,
  process.env.NEXT_PUBLIC_MEILISEARCH_SEARCH_KEY!
);
 
function ArticleHit({ hit }: { hit: any }) {
  return (
    <article className="rounded-lg border border-gray-200 p-6 transition-shadow hover:shadow-md">
      <div className="mb-2 flex items-center gap-2">
        <span className="rounded-full bg-blue-100 px-3 py-1 text-xs font-medium text-blue-800">
          {hit.category}
        </span>
        <span className="text-sm text-gray-500">
          {hit.readingTime} min read
        </span>
      </div>
 
      <h3 className="mb-2 text-xl font-semibold text-gray-900">
        <Highlight attribute="title" hit={hit} />
      </h3>
 
      <p className="mb-3 text-gray-600">
        <Highlight attribute="summary" hit={hit} />
      </p>
 
      <div className="flex items-center justify-between">
        <span className="text-sm text-gray-500">
          By {hit.author}
        </span>
        <div className="flex gap-2">
          {hit.tags?.slice(0, 3).map((tag: string) => (
            <span
              key={tag}
              className="rounded bg-gray-100 px-2 py-1 text-xs text-gray-600"
            >
              {tag}
            </span>
          ))}
        </div>
      </div>
    </article>
  );
}
 
export default function Search() {
  return (
    <InstantSearch
      indexName="articles"
      searchClient={searchClient}
    >
      <Configure hitsPerPage={10} />
 
      <div className="mx-auto max-w-6xl p-6">
        <h1 className="mb-8 text-3xl font-bold text-gray-900">
          Search Articles
        </h1>
 
        {/* Search bar */}
        <div className="mb-6">
          <SearchBox
            placeholder="Type your search..."
            classNames={{
              root: "relative",
              form: "relative",
              input:
                "w-full rounded-lg border border-gray-300 px-4 py-3 pl-10 text-lg focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200",
              submit: "absolute left-3 top-1/2 -translate-y-1/2",
              reset: "absolute right-3 top-1/2 -translate-y-1/2",
            }}
          />
        </div>
 
        {/* Stats */}
        <div className="mb-4">
          <Stats
            translations={{
              rootElementText({ nbHits, processingTimeMS }) {
                return `${nbHits} results found in ${processingTimeMS}ms`;
              },
            }}
          />
        </div>
 
        <div className="flex gap-8">
          {/* Sidebar filters */}
          <aside className="w-64 flex-shrink-0">
            <div className="mb-6">
              <h3 className="mb-3 font-semibold text-gray-700">
                Sort by
              </h3>
              <SortBy
                items={[
                  {
                    label: "Relevance",
                    value: "articles",
                  },
                  {
                    label: "Most Recent",
                    value: "articles:publishedAt:desc",
                  },
                  {
                    label: "Most Popular",
                    value: "articles:views:desc",
                  },
                ]}
                classNames={{
                  select:
                    "w-full rounded border border-gray-300 px-3 py-2",
                }}
              />
            </div>
 
            <div className="mb-6">
              <h3 className="mb-3 font-semibold text-gray-700">
                Category
              </h3>
              <RefinementList
                attribute="category"
                classNames={{
                  list: "space-y-2",
                  label: "flex items-center gap-2 cursor-pointer",
                  checkbox:
                    "rounded border-gray-300 text-blue-600",
                  labelText: "text-sm text-gray-600",
                  count:
                    "text-xs text-gray-400 bg-gray-100 rounded-full px-2",
                }}
              />
            </div>
 
            <div className="mb-6">
              <h3 className="mb-3 font-semibold text-gray-700">
                Tags
              </h3>
              <RefinementList
                attribute="tags"
                limit={10}
                showMore
                classNames={{
                  list: "space-y-2",
                  label: "flex items-center gap-2 cursor-pointer",
                  checkbox:
                    "rounded border-gray-300 text-blue-600",
                  labelText: "text-sm text-gray-600",
                  count:
                    "text-xs text-gray-400 bg-gray-100 rounded-full px-2",
                }}
              />
            </div>
 
            <ClearRefinements
              translations={{
                resetButtonText: "Clear all filters",
              }}
              classNames={{
                button:
                  "text-sm text-blue-600 hover:text-blue-800 underline",
              }}
            />
          </aside>
 
          {/* Results */}
          <main className="flex-1">
            <Hits
              hitComponent={ArticleHit}
              classNames={{
                list: "space-y-4",
              }}
            />
 
            <div className="mt-8">
              <Pagination
                classNames={{
                  list: "flex gap-2 justify-center",
                  item: "rounded border border-gray-300 px-3 py-2 hover:bg-gray-50",
                  selectedItem:
                    "rounded bg-blue-600 px-3 py-2 text-white",
                }}
              />
            </div>
          </main>
        </div>
      </div>
    </InstantSearch>
  );
}

Step 9: Integrate into the Main Page

Create src/app/page.tsx:

import Search from "@/components/Search";
 
export default function HomePage() {
  return (
    <main className="min-h-screen bg-white">
      <Search />
    </main>
  );
}

Step 10: Automatic Indexing with Route Handlers

For real-world use, you'll want to automatically index new content. Create src/app/api/index/route.ts:

import { meiliAdmin } from "@/lib/meilisearch";
import { NextRequest, NextResponse } from "next/server";
import type { Article } from "@/lib/types";
 
// Webhook to index a new article
export async function POST(request: NextRequest) {
  // Verify authentication token
  const authHeader = request.headers.get("authorization");
  if (authHeader !== `Bearer ${process.env.INDEX_API_SECRET}`) {
    return NextResponse.json(
      { error: "Unauthorized" },
      { status: 401 }
    );
  }
 
  try {
    const article: Article = await request.json();
    const index = meiliAdmin.index("articles");
 
    // addDocuments performs an upsert: creates or updates
    const task = await index.addDocuments([article]);
    await meiliAdmin.waitForTask(task.taskUid);
 
    return NextResponse.json({
      success: true,
      taskUid: task.taskUid,
    });
  } catch (error) {
    console.error("Indexing error:", error);
    return NextResponse.json(
      { error: "Indexing failed" },
      { status: 500 }
    );
  }
}
 
// Remove an article from the index
export async function DELETE(request: NextRequest) {
  const authHeader = request.headers.get("authorization");
  if (authHeader !== `Bearer ${process.env.INDEX_API_SECRET}`) {
    return NextResponse.json(
      { error: "Unauthorized" },
      { status: 401 }
    );
  }
 
  const { id } = await request.json();
  const index = meiliAdmin.index("articles");
 
  const task = await index.deleteDocument(id);
  await meiliAdmin.waitForTask(task.taskUid);
 
  return NextResponse.json({ success: true });
}

Step 11: Custom Search with Debounce

For finer control, you can create a custom hook. Create src/hooks/useSearch.ts:

"use client";
 
import { useState, useEffect } from "react";
 
interface SearchResult {
  hits: any[];
  query: string;
  processingTimeMs: number;
  totalHits: number;
  page: number;
  totalPages: number;
}
 
export function useSearch(debounceMs = 300) {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<SearchResult | null>(
    null
  );
  const [isLoading, setIsLoading] = useState(false);
  const [debouncedQuery, setDebouncedQuery] = useState("");
 
  // Debounce the query
  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedQuery(query);
    }, debounceMs);
 
    return () => clearTimeout(timer);
  }, [query, debounceMs]);
 
  // Perform the search
  useEffect(() => {
    if (!debouncedQuery.trim()) {
      setResults(null);
      return;
    }
 
    const controller = new AbortController();
 
    async function search() {
      setIsLoading(true);
      try {
        const response = await fetch(
          `/api/search?q=${encodeURIComponent(debouncedQuery)}`,
          { signal: controller.signal }
        );
        const data = await response.json();
        setResults(data);
      } catch (error) {
        if (
          error instanceof Error &&
          error.name !== "AbortError"
        ) {
          console.error("Search error:", error);
        }
      } finally {
        setIsLoading(false);
      }
    }
 
    search();
 
    return () => controller.abort();
  }, [debouncedQuery]);
 
  return {
    query,
    setQuery,
    results,
    isLoading,
  };
}

This hook can be used for a fully custom search interface without depending on InstantSearch.

Step 12: Optimize for Production

Configure Synonyms

Meilisearch supports synonyms to improve relevance:

const index = meiliAdmin.index("articles");
 
await index.updateSettings({
  synonyms: {
    js: ["javascript"],
    ts: ["typescript"],
    react: ["reactjs"],
    vue: ["vuejs"],
    api: ["application programming interface"],
    db: ["database"],
  },
});

Configure Stop Words

Stop words are ignored during search to improve relevance:

await index.updateSettings({
  stopWords: [
    "the",
    "a",
    "an",
    "and",
    "or",
    "but",
    "in",
    "on",
    "at",
    "to",
    "for",
    "of",
    "with",
    "by",
    "is",
    "are",
    "was",
    "were",
    "this",
    "that",
  ],
});

Configure Ranking

Customize ranking rules to match your needs:

await index.updateSettings({
  rankingRules: [
    "words",
    "typo",
    "proximity",
    "attribute",
    "sort",
    "exactness",
    "views:desc", // Favor popular articles
  ],
});

Step 13: Secure the Deployment

For production, create a docker-compose.prod.yml:

version: "3.8"
 
services:
  meilisearch:
    image: getmeili/meilisearch:v1.12
    container_name: meilisearch-prod
    ports:
      - "127.0.0.1:7700:7700"
    environment:
      - MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
      - MEILI_ENV=production
      - MEILI_DB_PATH=/meili_data
      - MEILI_MAX_INDEXING_MEMORY=512Mb
      - MEILI_HTTP_PAYLOAD_SIZE_LIMIT=100Mb
    volumes:
      - meilisearch_data:/meili_data
    restart: always
    deploy:
      resources:
        limits:
          memory: 1G
 
volumes:
  meilisearch_data:

Key points for production:

  • Expose only on localhost (127.0.0.1:7700) and use a reverse proxy (Nginx/Caddy)
  • Use MEILI_ENV=production to enable security protections
  • Generate a strong master key: openssl rand -base64 32
  • Limit memory with MEILI_MAX_INDEXING_MEMORY
  • Back up regularly the Docker volume

Nginx Configuration

server {
    server_name search.yourdomain.com;
 
    location / {
        proxy_pass http://127.0.0.1:7700;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
 
    # SSL via Let's Encrypt
    listen 443 ssl;
    ssl_certificate /etc/letsencrypt/live/search.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/search.yourdomain.com/privkey.pem;
}

Step 14: Test Your Implementation

Launch your application and test the features:

# Terminal 1: Meilisearch
docker compose up -d
 
# Terminal 2: Index data
pnpm seed
 
# Terminal 3: Start Next.js
pnpm dev

Open http://localhost:3000 and test:

  1. Instant search: type a term and watch results appear in real-time
  2. Typo tolerance: type "typesript" (with a typo) — Meilisearch still finds "TypeScript" results
  3. Filters: click a category to filter results
  4. Sorting: switch between relevance, date, and popularity
  5. Highlighting: search terms are highlighted in yellow within results

Troubleshooting

Meilisearch won't start

Check that port 7700 is free:

lsof -i :7700

If another service is using that port, change the mapping in docker-compose.yml.

Results don't display

Verify that data is properly indexed:

curl http://localhost:7700/indexes/articles/stats \
  -H "Authorization: Bearer your_secret_key_here"

Client-side CORS errors

Meilisearch allows all origins by default in development mode. In production, configure your reverse proxy to handle CORS headers.

Slow search responses

Check your searchableAttributes — indexing too many large fields (like content) can slow down responses. Limit to essential fields.

Next Steps

Now that your search engine is in place, here are ways to take it further:

  • Multi-index: create separate indexes for different content types (articles, products, users) and search across them simultaneously
  • Geo-search: Meilisearch supports geographic search — useful for directories or marketplaces
  • Analytics: track the most searched terms to improve your content
  • Federated search: combine multiple indexes in a single query with the multi-search feature
  • CI/CD integration: automate reindexing on each deployment

Conclusion

You've built a complete instant search application with Meilisearch and Next.js. In under 50ms, your users get relevant results with typo tolerance, faceted filters, and custom sorting.

Meilisearch stands out for its ease of setup and exceptional performance. Unlike cloud solutions like Algolia, it is fully open source and self-hostable, making it an ideal choice for projects that prioritize data sovereignty.

The complete source code from this tutorial is ready to be adapted to your use case — whether it's a blog, technical documentation, or an e-commerce platform.


Want to read more tutorials? Check out our latest tutorial on Next.js 15 Partial Prerendering (PPR): Build a Blazing-Fast Dashboard with Hybrid Rendering.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles