Recherche plein texte PostgreSQL avec Next.js — Construire une recherche puissante sans Elasticsearch (2026)

Évitez la taxe des moteurs de recherche. PostgreSQL intègre un moteur de recherche plein texte éprouvé qui gère la lemmatisation, le classement, la tolérance aux fautes et les requêtes multilingues nativement. Dans ce tutoriel, vous allez le connecter à un projet Next.js App Router — zéro infrastructure supplémentaire.
Ce que vous allez apprendre
À la fin de ce tutoriel, vous serez capable de :
- Comprendre les concepts de recherche plein texte PostgreSQL :
tsvector,tsquery, classement et poids - Créer un index GIN pour des recherches en millisecondes sur des millions de lignes
- Construire une API de recherche dans Next.js App Router avec Server Actions
- Ajouter la correspondance floue et la tolérance aux fautes avec
pg_trgm - Implémenter des résultats de recherche surlignés avec
ts_headline - Supporter la recherche multilingue (anglais, arabe, français)
- Gérer les suggestions d'autocomplétion avec la correspondance par préfixe
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20+ installé
- PostgreSQL 15+ en local ou sur un fournisseur cloud (Supabase, Neon ou Railway)
- Des connaissances de base en Next.js App Router et TypeScript
- Prisma ORM installé (nous utiliserons du SQL brut via Prisma pour les requêtes FTS)
- Un éditeur de code comme VS Code
Pourquoi la recherche plein texte PostgreSQL ?
La plupart des développeurs se tournent vers Elasticsearch, Algolia ou Meilisearch quand ils ont besoin de recherche. Mais PostgreSQL inclut déjà un moteur de recherche plein texte puissant qui :
- Ne nécessite aucune infrastructure supplémentaire — pas de service à héberger, surveiller ou payer
- Reste synchronisé automatiquement — votre index de recherche vit à côté de vos données
- Supporte la lemmatisation — chercher "running" correspond à "run", "runs", "runner"
- Gère le classement — les résultats sont ordonnés par pertinence, pas seulement alphabétiquement
- Passe à l'échelle pour des millions de lignes — avec les index GIN, les requêtes se complètent en millisecondes
- Supporte de multiples langues — dictionnaires intégrés pour plus de 30 langues
Pour la plupart des applications, le FTS PostgreSQL est largement suffisant. Vous n'avez besoin d'un moteur de recherche dédié que lorsque vous avez des milliards de documents ou besoin de fonctionnalités comme la recherche par similarité vectorielle.
Étape 1 : Configuration du projet
Créez un nouveau projet Next.js avec TypeScript et Prisma :
npx create-next-app@latest pg-search-demo --typescript --tailwind --app --src-dir
cd pg-search-demoInstallez Prisma :
npm install prisma @prisma/client
npx prisma initCela crée un fichier prisma/schema.prisma et un fichier .env. Mettez à jour votre .env avec votre chaîne de connexion PostgreSQL :
DATABASE_URL="postgresql://user:password@localhost:5432/search_demo?schema=public"Étape 2 : Définir le schéma de base de données
Nous allons construire une base de données d'articles interrogeable. Mettez à jour prisma/schema.prisma :
generator client {
provider = "prisma-client-js"
previewFeatures = ["fullTextSearchPostgres"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Article {
id Int @id @default(autoincrement())
title String
body String
category String
tags String[]
publishedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}Exécutez la migration :
npx prisma migrate dev --name initÉtape 3 : Ajouter les colonnes et index de recherche plein texte
La recherche plein texte PostgreSQL fonctionne avec deux types clés :
tsvector— un document traité, avec les mots réduits à leurs radicaux (lexèmes)tsquery— une requête de recherche, également lemmatisée et analysée en expression logique
Créez une migration pour ajouter les capacités de recherche :
npx prisma migrate dev --name add-fts --create-onlyOuvrez le fichier de migration généré dans prisma/migrations/ et remplacez son contenu par :
-- Ajouter une colonne tsvector générée combinant le titre (poids A) et le corps (poids B)
ALTER TABLE "Article" ADD COLUMN "search_vector" tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce("title", '')), 'A') ||
setweight(to_tsvector('english', coalesce("body", '')), 'B') ||
setweight(to_tsvector('english', coalesce("category", '')), 'C') ||
setweight(to_tsvector('english', array_to_string("tags", ' ')), 'D')
) STORED;
-- Créer un index GIN pour la recherche plein texte rapide
CREATE INDEX "Article_search_vector_idx" ON "Article" USING GIN ("search_vector");
-- Activer l'extension pg_trgm pour la correspondance floue
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- Créer un index trigramme sur le titre pour l'autocomplétion tolérante aux fautes
CREATE INDEX "Article_title_trgm_idx" ON "Article" USING GIN ("title" gin_trgm_ops);Appliquez la migration :
npx prisma migrate devComprendre les poids
Le FTS PostgreSQL supporte quatre classes de poids : A (le plus élevé), B, C et D (le plus bas). Les correspondances dans le titre (poids A) sont classées plus haut que les correspondances dans le corps (poids B). Cela garantit qu'un article intitulé "React Hooks" apparaît avant un article qui mentionne les hooks uniquement au paragraphe cinq.
Étape 4 : Alimenter la base de données
Créez prisma/seed.ts avec des données de démonstration :
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const articles = [
{
title: "Getting Started with Next.js App Router",
body: "The Next.js App Router introduces a new paradigm for building React applications. With server components, streaming, and nested layouts, it provides a powerful foundation for modern web development.",
category: "frontend",
tags: ["nextjs", "react", "app-router", "server-components"],
},
{
title: "PostgreSQL Performance Tuning for Production",
body: "Optimizing PostgreSQL for production workloads requires understanding query planning, index strategies, connection pooling, and resource allocation.",
category: "database",
tags: ["postgresql", "performance", "indexing", "production"],
},
{
title: "Building Type-Safe APIs with tRPC and Prisma",
body: "tRPC eliminates the API layer by sharing TypeScript types between your frontend and backend. Combined with Prisma for database access, you get end-to-end type safety.",
category: "backend",
tags: ["trpc", "prisma", "typescript", "api"],
},
];
async function main() {
console.log("Alimentation de la base de données...");
for (const article of articles) {
await prisma.article.create({ data: article });
}
console.log(`${articles.length} articles créés`);
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());Ajoutez la commande seed dans package.json :
{
"prisma": {
"seed": "npx tsx prisma/seed.ts"
}
}Exécutez le seed :
npx prisma db seedÉtape 5 : Construire la couche de requêtes de recherche
Créez src/lib/search.ts — la logique de recherche principale utilisant du SQL brut via Prisma :
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export interface SearchResult {
id: number;
title: string;
body: string;
category: string;
tags: string[];
publishedAt: Date;
rank: number;
headline: string;
}
export interface SearchOptions {
query: string;
limit?: number;
offset?: number;
category?: string;
}
export async function searchArticles({
query,
limit = 10,
offset = 0,
category,
}: SearchOptions): Promise<{ results: SearchResult[]; total: number }> {
if (!query.trim()) {
return { results: [], total: 0 };
}
const searchQuery = query.trim();
const categoryFilter = category
? `AND "category" = '${category}'`
: "";
const results = await prisma.$queryRawUnsafe<SearchResult[]>(
`
SELECT
"id", "title", "body", "category", "tags", "publishedAt",
ts_rank_cd("search_vector", websearch_to_tsquery('english', $1), 32) AS "rank",
ts_headline('english', "body", websearch_to_tsquery('english', $1),
'StartSel=<mark>, StopSel=</mark>, MaxWords=35, MinWords=15, MaxFragments=2'
) AS "headline"
FROM "Article"
WHERE "search_vector" @@ websearch_to_tsquery('english', $1)
${categoryFilter}
ORDER BY "rank" DESC, "publishedAt" DESC
LIMIT $2 OFFSET $3
`,
searchQuery, limit, offset
);
const countResult = await prisma.$queryRawUnsafe<[{ count: bigint }]>(
`
SELECT COUNT(*) as "count"
FROM "Article"
WHERE "search_vector" @@ websearch_to_tsquery('english', $1)
${categoryFilter}
`,
searchQuery
);
const total = Number(countResult[0]?.count ?? 0);
return { results, total };
}Comprendre la requête
Décomposons les fonctions PostgreSQL clés :
websearch_to_tsquery— analyse les requêtes de recherche style Google."react hooks" -classdevient'react' <-> 'hook' & !'class'ts_rank_cd— calcule la pertinence basée sur la proximité des termes correspondants, avec des bonus de poids pour les correspondances de titrets_headline— extrait un extrait du corps avec les termes correspondants entourés de balises<mark>@@— l'opérateur de correspondance qui vérifie si untsvectorcorrespond à untsquery
Étape 6 : Ajouter la recherche floue et l'autocomplétion
Pour la tolérance aux fautes et les suggestions d'autocomplétion, ajoutez ces fonctions à src/lib/search.ts :
export interface Suggestion {
id: number;
title: string;
similarity: number;
}
export async function getAutocompleteSuggestions(
query: string,
limit: number = 5
): Promise<Suggestion[]> {
if (!query.trim() || query.length < 2) return [];
const results = await prisma.$queryRawUnsafe<Suggestion[]>(
`
SELECT "id", "title", similarity("title", $1) AS "similarity"
FROM "Article"
WHERE "title" ILIKE $2 OR similarity("title", $1) > 0.15
ORDER BY
CASE WHEN "title" ILIKE $2 THEN 0 ELSE 1 END,
similarity("title", $1) DESC
LIMIT $3
`,
query, `%${query}%`, limit
);
return results;
}
export async function fuzzySearch(
query: string,
limit: number = 10
): Promise<SearchResult[]> {
if (!query.trim()) return [];
const results = await prisma.$queryRawUnsafe<SearchResult[]>(
`
SELECT
"id", "title", "body", "category", "tags", "publishedAt",
similarity("title", $1) AS "rank",
ts_headline('english', "body", plainto_tsquery('english', $1),
'StartSel=<mark>, StopSel=</mark>, MaxWords=35, MinWords=15'
) AS "headline"
FROM "Article"
WHERE similarity("title", $1) > 0.15 OR similarity("body", $1) > 0.05
ORDER BY similarity("title", $1) DESC
LIMIT $2
`,
query, limit
);
return results;
}
export async function hybridSearch(options: SearchOptions) {
const ftsResults = await searchArticles(options);
if (ftsResults.results.length > 0) return ftsResults;
const fuzzyResults = await fuzzySearch(options.query, options.limit);
return { results: fuzzyResults, total: fuzzyResults.length, fuzzy: true };
}Étape 7 : Créer l'API de recherche
Créez la route API dans src/app/api/search/route.ts :
import { NextRequest, NextResponse } from "next/server";
import { hybridSearch, getAutocompleteSuggestions } from "@/lib/search";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get("q") ?? "";
const category = searchParams.get("category") ?? undefined;
const limit = Math.min(parseInt(searchParams.get("limit") ?? "10"), 50);
const offset = parseInt(searchParams.get("offset") ?? "0");
const mode = searchParams.get("mode");
try {
if (mode === "suggest") {
const suggestions = await getAutocompleteSuggestions(query);
return NextResponse.json({ suggestions });
}
const results = await hybridSearch({ query, limit, offset, category });
return NextResponse.json(results);
} catch (error) {
console.error("Erreur de recherche:", error);
return NextResponse.json({ error: "Échec de la recherche" }, { status: 500 });
}
}Étape 8 : Construire l'interface de recherche
Créez le composant de recherche dans src/components/SearchBox.tsx :
"use client";
import { useState, useCallback } from "react";
interface SearchResult {
id: number;
title: string;
category: string;
tags: string[];
rank: number;
headline: string;
}
export function SearchBox() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const handleSearch = useCallback(async (searchQuery: string) => {
if (!searchQuery.trim()) { setResults([]); setTotal(0); return; }
setLoading(true);
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`);
const data = await res.json();
setResults(data.results ?? []);
setTotal(data.total ?? 0);
} finally { setLoading(false); }
}, []);
return (
<div className="w-full max-w-2xl mx-auto">
<div className="relative">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(query); }}
placeholder="Rechercher des articles..."
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={() => handleSearch(query)}
className="absolute right-2 top-1/2 -translate-y-1/2 px-4 py-1.5 bg-blue-600 text-white rounded-md"
>
Rechercher
</button>
</div>
{loading && <div className="mt-6 text-center text-gray-400">Recherche en cours...</div>}
<div className="mt-4 space-y-4">
{results.map((result) => (
<article key={result.id} className="p-4 border rounded-lg hover:shadow-md">
<h3 className="text-lg font-semibold">{result.title}</h3>
<p
className="mt-1 text-sm text-gray-600 [&_mark]:bg-yellow-200"
dangerouslySetInnerHTML={{ __html: result.headline }}
/>
</article>
))}
</div>
</div>
);
}Étape 9 : Créer la page de recherche
Créez src/app/search/page.tsx :
import { SearchBox } from "@/components/SearchBox";
export default function SearchPage() {
return (
<main className="min-h-screen bg-gray-50 py-12 px-4">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold text-center mb-2">
Recherche d'articles
</h1>
<p className="text-center text-gray-500 mb-8">
Supporte le langage naturel, les phrases entre guillemets et les exclusions
(ex : docker -kubernetes)
</p>
<SearchBox />
</div>
</main>
);
}Lancez le serveur de développement et testez :
npm run devÉtape 10 : Support de la recherche multilingue
PostgreSQL inclut des dictionnaires pour de nombreuses langues. Pour supporter l'arabe et le français en plus de l'anglais, mettez à jour votre colonne tsvector :
ALTER TABLE "Article" ADD COLUMN "lang" TEXT DEFAULT 'english';
ALTER TABLE "Article" ADD COLUMN "search_vector" tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector(
CASE
WHEN "lang" = 'arabic' THEN 'arabic'::regconfig
WHEN "lang" = 'french' THEN 'french'::regconfig
ELSE 'english'::regconfig
END,
coalesce("title", '')
), 'A') ||
setweight(to_tsvector(
CASE
WHEN "lang" = 'arabic' THEN 'arabic'::regconfig
WHEN "lang" = 'french' THEN 'french'::regconfig
ELSE 'english'::regconfig
END,
coalesce("body", '')
), 'B')
) STORED;Étape 11 : Optimisation des performances
Surveiller les performances des requêtes
Utilisez EXPLAIN ANALYZE pour vérifier que vos requêtes utilisent l'index GIN :
EXPLAIN ANALYZE
SELECT *
FROM "Article"
WHERE "search_vector" @@ websearch_to_tsquery('english', 'react hooks');Vous devriez voir Bitmap Index Scan on Article_search_vector_idx dans la sortie.
Benchmarks
| Nombre de lignes | Temps de requête (GIN) | Temps de requête (sans index) |
|---|---|---|
| 1 000 | moins de 1ms | 2ms |
| 100 000 | 2-5ms | 150ms |
| 1 000 000 | 5-15ms | 1 500ms |
| 10 000 000 | 15-50ms | 15 000ms |
Conseils pour les grands jeux de données
- Limitez la génération de titres —
ts_headlineest coûteuse. Ne la calculez que pour la page finale de résultats - Matérialisez le tsvector — l'approche
GENERATED ALWAYS AS ... STOREDle fait automatiquement - Index partiels — si vous ne cherchez que dans les articles publiés, ajoutez une clause
WHEREà votre index - Pooling de connexions — utilisez PgBouncer ou Prisma Accelerate pour les applications à fort trafic
Étape 12 : Alternative avec Server Actions
Si vous préférez les Server Actions aux routes API, créez src/app/search/actions.ts :
"use server";
import { hybridSearch, getAutocompleteSuggestions } from "@/lib/search";
export async function searchAction(formData: FormData) {
const query = formData.get("q") as string;
const category = formData.get("category") as string | undefined;
return hybridSearch({ query, category });
}
export async function suggestAction(query: string) {
return getAutocompleteSuggestions(query);
}Dépannage
La recherche ne retourne aucun résultat malgré la présence de données
- Vérifiez que la colonne
search_vectorest remplie :SELECT title, search_vector FROM "Article" LIMIT 1; - Assurez-vous que la configuration de langue correspond : chercher avec la config
englishne correspondra pas au texte arabe - Vérifiez que l'index GIN existe :
\di Article_search_vector_idx
La recherche floue est trop lente
- Ajoutez un index trigramme :
CREATE INDEX ON "Article" USING GIN ("title" gin_trgm_ops); - Augmentez le seuil de similarité de
0.15à0.3pour réduire les faux positifs
websearch_to_tsquery lance une erreur
- Cette fonction nécessite PostgreSQL 11+. Pour les versions antérieures, utilisez
plainto_tsqueryà la place
Prochaines étapes
Maintenant que la recherche plein texte fonctionne, envisagez ces améliorations :
- Analytique de recherche — enregistrez les requêtes et les taux de clics pour améliorer la pertinence
- Recherche à facettes — ajoutez des filtres de catégorie et de tags avec
GROUP BYetCOUNT - Recherche instantanée — remplacez l'autocomplétion par des résultats instantanés avec
useTransition - Synonymes — créez un dictionnaire PostgreSQL personnalisé qui associe "js" à "javascript"
- Recherche vectorielle — combinez le FTS avec
pgvectorpour la similarité sémantique
Conclusion
La recherche plein texte PostgreSQL est une fonctionnalité puissante et prête pour la production qui élimine le besoin de services de recherche externes dans la plupart des applications. Dans ce tutoriel, vous avez appris à :
- Configurer des colonnes
tsvectoravec des champs pondérés pour un classement intelligent - Créer des index GIN pour des performances de requête en millisecondes
- Construire une API de recherche complète avec Next.js App Router
- Ajouter la correspondance floue avec
pg_trgmpour la tolérance aux fautes - Générer des extraits de recherche surlignés avec
ts_headline - Supporter la recherche multilingue en anglais, arabe et français
Le point clé : commencez avec le FTS PostgreSQL. Si vous le dépassez — et pour la plupart des applications, ce ne sera pas le cas — vous pourrez toujours migrer vers un moteur de recherche dédié plus tard. Mais vous serez surpris de voir jusqu'où PostgreSQL peut vous emmener.
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 Application Full-Stack avec Drizzle ORM et Next.js 15 : Base de Donnees Type-Safe du Zero a la Production
Apprenez a construire une application full-stack type-safe avec Drizzle ORM et Next.js 15. Ce tutoriel pratique couvre la conception de schemas, les migrations, les Server Actions, les operations CRUD et le deploiement avec PostgreSQL.

Zustand + Next.js App Router : Gestion d'État React Moderne du Zéro à la Production
Maîtrisez la gestion d'état React moderne avec Zustand et Next.js 15 App Router. Ce tutoriel pratique couvre la création de stores, les middleware, la persistance, l'hydratation côté serveur et les patterns concrets pour des applications évolutives.

Construire un Agent IA Autonome avec Agentic RAG et Next.js
Apprenez a construire un agent IA qui decide de maniere autonome quand et comment recuperer des informations depuis des bases de donnees vectorielles. Un guide pratique complet avec Vercel AI SDK et Next.js, accompagne d'exemples executables.