レビュー済み·難易度: 中級·更新: 2026-04-17

Next.js の searchParams で URL 同期フィルタを実装する

searchParams を読み取り、URL クエリパラメータに基づいてサーバー側でリストをフィルタリングする実装例。

nextjssearch-filterrouting

対応バージョン

nextjs 15

前提環境

Next.js App Router のサーバーコンポーネントと useRouter / useSearchParams の基本を理解していること

概要

Next.js App Router では、URL のクエリパラメータ(?category=api)をサーバーコンポーネントの searchParams で受け取り、サーバー側でフィルタリングできる。 クライアントコンポーネントから useRouter でURLを更新すると、サーバーコンポーネントが再実行されてフィルタ結果が反映される。

インストール

# 追加インストールは不要

実装例

// src/app/samples/page.tsx(サーバーコンポーネント)
type Post = { id: number; title: string; category: string };

type Props = {
  searchParams: Promise<{ category?: string; q?: string }>;
};

async function getPosts(): Promise<Post[]> {
  // 実際の実装では DB や API から取得する
  return [
    { id: 1, title: "useQuery でデータフェッチ", category: "api" },
    { id: 2, title: "useReducer で状態管理", category: "state-management" },
    { id: 3, title: "Zod でバリデーション", category: "validation" },
    { id: 4, title: "useMutation で POST", category: "api" },
  ];
}

export default async function SamplesPage({ searchParams }: Props) {
  const { category, q } = await searchParams;
  const posts = await getPosts();

  // サーバー側でフィルタリング
  const filtered = posts.filter((post) => {
    const matchCategory = !category || post.category === category;
    const matchQuery = !q || post.title.toLowerCase().includes(q.toLowerCase());
    return matchCategory && matchQuery;
  });

  return (
    <div className="p-6 space-y-4">
      {/* クライアントコンポーネントのフィルタ UI */}
      <FilterBar currentCategory={category} currentQuery={q} />

      {/* フィルタ結果 */}
      <p className="text-sm text-gray-500">{filtered.length} 件</p>
      <ul className="space-y-2">
        {filtered.map((post) => (
          <li key={post.id} className="rounded border px-4 py-3 text-sm">
            <p className="font-medium">{post.title}</p>
            <p className="text-xs text-gray-400">{post.category}</p>
          </li>
        ))}
      </ul>
    </div>
  );
}
// src/app/samples/FilterBar.tsx(クライアントコンポーネント)
"use client";

import { useRouter, usePathname } from "next/navigation";
import { useCallback } from "react";

type Props = { currentCategory?: string; currentQuery?: string };

const CATEGORIES = ["api", "state-management", "validation", "form"];

export function FilterBar({ currentCategory, currentQuery }: Props) {
  const router = useRouter();
  const pathname = usePathname();

  const updateParams = useCallback(
    (key: string, value: string) => {
      const params = new URLSearchParams(window.location.search);
      if (value) {
        params.set(key, value);
      } else {
        params.delete(key);
      }
      router.push(`${pathname}?${params.toString()}`);
    },
    [router, pathname]
  );

  return (
    <div className="flex flex-wrap gap-2">
      {/* カテゴリフィルタ */}
      <div className="flex gap-1">
        <button
          onClick={() => updateParams("category", "")}
          className={`rounded border px-3 py-1 text-xs ${!currentCategory ? "bg-gray-900 text-white" : ""}`}
        >
          すべて
        </button>
        {CATEGORIES.map((cat) => (
          <button
            key={cat}
            onClick={() => updateParams("category", cat)}
            className={`rounded border px-3 py-1 text-xs ${currentCategory === cat ? "bg-gray-900 text-white" : ""}`}
          >
            {cat}
          </button>
        ))}
      </div>

      {/* キーワード検索 */}
      <input
        defaultValue={currentQuery}
        onChange={(e) => updateParams("q", e.target.value)}
        placeholder="キーワード検索"
        className="rounded border px-3 py-1 text-sm"
      />
    </div>
  );
}

ポイント

  • サーバーコンポーネントは searchParams props でクエリを受け取る(Next.js 15 では await が必要)
  • URLSearchParams を使うと複数のクエリパラメータを維持しながら1つだけ更新できる
  • router.push で URL を変更するとサーバーコンポーネントが再レンダリングされ、フィルタ結果が自動更新される
  • URL にフィルタ状態が入るため、リロードや共有リンクでフィルタ状態が保持される
  • フィルタ処理はサーバー側で行うため、大量データでも DB クエリで効率的に絞り込める

注意点

サーバーコンポーネントでは props の searchParams でクエリを受け取る。クライアント側でフィルタ変更時に useRouter の push / replace で URL を更新する。ページネーションと組み合わせる場合はページ番号もクエリに含める。

関連サンプル