Construire une recherche instantanée avec Meilisearch et Next.js

Prérequis
Avant de commencer, assurez-vous de disposer de :
- Node.js 20+ installé sur votre machine
- Docker et Docker Compose installés
- Des connaissances de base en Next.js et TypeScript
- pnpm ou npm comme gestionnaire de paquets
- Un éditeur de code comme VS Code
Ce que vous allez construire
Dans ce tutoriel, vous allez créer une application de recherche instantanée complète comprenant :
- Un moteur de recherche Meilisearch déployé via Docker
- Une API Next.js pour indexer et interroger les données
- Une interface de recherche avec résultats en temps réel (moins de 50 ms)
- Des filtres à facettes pour affiner les résultats
- La mise en surbrillance des termes recherchés
- Un système de tri par pertinence, date ou popularité
- La tolérance aux fautes de frappe native de Meilisearch
Pourquoi Meilisearch ?
Meilisearch est un moteur de recherche open source conçu pour offrir une expérience de recherche instantanée. Contrairement à Elasticsearch qui nécessite une configuration complexe, Meilisearch est prêt à utiliser en quelques minutes.
Avantages clés :
- Ultra-rapide : réponses en moins de 50 ms, même sur des millions de documents
- Tolérance aux fautes de frappe : comprend les erreurs de saisie automatiquement
- Filtres et facettes : recherche affinée sans configuration complexe
- Open source : auto-hébergeable, pas de dépendance à un service cloud
- API RESTful simple : intégration facile avec tout framework
Étape 1 : Déployer Meilisearch avec Docker
Commençons par mettre en place Meilisearch localement avec Docker Compose.
Créez un fichier docker-compose.yml à la racine de votre projet :
version: "3.8"
services:
meilisearch:
image: getmeili/meilisearch:v1.12
container_name: meilisearch
ports:
- "7700:7700"
environment:
- MEILI_MASTER_KEY=votre_cle_secrete_ici
- MEILI_ENV=development
- MEILI_DB_PATH=/meili_data
volumes:
- meilisearch_data:/meili_data
restart: unless-stopped
volumes:
meilisearch_data:Lancez le conteneur :
docker compose up -dVérifiez que Meilisearch fonctionne :
curl http://localhost:7700/health
# {"status":"available"}Vous pouvez aussi accéder au tableau de bord intégré sur http://localhost:7700 dans votre navigateur.
Étape 2 : Initialiser le projet Next.js
Créez un nouveau projet Next.js avec TypeScript :
npx create-next-app@latest meilisearch-app --typescript --tailwind --app --src-dir
cd meilisearch-appInstallez les dépendances nécessaires :
pnpm add meilisearch react-instantsearch @meilisearch/instant-meilisearch- meilisearch : client officiel JavaScript pour interagir avec le serveur
- react-instantsearch : composants React pour construire des interfaces de recherche
- @meilisearch/instant-meilisearch : adaptateur qui connecte Meilisearch à InstantSearch
Étape 3 : Configurer les variables d'environnement
Créez un fichier .env.local à la racine du projet :
MEILISEARCH_HOST=http://localhost:7700
MEILISEARCH_ADMIN_KEY=votre_cle_secrete_ici
NEXT_PUBLIC_MEILISEARCH_HOST=http://localhost:7700
NEXT_PUBLIC_MEILISEARCH_SEARCH_KEY=La clé MEILISEARCH_ADMIN_KEY est utilisée côté serveur pour indexer les données. La clé NEXT_PUBLIC_MEILISEARCH_SEARCH_KEY sera générée dans la prochaine étape — elle est limitée à la recherche uniquement et peut être exposée côté client en toute sécurité.
Étape 4 : Créer le client Meilisearch
Créez le fichier src/lib/meilisearch.ts :
import { MeiliSearch } from "meilisearch";
// Client admin (côté serveur uniquement)
export const meiliAdmin = new MeiliSearch({
host: process.env.MEILISEARCH_HOST!,
apiKey: process.env.MEILISEARCH_ADMIN_KEY!,
});
// Fonction pour obtenir ou créer la clé de recherche
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;
}
// Créer une clé limitée à la recherche
const newKey = await meiliAdmin.createKey({
description: "Clé de recherche publique",
actions: ["search"],
indexes: ["*"],
expiresAt: null,
});
return newKey.key;
}Étape 5 : Définir le schéma de données
Pour ce tutoriel, nous allons créer un index d'articles de blog. Créez 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;
}Étape 6 : Créer le script d'indexation
Créez src/lib/seed.ts pour alimenter Meilisearch avec des données de démonstration :
import { meiliAdmin } from "./meilisearch";
import type { Article } from "./types";
const sampleArticles: Article[] = [
{
id: "1",
title: "Introduction à TypeScript 5.5",
summary:
"Découvrez les nouvelles fonctionnalités de TypeScript 5.5 et comment elles améliorent votre workflow.",
content:
"TypeScript 5.5 introduit plusieurs fonctionnalités révolutionnaires...",
author: "Sarah Chen",
category: "TypeScript",
tags: ["typescript", "javascript", "web"],
publishedAt: "2026-03-15",
readingTime: 8,
views: 2450,
},
{
id: "2",
title: "Construire des APIs REST avec Hono et Bun",
summary:
"Guide pratique pour créer des APIs rapides avec le framework Hono sur Bun.",
content:
"Hono est un framework web ultra-léger conçu pour les 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 : Le guide complet du PPR",
summary:
"Tout savoir sur le Partial Prerendering dans Next.js 15.",
content:
"Le Partial Prerendering combine le meilleur du SSR et du SSG...",
author: "Anis Marrouchi",
category: "Frontend",
tags: ["nextjs", "react", "ssr", "performance"],
publishedAt: "2026-02-28",
readingTime: 15,
views: 3200,
},
{
id: "4",
title: "Docker Compose pour les développeurs",
summary:
"Maîtrisez Docker Compose pour orchestrer vos environnements de développement.",
content:
"Docker Compose simplifie la gestion de conteneurs multiples...",
author: "Fatma Ben Ali",
category: "DevOps",
tags: ["docker", "devops", "conteneurs"],
publishedAt: "2026-03-20",
readingTime: 10,
views: 1540,
},
{
id: "5",
title: "Authentification moderne avec Passkeys",
summary:
"Implémentez une authentification sans mot de passe avec WebAuthn et Passkeys.",
content:
"Les Passkeys représentent le futur de l authentification web...",
author: "Karim Mansour",
category: "Sécurité",
tags: ["auth", "sécurité", "passkeys", "webauthn"],
publishedAt: "2026-03-25",
readingTime: 14,
views: 4100,
},
];
async function seedMeilisearch() {
console.log("Configuration de l index articles...");
// Créer ou mettre à jour l'index
const index = meiliAdmin.index("articles");
// Configurer les attributs filtrables et triables
await index.updateSettings({
filterableAttributes: [
"category",
"tags",
"author",
"readingTime",
],
sortableAttributes: [
"publishedAt",
"views",
"readingTime",
],
searchableAttributes: [
"title",
"summary",
"content",
"author",
"tags",
],
// Attributs affichés dans les résultats (exclure le contenu complet)
displayedAttributes: [
"id",
"title",
"summary",
"author",
"category",
"tags",
"publishedAt",
"readingTime",
"views",
],
});
console.log("Indexation des articles...");
// Ajouter les documents
const response = await index.addDocuments(sampleArticles);
console.log("Tâche d indexation créée :", response.taskUid);
// Attendre la fin de l'indexation
await meiliAdmin.waitForTask(response.taskUid);
console.log("Indexation terminée avec succès !");
// Vérifier
const stats = await index.getStats();
console.log(`${stats.numberOfDocuments} documents indexés`);
}
seedMeilisearch().catch(console.error);Ajoutez un script dans votre package.json :
{
"scripts": {
"seed": "npx tsx src/lib/seed.ts"
}
}Exécutez-le :
pnpm seedÉtape 7 : Créer la route API de recherche
Créez src/app/api/search/route.ts pour une recherche côté serveur :
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("Erreur de recherche :", error);
return NextResponse.json(
{ error: "Erreur lors de la recherche" },
{ status: 500 }
);
}
}Étape 8 : Construire le composant de recherche avec InstantSearch
Maintenant, créons une interface de recherche riche en utilisant React InstantSearch. Créez 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 de lecture
</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">
Par {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">
Rechercher des articles
</h1>
{/* Barre de recherche */}
<div className="mb-6">
<SearchBox
placeholder="Tapez votre recherche..."
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>
{/* Statistiques */}
<div className="mb-4">
<Stats
translations={{
rootElementText({ nbHits, processingTimeMS }) {
return `${nbHits} résultats trouvés en ${processingTimeMS}ms`;
},
}}
/>
</div>
<div className="flex gap-8">
{/* Filtres latéraux */}
<aside className="w-64 flex-shrink-0">
<div className="mb-6">
<h3 className="mb-3 font-semibold text-gray-700">
Trier par
</h3>
<SortBy
items={[
{
label: "Pertinence",
value: "articles",
},
{
label: "Plus récents",
value: "articles:publishedAt:desc",
},
{
label: "Plus populaires",
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">
Catégorie
</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: "Effacer les filtres",
}}
classNames={{
button:
"text-sm text-blue-600 hover:text-blue-800 underline",
}}
/>
</aside>
{/* Résultats */}
<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>
);
}Étape 9 : Intégrer dans la page principale
Créez src/app/page.tsx :
import Search from "@/components/Search";
export default function HomePage() {
return (
<main className="min-h-screen bg-white">
<Search />
</main>
);
}Étape 10 : Indexation automatique avec les Route Handlers
Pour un cas réel, vous voudrez indexer automatiquement les nouveaux contenus. Créez src/app/api/index/route.ts :
import { meiliAdmin } from "@/lib/meilisearch";
import { NextRequest, NextResponse } from "next/server";
import type { Article } from "@/lib/types";
// Webhook pour indexer un nouvel article
export async function POST(request: NextRequest) {
// Vérifier le token d'authentification
const authHeader = request.headers.get("authorization");
if (authHeader !== `Bearer ${process.env.INDEX_API_SECRET}`) {
return NextResponse.json(
{ error: "Non autorisé" },
{ status: 401 }
);
}
try {
const article: Article = await request.json();
const index = meiliAdmin.index("articles");
// addDocuments fait un upsert : crée ou met à jour
const task = await index.addDocuments([article]);
await meiliAdmin.waitForTask(task.taskUid);
return NextResponse.json({
success: true,
taskUid: task.taskUid,
});
} catch (error) {
console.error("Erreur d indexation :", error);
return NextResponse.json(
{ error: "Erreur lors de l indexation" },
{ status: 500 }
);
}
}
// Supprimer un article de l'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: "Non autorisé" },
{ 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 });
}Étape 11 : Recherche avec debounce personnalisé
Pour un contrôle plus fin, vous pouvez créer un hook personnalisé. Créez src/hooks/useSearch.ts :
"use client";
import { useState, useEffect, useCallback } 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 la requête
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(query);
}, debounceMs);
return () => clearTimeout(timer);
}, [query, debounceMs]);
// Effectuer la recherche
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("Erreur de recherche :", error);
}
} finally {
setIsLoading(false);
}
}
search();
return () => controller.abort();
}, [debouncedQuery]);
return {
query,
setQuery,
results,
isLoading,
};
}Ce hook peut être utilisé pour une interface de recherche entièrement personnalisée, sans dépendance à InstantSearch.
Étape 12 : Optimiser pour la production
Configurer les synonymes
Meilisearch supporte les synonymes pour améliorer la pertinence :
const index = meiliAdmin.index("articles");
await index.updateSettings({
synonyms: {
js: ["javascript"],
ts: ["typescript"],
react: ["reactjs"],
vue: ["vuejs"],
api: ["interface de programmation"],
db: ["base de données", "database"],
},
});Configurer les stop words
Les mots vides (stop words) sont ignorés lors de la recherche pour améliorer la pertinence :
await index.updateSettings({
stopWords: [
"le",
"la",
"les",
"de",
"du",
"des",
"un",
"une",
"et",
"en",
"à",
"pour",
"avec",
"sur",
"dans",
"est",
"sont",
"ce",
"cette",
"qui",
"que",
],
});Configurer le classement
Personnalisez les règles de classement selon vos besoins :
await index.updateSettings({
rankingRules: [
"words",
"typo",
"proximity",
"attribute",
"sort",
"exactness",
"views:desc", // Favoriser les articles populaires
],
});Étape 13 : Sécuriser le déploiement
Pour la production, créez un 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:Points importants pour la production :
- Exposez uniquement sur localhost (
127.0.0.1:7700) et utilisez un reverse proxy (Nginx/Caddy) - Utilisez
MEILI_ENV=productionpour activer les protections de sécurité - Générez une clé master forte :
openssl rand -base64 32 - Limitez la mémoire avec
MEILI_MAX_INDEXING_MEMORY - Sauvegardez régulièrement le volume Docker
Configuration Nginx
server {
server_name search.votredomaine.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.votredomaine.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/search.votredomaine.com/privkey.pem;
}Étape 14 : Tester votre implémentation
Lancez votre application et testez les fonctionnalités :
# Terminal 1 : Meilisearch
docker compose up -d
# Terminal 2 : Indexer les données
pnpm seed
# Terminal 3 : Lancer Next.js
pnpm devOuvrez http://localhost:3000 et testez :
- Recherche instantanée : tapez un terme et observez les résultats apparaître en temps réel
- Tolérance aux fautes : tapez "typesript" (avec une faute) — Meilisearch trouve quand même les résultats "TypeScript"
- Filtres : cliquez sur une catégorie pour filtrer les résultats
- Tri : changez entre pertinence, date et popularité
- Mise en surbrillance : les termes recherchés sont surlignés en jaune dans les résultats
Dépannage
Meilisearch ne démarre pas
Vérifiez que le port 7700 est libre :
lsof -i :7700Si un autre service utilise ce port, modifiez le mapping dans docker-compose.yml.
Les résultats ne s'affichent pas
Vérifiez que les données sont bien indexées :
curl http://localhost:7700/indexes/articles/stats \
-H "Authorization: Bearer votre_cle_secrete_ici"Erreur CORS côté client
Meilisearch autorise toutes les origines par défaut en mode développement. En production, configurez votre reverse proxy pour gérer les en-têtes CORS.
La recherche est lente
Vérifiez les attributs searchableAttributes — indexer trop de champs volumineux (comme content) peut ralentir les réponses. Limitez aux champs essentiels.
Prochaines étapes
Maintenant que votre moteur de recherche est en place, voici des pistes pour aller plus loin :
- Multi-index : créez des index séparés pour différents types de contenu (articles, produits, utilisateurs) et recherchez dans tous simultanément
- Geo-search : Meilisearch supporte la recherche géographique — utile pour un annuaire ou une marketplace
- Analytics : suivez les termes les plus recherchés pour améliorer votre contenu
- Fédération de recherche : combinez plusieurs index dans une seule requête avec la fonctionnalité multi-search
- Intégration CI/CD : automatisez la réindexation lors de chaque déploiement
Conclusion
Vous avez construit une recherche instantanée complète avec Meilisearch et Next.js. En moins de 50 ms, vos utilisateurs obtiennent des résultats pertinents avec tolérance aux fautes de frappe, filtres à facettes et tri personnalisé.
Meilisearch se distingue par sa simplicité de mise en place et ses performances exceptionnelles. Contrairement aux solutions cloud comme Algolia, il est entièrement open source et auto-hébergeable, ce qui en fait un choix idéal pour les projets soucieux de la souveraineté des données.
Le code source complet de ce tutoriel est disponible et prêt à être adapté à votre cas d'utilisation — que ce soit un blog, une documentation technique ou une plateforme e-commerce.
Discutez de votre projet avec nous
Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.
Trouvons les meilleures solutions pour vos besoins.
Articles connexes

Construire une Progressive Web App (PWA) avec Next.js App Router
Apprenez à transformer votre application Next.js en Progressive Web App installable avec support hors-ligne, notifications push et synchronisation en arrière-plan.

Optimiser Cursor AI pour le développement React et Next.js
Un guide complet pour configurer les paramètres et fonctionnalités de Cursor AI pour une efficacité maximale lors du travail avec React, Next.js et Tailwind CSS.

Construire une API Production-Ready avec tRPC, Prisma et Next.js
Apprenez à créer une API entièrement type-safe et prête pour la production avec tRPC, Prisma ORM et Next.js 15 App Router. Guide complet de la configuration au déploiement avec les meilleures pratiques.