PostgreSQL Full-Text Search with Next.js — Build Powerful Search Without Elasticsearch (2026)

Skip the search-engine tax. PostgreSQL ships with a battle-tested full-text search engine that handles stemming, ranking, typo tolerance, and multilingual queries out of the box. In this tutorial you will wire it into a Next.js App Router project — zero extra infrastructure required.
What You Will Learn
By the end of this tutorial, you will:
- Understand PostgreSQL full-text search concepts:
tsvector,tsquery, ranking, and weights - Create a GIN index for millisecond search on millions of rows
- Build a search API route in Next.js App Router with Server Actions
- Add fuzzy matching and typo tolerance with
pg_trgm - Implement highlighted search results with
ts_headline - Support multilingual search (English, Arabic, French)
- Handle autocomplete suggestions with prefix matching
Prerequisites
Before starting, make sure you have:
- Node.js 20+ installed
- PostgreSQL 15+ running locally or on a cloud provider (Supabase, Neon, or Railway)
- Basic knowledge of Next.js App Router and TypeScript
- Prisma ORM installed (we will use raw SQL through Prisma for FTS queries)
- A code editor like VS Code
Why PostgreSQL Full-Text Search?
Most developers reach for Elasticsearch, Algolia, or Meilisearch when they need search. But PostgreSQL already includes a powerful full-text search engine that:
- Requires zero additional infrastructure — no extra service to host, monitor, or pay for
- Stays in sync automatically — your search index lives alongside your data
- Supports stemming — searching "running" matches "run", "runs", "runner"
- Handles ranking — results are ordered by relevance, not just alphabetical
- Scales to millions of rows — with GIN indexes, queries complete in single-digit milliseconds
- Supports multiple languages — built-in dictionaries for 30+ languages
For most applications, PostgreSQL FTS is more than enough. You only need a dedicated search engine when you have billions of documents or need features like vector similarity search.
Step 1: Project Setup
Create a new Next.js project with TypeScript and Prisma:
npx create-next-app@latest pg-search-demo --typescript --tailwind --app --src-dir
cd pg-search-demoInstall Prisma:
npm install prisma @prisma/client
npx prisma initThis creates a prisma/schema.prisma file and a .env file. Update your .env with your PostgreSQL connection string:
DATABASE_URL="postgresql://user:password@localhost:5432/search_demo?schema=public"Step 2: Define the Database Schema
We will build a searchable articles database. Update 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
}Run the migration:
npx prisma migrate dev --name initStep 3: Add Full-Text Search Columns and Indexes
PostgreSQL full-text search works with two key types:
tsvector— a processed document, with words reduced to their stems (lexemes)tsquery— a search query, also stemmed and parsed into a logical expression
Create a migration to add search capabilities:
npx prisma migrate dev --name add-fts --create-onlyOpen the generated migration file in prisma/migrations/ and replace its content with:
-- Add a generated tsvector column that combines title (weight A) and body (weight 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;
-- Create a GIN index for fast full-text search
CREATE INDEX "Article_search_vector_idx" ON "Article" USING GIN ("search_vector");
-- Enable the pg_trgm extension for fuzzy matching
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- Create a trigram index on title for typo-tolerant autocomplete
CREATE INDEX "Article_title_trgm_idx" ON "Article" USING GIN ("title" gin_trgm_ops);Apply the migration:
npx prisma migrate devUnderstanding Weights
PostgreSQL FTS supports four weight classes: A (highest), B, C, and D (lowest). Matches in the title (weight A) rank higher than matches in the body (weight B). This ensures that an article titled "React Hooks" appears before an article that merely mentions hooks in paragraph five.
Step 4: Seed the Database
Create prisma/seed.ts with sample data:
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. This guide walks you through the core concepts including file-based routing, loading states, error boundaries, and data fetching patterns.",
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. Learn how to analyze slow queries with EXPLAIN ANALYZE, create effective indexes, configure connection pools with PgBouncer, and tune memory settings for your workload.",
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 from your database schema to your React components. This tutorial covers router setup, input validation with Zod, middleware, and optimistic updates.",
category: "backend",
tags: ["trpc", "prisma", "typescript", "api"],
},
{
title: "Docker Compose for Full-Stack Development",
body: "Docker Compose simplifies multi-container development environments. Set up PostgreSQL, Redis, your Next.js app, and background workers with a single docker-compose.yml file. This guide covers service definitions, volume mounts, networking, health checks, and production-ready configurations.",
category: "devops",
tags: ["docker", "docker-compose", "containers", "development"],
},
{
title: "React Server Components Deep Dive",
body: "React Server Components fundamentally change how we think about React applications. They run on the server, have zero client-side JavaScript bundle impact, and can directly access databases and file systems. Learn the mental model, when to use server vs client components, and common patterns for data fetching and mutations.",
category: "frontend",
tags: ["react", "server-components", "nextjs", "rendering"],
},
{
title: "Implementing Authentication with Better Auth",
body: "Better Auth is a modern authentication library for TypeScript applications. It supports email/password, OAuth providers, magic links, and two-factor authentication out of the box. This tutorial covers installation, configuration with Next.js, protected routes, session management, and role-based access control.",
category: "security",
tags: ["authentication", "better-auth", "security", "nextjs"],
},
{
title: "Tailwind CSS v4: What Changed and How to Migrate",
body: "Tailwind CSS v4 brings a new engine built on Rust, CSS-first configuration, automatic content detection, and native cascade layers. The migration from v3 requires updating your configuration approach from JavaScript to CSS. This guide covers every breaking change and provides a step-by-step migration path.",
category: "frontend",
tags: ["tailwind", "css", "frontend", "migration"],
},
{
title: "Redis Caching Strategies for Next.js Applications",
body: "Redis provides lightning-fast in-memory caching that dramatically reduces database load and API response times. Learn cache-aside, write-through, and write-behind patterns. Implement rate limiting, session storage, and real-time leaderboards using Upstash Redis with Next.js server actions and API routes.",
category: "backend",
tags: ["redis", "caching", "nextjs", "performance"],
},
{
title: "Testing React Applications with Vitest",
body: "Vitest is the fastest test runner for modern JavaScript projects. It provides native ESM support, TypeScript out of the box, and a Jest-compatible API. Learn how to test React components with React Testing Library, mock API calls, test custom hooks, and set up continuous integration with GitHub Actions.",
category: "testing",
tags: ["vitest", "testing", "react", "ci"],
},
{
title: "GraphQL API Design Best Practices",
body: "Designing a GraphQL API requires different thinking than REST. Learn about schema design principles, pagination with cursors, error handling, authentication patterns, N+1 query prevention with DataLoader, and performance monitoring. This guide covers production-ready patterns used by companies like GitHub and Shopify.",
category: "backend",
tags: ["graphql", "api", "schema-design", "backend"],
},
];
async function main() {
console.log("Seeding database...");
for (const article of articles) {
await prisma.article.create({ data: article });
}
console.log(`Seeded ${articles.length} articles`);
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());Add the seed command to package.json:
{
"prisma": {
"seed": "npx tsx prisma/seed.ts"
}
}Run the seed:
npx prisma db seedStep 5: Build the Search Query Layer
Create src/lib/search.ts — the core search logic using raw SQL through 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 };
}
// Convert the user query into a tsquery
// plainto_tsquery handles natural language input
// websearch_to_tsquery handles Google-style queries (quotes, minus, OR)
const searchQuery = query.trim();
const categoryFilter = category
? `AND "category" = '${category}'`
: "";
// Main search query with ranking and headline generation
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
);
// Get total count for pagination
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 };
}Understanding the Query
Let us break down the key PostgreSQL functions:
websearch_to_tsquery— parses Google-style search queries."react hooks" -classbecomes'react' <-> 'hook' & !'class'ts_rank_cd— calculates relevance based on how close matching terms are, with weight bonuses for title matches (weight A)ts_headline— extracts a snippet from the body with matching terms wrapped in<mark>tags@@— the match operator that checks if atsvectormatches atsquery
Step 6: Add Fuzzy Search and Autocomplete
For typo tolerance and autocomplete suggestions, add these functions to 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 [];
}
// Combine prefix matching (for fast-as-you-type) with trigram similarity (for typos)
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 [];
}
// Fall back to trigram similarity when FTS returns no results
// This handles typos like "reakt" matching "React"
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, MaxFragments=2'
) 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;
}Combining FTS and Fuzzy Search
The best strategy is to try full-text search first, then fall back to fuzzy search when FTS returns zero results:
export async function hybridSearch(options: SearchOptions) {
// Try exact full-text search first
const ftsResults = await searchArticles(options);
if (ftsResults.results.length > 0) {
return ftsResults;
}
// Fall back to fuzzy trigram search for typos
const fuzzyResults = await fuzzySearch(options.query, options.limit);
return {
results: fuzzyResults,
total: fuzzyResults.length,
fuzzy: true,
};
}Step 7: Create the Search API
Create the API route at 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"); // "suggest" for autocomplete
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("Search error:", error);
return NextResponse.json(
{ error: "Search failed" },
{ status: 500 }
);
}
}Step 8: Build the Search UI
Create the search component at src/components/SearchBox.tsx:
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
interface SearchResult {
id: number;
title: string;
category: string;
tags: string[];
rank: number;
headline: string;
}
interface Suggestion {
id: number;
title: string;
similarity: number;
}
export function SearchBox() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [isFuzzy, setIsFuzzy] = useState(false);
const [showSuggestions, setShowSuggestions] = useState(false);
const debounceRef = useRef<NodeJS.Timeout>();
const inputRef = useRef<HTMLInputElement>(null);
// Debounced autocomplete
useEffect(() => {
if (query.length < 2) {
setSuggestions([]);
return;
}
debounceRef.current = setTimeout(async () => {
const res = await fetch(
`/api/search?q=${encodeURIComponent(query)}&mode=suggest`
);
const data = await res.json();
setSuggestions(data.suggestions ?? []);
setShowSuggestions(true);
}, 150);
return () => clearTimeout(debounceRef.current);
}, [query]);
const handleSearch = useCallback(async (searchQuery: string) => {
if (!searchQuery.trim()) {
setResults([]);
setTotal(0);
return;
}
setLoading(true);
setShowSuggestions(false);
try {
const res = await fetch(
`/api/search?q=${encodeURIComponent(searchQuery)}`
);
const data = await res.json();
setResults(data.results ?? []);
setTotal(data.total ?? 0);
setIsFuzzy(data.fuzzy ?? false);
} catch {
console.error("Search failed");
} finally {
setLoading(false);
}
}, []);
return (
<div className="w-full max-w-2xl mx-auto">
{/* Search Input */}
<div className="relative">
<input
ref={inputRef}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSearch(query);
}}
placeholder="Search articles... (try: react hooks, "server components", docker -kubernetes)"
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-lg"
/>
<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 hover:bg-blue-700"
>
Search
</button>
{/* Autocomplete Dropdown */}
{showSuggestions && suggestions.length > 0 && (
<ul className="absolute z-10 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg">
{suggestions.map((s) => (
<li
key={s.id}
className="px-4 py-2 hover:bg-gray-50 cursor-pointer"
onClick={() => {
setQuery(s.title);
handleSearch(s.title);
}}
>
{s.title}
</li>
))}
</ul>
)}
</div>
{/* Results Info */}
{total > 0 && (
<p className="mt-4 text-sm text-gray-500">
{isFuzzy && "Showing fuzzy matches. "}
Found {total} result{total !== 1 ? "s" : ""}
</p>
)}
{/* Loading State */}
{loading && (
<div className="mt-6 text-center text-gray-400">Searching...</div>
)}
{/* Results */}
<div className="mt-4 space-y-4">
{results.map((result) => (
<article
key={result.id}
className="p-4 border border-gray-200 rounded-lg hover:shadow-md transition-shadow"
>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs px-2 py-0.5 bg-blue-100 text-blue-700 rounded-full">
{result.category}
</span>
<span className="text-xs text-gray-400">
Relevance: {(result.rank * 100).toFixed(0)}%
</span>
</div>
<h3 className="text-lg font-semibold text-gray-900">
{result.title}
</h3>
<p
className="mt-1 text-sm text-gray-600 [&_mark]:bg-yellow-200 [&_mark]:px-0.5 [&_mark]:rounded"
dangerouslySetInnerHTML={{ __html: result.headline }}
/>
<div className="mt-2 flex gap-1">
{result.tags.map((tag) => (
<span
key={tag}
className="text-xs px-1.5 py-0.5 bg-gray-100 text-gray-500 rounded"
>
{tag}
</span>
))}
</div>
</article>
))}
</div>
{/* No Results */}
{!loading && query && results.length === 0 && total === 0 && (
<p className="mt-6 text-center text-gray-400">
No results found for "{query}"
</p>
)}
</div>
);
}Step 9: Create the Search Page
Create src/app/search/page.tsx:
import { SearchBox } from "@/components/SearchBox";
export const metadata = {
title: "Search Articles",
description: "Search through our articles using full-text search",
};
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">
Search Articles
</h1>
<p className="text-center text-gray-500 mb-8">
Supports natural language, quoted phrases, and exclusions (e.g.,
docker -kubernetes)
</p>
<SearchBox />
</div>
</main>
);
}Start the dev server and test:
npm run devNavigate to http://localhost:3000/search and try queries like:
react— finds all articles mentioning React"server components"— exact phrase matchdocker -kubernetes— Docker articles that do not mention Kubernetesreakt— fuzzy match catches the typo and still finds React articles
Step 10: Add Multilingual Search Support
PostgreSQL includes dictionaries for many languages. To support Arabic and French alongside English, update your generated column migration:
-- Drop and recreate the search_vector column with multilingual support
ALTER TABLE "Article" DROP COLUMN IF EXISTS "search_vector";
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;
CREATE INDEX "Article_search_vector_idx" ON "Article" USING GIN ("search_vector");Then update your search function to accept a language parameter:
export async function searchArticles({
query,
limit = 10,
offset = 0,
category,
lang = "english",
}: SearchOptions & { lang?: string }) {
const config = lang === "arabic" ? "arabic"
: lang === "french" ? "french"
: "english";
const results = await prisma.$queryRawUnsafe<SearchResult[]>(
`
SELECT
"id", "title", "body", "category", "tags", "publishedAt",
ts_rank_cd("search_vector", websearch_to_tsquery($4, $1), 32) AS "rank",
ts_headline($4, "body", websearch_to_tsquery($4, $1),
'StartSel=<mark>, StopSel=</mark>, MaxWords=35, MinWords=15'
) AS "headline"
FROM "Article"
WHERE "search_vector" @@ websearch_to_tsquery($4, $1)
ORDER BY "rank" DESC
LIMIT $2 OFFSET $3
`,
query, limit, offset, config
);
return results;
}Step 11: Performance Optimization
Monitoring Query Performance
Use EXPLAIN ANALYZE to verify your queries use the GIN index:
EXPLAIN ANALYZE
SELECT *
FROM "Article"
WHERE "search_vector" @@ websearch_to_tsquery('english', 'react hooks');You should see Bitmap Index Scan on Article_search_vector_idx in the output. If you see Seq Scan instead, your index is not being used.
Benchmarks
Here are typical performance numbers for PostgreSQL FTS with a GIN index:
| Row Count | Query Time (GIN) | Query Time (No Index) |
|---|---|---|
| 1,000 | under 1ms | 2ms |
| 100,000 | 2-5ms | 150ms |
| 1,000,000 | 5-15ms | 1,500ms |
| 10,000,000 | 15-50ms | 15,000ms |
Tips for Large Datasets
- Limit headline generation —
ts_headlineis expensive. Only compute it for the final page of results, not during counting - Materialize the tsvector — the
GENERATED ALWAYS AS ... STOREDapproach we used does this automatically - Partial indexes — if you only search published articles, add a
WHERE "publishedAt" IS NOT NULLclause to your index - Connection pooling — use PgBouncer or Prisma Accelerate for high-traffic applications
-- Partial index example: only index published articles
CREATE INDEX "Article_search_published_idx"
ON "Article" USING GIN ("search_vector")
WHERE "publishedAt" IS NOT NULL;Step 12: Server Actions Alternative
If you prefer Server Actions over API routes, create 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);
}Then use them directly in your component:
"use client";
import { searchAction } from "./actions";
import { useActionState } from "react";
export function ServerActionSearch() {
const [results, action, isPending] = useActionState(searchAction, null);
return (
<form action={action}>
<input name="q" type="search" placeholder="Search..." />
<button type="submit" disabled={isPending}>
{isPending ? "Searching..." : "Search"}
</button>
{results?.results.map((r) => (
<div key={r.id}>{r.title}</div>
))}
</form>
);
}Testing Your Implementation
Manual Testing Checklist
- Basic search: Type "react" and verify relevant results appear
- Phrase search: Search
"server components"with quotes — only exact phrase matches should appear - Exclusion: Search
docker -kubernetes— Docker results without Kubernetes mentions - Ranking: Search "postgresql" — the article with "PostgreSQL" in the title should rank highest
- Fuzzy matching: Search "reakt" — should still find React articles via trigram similarity
- Autocomplete: Type "doc" slowly — suggestions should appear after 2 characters
- Empty results: Search "xyznonexistent" — should show "No results found"
Automated Test
import { searchArticles, fuzzySearch } from "@/lib/search";
describe("PostgreSQL Full-Text Search", () => {
it("finds articles by keyword", async () => {
const { results } = await searchArticles({ query: "react" });
expect(results.length).toBeGreaterThan(0);
expect(results[0].title.toLowerCase()).toContain("react");
});
it("handles phrase search", async () => {
const { results } = await searchArticles({
query: '"server components"',
});
expect(results.every(r =>
r.body.toLowerCase().includes("server components")
)).toBe(true);
});
it("fuzzy matches typos", async () => {
const results = await fuzzySearch("reakt");
expect(results.length).toBeGreaterThan(0);
});
it("returns empty for nonsense queries", async () => {
const { results } = await searchArticles({ query: "xyzxyzxyz" });
expect(results).toHaveLength(0);
});
});Troubleshooting
Search returns no results even though data exists
- Verify the
search_vectorcolumn is populated:SELECT title, search_vector FROM "Article" LIMIT 1; - Check the language configuration matches: searching with
englishconfig will not match Arabic text - Ensure the GIN index exists:
\di Article_search_vector_idx
Fuzzy search is too slow
- Add a trigram index:
CREATE INDEX ON "Article" USING GIN ("title" gin_trgm_ops); - Increase the similarity threshold from
0.15to0.3to reduce false positives
websearch_to_tsquery throws an error
- This function requires PostgreSQL 11+. For older versions, use
plainto_tsqueryinstead - Special characters in the query may cause parsing errors — sanitize input or wrap in a try-catch
Highlights show raw HTML
- The
dangerouslySetInnerHTMLapproach is intentional forts_headlineoutput - Ensure you sanitize the
<mark>tags if accepting user-generated content in articles
Next Steps
Now that you have full-text search working, consider these enhancements:
- Search analytics — log search queries and click-through rates to improve relevance
- Faceted search — add category and tag filters using
GROUP BYandCOUNT - Search-as-you-type — replace the autocomplete with instant results using
useTransition - Synonyms — create a custom PostgreSQL dictionary that maps "js" to "javascript"
- Vector search — combine FTS with
pgvectorfor semantic similarity (see our AI Vector Search tutorial)
Conclusion
PostgreSQL full-text search is a powerful, production-ready feature that eliminates the need for external search services in most applications. In this tutorial, you learned how to:
- Set up
tsvectorcolumns with weighted fields for intelligent ranking - Create GIN indexes for millisecond query performance
- Build a complete search API with Next.js App Router
- Add fuzzy matching with
pg_trgmfor typo tolerance - Generate highlighted search snippets with
ts_headline - Support multilingual search across English, Arabic, and French
The key takeaway: start with PostgreSQL FTS. If you outgrow it — and for most applications, you will not — you can always migrate to a dedicated search engine later. But you will be surprised how far PostgreSQL can take you.
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

Build a Full-Stack App with Drizzle ORM and Next.js 15: Type-Safe Database from Zero to Production
Learn how to build a type-safe full-stack application using Drizzle ORM with Next.js 15. This hands-on tutorial covers schema design, migrations, Server Actions, CRUD operations, and deployment with PostgreSQL.

Zustand + Next.js App Router: Modern React State Management from Zero to Production
Master modern React state management with Zustand and Next.js 15 App Router. This hands-on tutorial covers store creation, middleware, persistence, server-side hydration, and real-world patterns for scalable applications.

Building an Autonomous AI Agent with Agentic RAG and Next.js
Learn how to build an AI agent that autonomously decides when and how to retrieve information from vector databases. A comprehensive hands-on guide using Vercel AI SDK and Next.js with executable examples.