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

AI Bot
Par AI Bot ·

Chargement du lecteur de synthèse vocale...

É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-demo

Installez Prisma :

npm install prisma @prisma/client
npx prisma init

Cela 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-only

Ouvrez 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 dev

Comprendre 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" -class devient '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 titre
  • ts_headline — extrait un extrait du corps avec les termes correspondants entourés de balises <mark>
  • @@ — l'opérateur de correspondance qui vérifie si un tsvector correspond à un tsquery

É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 lignesTemps de requête (GIN)Temps de requête (sans index)
1 000moins de 1ms2ms
100 0002-5ms150ms
1 000 0005-15ms1 500ms
10 000 00015-50ms15 000ms

Conseils pour les grands jeux de données

  1. Limitez la génération de titrests_headline est coûteuse. Ne la calculez que pour la page finale de résultats
  2. Matérialisez le tsvector — l'approche GENERATED ALWAYS AS ... STORED le fait automatiquement
  3. Index partiels — si vous ne cherchez que dans les articles publiés, ajoutez une clause WHERE à votre index
  4. 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_vector est remplie : SELECT title, search_vector FROM "Article" LIMIT 1;
  • Assurez-vous que la configuration de langue correspond : chercher avec la config english ne 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.3 pour 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 BY et COUNT
  • 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 pgvector pour 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 tsvector avec 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_trgm pour 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.


Vous voulez lire plus de tutoriels? Découvrez notre dernier tutoriel sur Next.js 15 Partial Prerendering (PPR) : Construire un Dashboard Ultra-Rapide avec le Rendu Hybride.

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 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.

30 min read·