在 Next.JS 全栈应用中使用 Postgres 全文搜索
Source: Dev.to
请提供您希望翻译的完整文本(除代码块和 URL 之外),我将把它翻译成简体中文并保持原有的格式、Markdown 语法以及技术术语不变。
目录
- 假设
- 创建全文搜索索引(Neon)
- 将 Drizzle 连接到 Neon
- 搜索 API 路由(
/api/search) - 安装 Shadcn UI 组件
- 搜索页面 UI(
/search) - 工作原理(摘要)
- 文章搜索组件(Next.js)
假设
- Next.js 全栈应用已 完整。
- 已经连接了 PostgreSQL 数据库(托管在 Neon 上)。
articles表中包含我们想要搜索的数据(title、summary、content等)。
创建全文搜索索引 (Neon)
在 Neon SQL 编辑器(或任何 PostgreSQL 客户端)中运行以下 SQL。
它会创建一个 GIN 索引,以加速全文查询并实现基于相关性的排名。
CREATE INDEX articles_search_idx
ON articles
USING GIN (
to_tsvector(
'english',
title || ' ' || summary || ' ' || content
)
);
您可以将 articles_search_idx 重命名为任意您喜欢的名称。
将 Drizzle 连接到 Neon
如果数据库连接尚未设置,请添加以下文件:
📁 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.
搜索 API 路由 (/api/search)
创建一个 API 端点,接收查询字符串,执行全文搜索,对结果进行排序,并返回前 20 篇文章。
📁 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);
}
这里发生了什么?
- 端点从请求中读取
?q=参数。 - 它根据用户输入构建
plainto_tsquery。 - 在拼接的
title + summary + content向量上执行搜索。 ts_rank为每行打分;结果按该分数降序排列。
安装 Shadcn UI 组件
UI 使用 Shadcn + Tailwind。运行以下命令一次(它们会将组件文件添加到 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
搜索页面 UI (/search)
创建一个客户端页面,让用户输入查询,调用 API 并显示排序后的结果。
📁 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>
);
}
工作原理(摘要)
| 步骤 | 发生了什么 |
|---|---|
| User 输入一个词并点击 Search | 前端调用 GET /api/search?q=term |
| API route 收到查询 | - 将词转换为 plainto_tsquery - 使用 to_tsvector 搜索 articles 表 - 用 ts_rank 为每行打分 |
| Database 返回按相关性排序的前 20 行 | GIN 索引 (articles_search_idx) 使其快速 |
Front‑end 接收 JSON → 渲染 Card 组件列表 | 显示标题、可选摘要和发布日期。点击标题可跳转到文章页面。 |
🎉 完成!
现在,你已经在 Next.js 博客中集成了高性能、按相关性排序的全文搜索,后端由 PostgreSQL、Neon、Drizzle ORM 提供支持,界面使用 Shadcn UI 样式。可以随意调整排序公式、添加分页,或根据需要扩展可搜索字段。祝编码愉快!
文章搜索组件 (Next.js)
概览
-
用户流程
- 用户输入查询 → 前端更新
query。 - 点击 Search 运行
handleSearch(),调用/api/search?q=…。 - 后端执行 PostgreSQL 全文搜索。
- 结果以 JSON 返回,UI 更新。
- 用户输入查询 → 前端更新
-
可选实时搜索 – 在用户输入时即开始搜索(类似 Google)。
前端代码 (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>
);
}
实时搜索(可选)
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);
}
提示: 对请求进行防抖(例如 300 ms)以避免对服务器造成冲击。
扩展搜索后端
- Algolia 或 Typesense 可以替代 PostgreSQL 全文搜索,以获得更快、容错的结果。
- API 路由(
/api/search)只需将查询转发给所选服务并返回其 JSON 响应。
演示
组件(包括可选实时搜索)的在线演示可在以下地址查看:
https://your-demo-url.com/articles/search
感谢 您抽出时间阅读本文! 🙏