概要
クエリパラメータでキーワードを受け取る Route Handler をサーバーサイドでフィルタし、クライアントはデバウンス入力で API を叩くパターン。nextjs-search-params-filter(URL クエリ + Server Component)とは異なり、Client Component から fetch する API 型の検索実装。
インストール
# 追加インストールは不要
実装
検索 API(Route Handler)
// app/api/search/route.ts
import { NextResponse } from "next/server";
type Article = { id: number; title: string; category: string };
const ARTICLES: Article[] = [
{ id: 1, title: "Next.js App Router 入門", category: "nextjs" },
{ id: 2, title: "React フックの使い方", category: "react" },
{ id: 3, title: "Zod でバリデーション", category: "validation" },
{ id: 4, title: "Tailwind CSS ガイド", category: "styling" },
{ id: 5, title: "TypeScript 型定義パターン", category: "typescript" },
{ id: 6, title: "Next.js Route Handler", category: "nextjs" },
{ id: 7, title: "Jest でテストを書く", category: "testing" },
];
const PAGE_SIZE = 3;
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const query = searchParams.get("q")?.trim().toLowerCase() ?? "";
const page = Math.max(1, Number(searchParams.get("page") ?? "1"));
const filtered = query
? ARTICLES.filter(
(a) =>
a.title.toLowerCase().includes(query) ||
a.category.toLowerCase().includes(query)
)
: ARTICLES;
const total = filtered.length;
const items = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
return NextResponse.json({ items, total, page, pageSize: PAGE_SIZE });
}
クライアント側の検索 UI
// components/SearchBox.tsx
"use client";
import { useEffect, useRef, useState } from "react";
type Article = { id: number; title: string; category: string };
type SearchResult = { items: Article[]; total: number; page: number };
export function SearchBox() {
const [query, setQuery] = useState("");
const [result, setResult] = useState<SearchResult | null>(null);
const [loading, setLoading] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
// デバウンス: 300ms 入力が止まってから検索
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(async () => {
setLoading(true);
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&page=1`);
const data = await res.json() as SearchResult;
setResult(data);
setLoading(false);
}, 300);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [query]);
return (
<div className="space-y-4">
<div className="relative">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="キーワードで検索..."
className="block w-full rounded border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{loading && (
<span className="absolute right-3 top-2.5 text-xs text-gray-400">検索中...</span>
)}
</div>
{result && (
<div>
<p className="mb-2 text-xs text-gray-500">{result.total} 件</p>
{result.items.length === 0 ? (
<p className="text-sm text-gray-400">該当なし</p>
) : (
<ul className="space-y-2">
{result.items.map((item) => (
<li key={item.id} className="rounded border p-3">
<p className="text-sm font-medium text-gray-800">{item.title}</p>
<span className="mt-1 inline-block rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
{item.category}
</span>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
ページ
// app/page.tsx
import { SearchBox } from "@/components/SearchBox";
export default function Page() {
return (
<main className="mx-auto max-w-lg p-8">
<h1 className="mb-6 text-2xl font-bold">記事検索</h1>
<SearchBox />
</main>
);
}
ポイント
- Route Handler でフィルタするため、データが増えてもクライアントに全件を送らなくて済む。将来 DB 検索に差し替えやすい
encodeURIComponent(query)でクエリを安全にエンコードしてから URL に含める。空文字では全件返す- デバウンスは
useEffect内でsetTimeoutを使う。clearTimeoutを return することで入力ごとのリクエスト多発を防ぐ nextjs-search-params-filterとの使い分け: URL にクエリを保存してブックマーク・シェアを可能にしたい場合はsearchParams版。クライアント内で完結して URL を汚さない場合はこのパターン- ページネーションと組み合わせる場合は
pageパラメータを追加してnextjs-pagination-urlのパターンと組み合わせる