Using Postgres Full-Text Search on a Next.JS Fullstack App
Source: Dev.to
Table of Contents
- Assumptions
- Create a Full‑Text Search Index (Neon)
- Connect Drizzle to Neon
- Search API Route (
/api/search) - Install Shadcn UI components
- Search Page UI (
/search) - How It Works (summary)
- Article Search Component (Next.js)
Assumptions
- The Next.js full‑stack app is complete.
- A PostgreSQL database (hosted on Neon) is already attached.
- The
articlestable contains data (title,summary,content, etc.) that we want to search.
Create a Full‑Text Search Index (Neon)
Run the following SQL in the Neon SQL Editor (or any PostgreSQL client).
It creates a GIN index that speeds up full‑text queries and enables ranking by relevance.
CREATE INDEX articles_search_idx
ON articles
USING GIN (
to_tsvector(
'english',
title || ' ' || summary || ' ' || content
)
);
You can rename articles_search_idx to anything you like.
Connect Drizzle to Neon
If the database connection isn’t already set up, add the following file:
📁 db/index.ts
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
import * as schema from "./schema";
const sql = neon(process.env.DATABASE_URL!); // The rest of the app should already import `db` from this module.
Search API Route (/api/search)
Create the API endpoint that receives a query string, runs a full‑text search, ranks results, and returns the top 20 articles.
📁 app/api/search/route.ts
import { NextResponse } from "next/server";
import { db } from "@/db";
import { sql } from "drizzle-orm";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const query = searchParams.get("q");
if (!query) {
return NextResponse.json([]);
}
const results = await db.execute(sql`
SELECT
id,
title,
slug,
summary,
image_url,
created_at,
ts_rank(
to_tsvector('english', title || ' ' || COALESCE(summary, '') || ' ' || content),
plainto_tsquery('english', ${query})
) AS rank
FROM articles
WHERE published = true
AND to_tsvector(
'english',
title || ' ' || COALESCE(summary, '') || ' ' || content
) @@ plainto_tsquery('english', ${query})
ORDER BY rank DESC
LIMIT 20
`);
return NextResponse.json(results.rows);
}
What happens here?
- The endpoint reads
?q=from the request. - It builds a
plainto_tsqueryfrom the user input. - It searches the concatenated
title + summary + contentvector. ts_rankscores each row; results are ordered by that score.
Install Shadcn UI Components
The UI uses Shadcn + Tailwind. Run the commands once (they add the component files to src/components/ui).
npx shadcn@latest init
npx shadcn@latest add input
npx shadcn@latest add button
npx shadcn@latest add card
npx shadcn@latest add separator
Search Page UI (/search)
Create a client‑side page that lets users type a query, calls the API, and displays the ranked results.
📁 app/search/page.tsx
"use client";
import { useState } from "react";
import Link from "next/link";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
type Article = {
id: number;
title: string;
slug: string;
summary: string | null;
created_at: string;
};
export default function ArticleSearchPage() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
async function handleSearch() {
if (!query.trim()) return;
setLoading(true);
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await res.json();
setResults(data);
setLoading(false);
}
return (
<div className="max-w-2xl mx-auto p-4">
{/* Page Header */}
<h1 className="text-2xl font-bold">Search Articles</h1>
<p className="text-muted-foreground mb-4">
Find articles by title, summary, or content
</p>
{/* Search Input */}
<div className="flex gap-2 mb-4">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search articles..."
className="flex-1"
/>
<Button onClick={handleSearch} disabled={loading}>
{loading ? "Searching…" : "Search"}
</Button>
</div>
{/* Results */}
<div className="space-y-4">
{results.length === 0 && !loading && query && (
<p>No articles found.</p>
)}
{results.map((article) => (
<Card key={article.id}>
<CardContent className="pt-4">
<Link href={`/articles/${article.slug}`} className="text-lg font-medium hover:underline">
{article.title}
</Link>
{article.summary && (
<p className="text-sm text-muted-foreground mt-1">
{article.summary}
</p>
)}
<p className="text-xs text-muted-foreground mt-2">
{new Date(article.created_at).toDateString()}
</p>
</CardContent>
</Card>
))}
</div>
</div>
);
}
How It Works (summary)
| Step | What Happens |
|---|---|
| User types a term and clicks Search | Front‑end calls GET /api/search?q=term |
| API route receives the query | - Converts the term to a plainto_tsquery - Searches the articles table using to_tsvector - Scores each row with ts_rank |
| Database returns the top 20 rows ordered by relevance | The GIN index (articles_search_idx) makes this fast |
Front‑end receives JSON → renders a list of Card components | Shows title, optional summary, and publish date. Clicking a title navigates to the article page. |
🎉 Done!
You now have a performant, relevance‑sorted full‑text search integrated into your Next.js blog, powered by PostgreSQL, Neon, Drizzle ORM, and styled with Shadcn UI. Feel free to tweak the ranking formula, add pagination, or expand the searchable fields as needed. Happy coding!
Article Search Component (Next.js)
Overview
-
User flow
- User types a query → the frontend updates
query. - Clicking Search runs
handleSearch(), which calls/api/search?q=…. - The backend performs a PostgreSQL full‑text search.
- Results are returned as JSON and the UI updates.
- User types a query → the frontend updates
-
Optional live‑search – start searching as the user types (similar to Google).
Front‑end Code (app/articles/search/page.tsx)
"use client";
import { useState } from "react";
import Link from "next/link";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
type Article = {
id: number;
title: string;
slug: string;
summary: string | null;
created_at: string;
};
export default function ArticleSearchPage() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
async function handleSearch() {
if (!query.trim()) return;
setLoading(true);
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await res.json();
setResults(data);
setLoading(false);
}
return (
<div className="max-w-2xl mx-auto p-4">
{/* Page Header */}
<h1 className="text-2xl font-bold">Search Articles</h1>
<p className="text-muted-foreground mb-4">
Find articles by title, summary, or content
</p>
{/* Search Input */}
<div className="flex gap-2 mb-4">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search articles..."
className="flex-1"
/>
<Button onClick={handleSearch} disabled={loading}>
{loading ? "Searching…" : "Search"}
</Button>
</div>
{/* Results */}
<div className="space-y-4">
{results.length === 0 && !loading && query && (
<p>No articles found.</p>
)}
{results.map((article) => (
<Card key={article.id}>
<CardContent className="pt-4">
<Link href={`/articles/${article.slug}`} className="text-lg font-medium hover:underline">
{article.title}
</Link>
{article.summary && (
<p className="text-sm text-muted-foreground mt-1">
{article.summary}
</p>
)}
<p className="text-xs text-muted-foreground mt-2">
{new Date(article.created_at).toDateString()}
</p>
</CardContent>
</Card>
))}
</div>
</div>
);
}
Live‑Search (Optional)
Replace the static handler with an on‑change that queries the API on every keystroke:
{
const value = e.target.value;
setQuery(value);
if (!value.trim()) {
setResults([]);
return;
}
setLoading(true);
const res = await fetch(
`/api/search?q=${encodeURIComponent(value)}`
);
const data = await res.json();
setResults(data);
setLoading(false);
}
Tip: Debounce the request (e.g., 300 ms) to avoid flooding the server.
Extending the Search Backend
- Algolia or Typesense can replace the PostgreSQL full‑text search for faster, typo‑tolerant results.
- The API route (
/api/search) would simply forward the query to the chosen service and return its JSON response.
Demo
A live demo of the component (including optional live‑search) can be viewed at:
https://your-demo-url.com/articles/search
Thank you for taking the time to read through this article! 🙏