レビュー待ち·難易度: 中級·更新: 2026-04-18

Next.js Route Handler でサーバーサイドキーワード検索を実装する

クエリパラメータで受け取ったキーワードを Route Handler でサーバーサイドフィルタし、ページネーション付きで返す例。クライアントのデバウンス入力と合わせた構成も示す。

nextjssearch-filterapi

対応バージョン

nextjs 15react 19

前提環境

Next.js Route Handler の基本と URL クエリパラメータの扱いを理解していること

概要

クエリパラメータでキーワードを受け取る 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 のパターンと組み合わせる

注意点

nextjs-search-params-filter は URL クエリ + Server Component での絞り込み(ページ遷移型)。react-debounce-search はクライアント側のデバウンス検索。react-multi-filter はチェックボックスフィルタ。これは Route Handler にキーワードを送ってサーバーサイドでフィルタする API 型検索。

関連サンプル