絕大多數的搜尋功能都需要後端:一個接收查詢字串的 API、一個資料庫或搜尋引擎。
但對個人技術 Blog 來說,文章數量通常不多,這時候有更簡單的做法:把所有文章的 metadata 直接傳給前端,在 Client 端過濾。不需要 API,不需要資料庫,搜尋結果即時出現。
設計思路
Server Component(頁面)
│
│ getAllPostMeta() → PostMeta[]
│ ↓ 傳入 props
└──▶ <SearchBox posts={posts} />
│
│ useState(query)
│ useMemo → filter + slice
└──▶ 即時顯示結果(不 re-fetch)
所有資料在 Server 端取得,傳給 Client Component 之後,後續的過濾完全在瀏覽器端完成。
PostMeta 的結構
搜尋針對的欄位是文章的 title、summary、tags,這些資料已經在 getAllPostMeta() 解析好了:
export type PostMeta = {
slug: string;
title: string;
date: string;
summary: string;
tags: string[];
readingTime: number;
};搜尋不需要文章的完整內文,只用 metadata 就夠了。這讓傳給 Client 的資料量保持很小。
建立 <SearchBox /> 元件
<SearchBox /> 是一個 Client Component,接收所有文章的 metadata,用 useMemo 計算搜尋結果:
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import type { PostMeta } from "@/lib/posts";
export default function SearchBox({ posts }: { posts: PostMeta[] }) {
const [q, setQ] = useState("");
const results = useMemo(() => {
const query = q.trim().toLowerCase();
if (!query) return [];
return posts
.filter((p) => {
// 把 title、summary、tags 合成一個字串搜尋
const hay = [p.title, p.summary, p.tags.join(" ")]
.join(" ")
.toLowerCase();
return hay.includes(query);
})
.slice(0, 8); // 最多顯示 8 筆
}, [q, posts]);
return (
<div>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="搜尋文章…"
/>
{q.trim() && (
<div>
{results.length ? (
results.map((p) => (
<Link key={p.slug} href={`/dashboard/posts/${p.slug}`}>
<div>{p.title}</div>
<div>{p.date}</div>
</Link>
))
) : (
<div>找不到結果</div>
)}
</div>
)}
</div>
);
}為什麼用 useMemo?
useMemo 讓過濾運算只在 q 或 posts 改變時重新執行,輸入每個字元不會每次都重新跑 filter。
在 Server Component 傳入資料
搜尋元件放在 sidebar 裡,由 Server Component 取得資料並傳入:
import { getAllPostMeta } from "@/lib/posts";
import SearchBox from "@/components/SearchBox";
export default async function SidebarNav() {
const posts = getAllPostMeta(); // ← Server 端讀取
return (
<aside>
<SearchBox posts={posts} /> {/* ← 傳給 Client Component */}
{/* ... 其他 sidebar 內容 */}
</aside>
);
}getAllPostMeta() 用 React 的 cache() 包起來,在同一個 request 週期內只讀一次檔案,不會重複 I/O。
搜尋範圍與限制
這個實作的搜尋方式是 substring match,也就是關鍵字必須完整出現在文字中:
| 查詢 | 結果 |
|---|---|
next | ✅ 符合含有「next」的標題 |
nxt | ❌ 不符合(不是 fuzzy search) |
TOC 目錄 | ✅ 符合(多字元空格分隔也 ok) |
這種方式實作最簡單,對技術文章搜尋足夠用。如果之後文章量增加,可以換成 Fuse.js 實作 fuzzy search,API 介面幾乎一樣,只需要替換 filter 的邏輯。
與後端搜尋的比較
| 純前端搜尋(本文做法) | 後端 API 搜尋 | |
|---|---|---|
| 延遲 | 即時,無網路請求 | 有網路往返延遲 |
| 複雜度 | 低,不需要 API | 需要後端 + 資料庫 |
| 搜尋範圍 | 僅 metadata | 可搜尋完整文章內文 |
| 適合規模 | 幾十篇到上百篇 | 文章量大時 |
對個人 Blog 來說,純前端搜尋是最務實的選擇。
小結
這個 Blog 的搜尋功能只用了 useState + useMemo,不需要任何後端:
getAllPostMeta()在 Server 端讀取所有文章 metadata- 透過 props 傳給
<SearchBox />(Client Component) - 使用者輸入關鍵字,
useMemo即時過濾結果
實作簡單,但對個人 Blog 來說完全夠用。
這是「如何打造這個 Blog」系列的最後一篇。五篇下來,從整體架構、MDX 文章系統、Code Highlight、TOC 目錄,到搜尋功能,把這個 Blog 的主要技術細節都走過一遍了。