在 Next.JS 全栈应用中使用 Postgres 全文搜索

发布: (2026年1月3日 GMT+8 04:43)
8 min read
原文: Dev.to

Source: Dev.to

请提供您希望翻译的完整文本(除代码块和 URL 之外),我将把它翻译成简体中文并保持原有的格式、Markdown 语法以及技术术语不变。

目录

  1. 假设
  2. 创建全文搜索索引(Neon)
  3. 将 Drizzle 连接到 Neon
  4. 搜索 API 路由(/api/search
  5. 安装 Shadcn UI 组件
  6. 搜索页面 UI(/search
  7. 工作原理(摘要)
  8. 文章搜索组件(Next.js)

假设

  • Next.js 全栈应用已 完整
  • 已经连接了 PostgreSQL 数据库(托管在 Neon 上)。
  • articles 表中包含我们想要搜索的数据(titlesummarycontent 等)。

创建全文搜索索引 (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);
}

这里发生了什么?

  1. 端点从请求中读取 ?q= 参数。
  2. 它根据用户输入构建 plainto_tsquery
  3. 在拼接的 title + summary + content 向量上执行搜索。
  4. 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

创建一个客户端页面,让用户输入查询,调用 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 博客中集成了高性能、按相关性排序的全文搜索,后端由 PostgreSQLNeonDrizzle ORM 提供支持,界面使用 Shadcn UI 样式。可以随意调整排序公式、添加分页,或根据需要扩展可搜索字段。祝编码愉快!

文章搜索组件 (Next.js)

概览

  • 用户流程

    1. 用户输入查询 → 前端更新 query
    2. 点击 Search 运行 handleSearch(),调用 /api/search?q=…
    3. 后端执行 PostgreSQL 全文搜索。
    4. 结果以 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)以避免对服务器造成冲击。

扩展搜索后端

  • AlgoliaTypesense 可以替代 PostgreSQL 全文搜索,以获得更快、容错的结果。
  • API 路由(/api/search)只需将查询转发给所选服务并返回其 JSON 响应。

演示

组件(包括可选实时搜索)的在线演示可在以下地址查看:
https://your-demo-url.com/articles/search

感谢 您抽出时间阅读本文! 🙏

Back to Blog

相关文章

阅读更多 »

创意开发者文集:2026作品集

介绍 本提交作品是由 Google AI 主办的“新年,新你”作品集挑战赛。大多数作品集感觉像是一份配料清单;对于 2026…