Playbook

検索付き一覧画面を作る

5つのフェーズで検索付き一覧画面を段階的に構築する実装記録。最小表示から始め、フィルタ・URL設計・E2E・ドキュメント化まで一通り仕上げる。Next.js App Router のサーバーコンポーネント中心、フィルタ状態を URL で管理する設計。

next.jssearchfilterpaginationplaywright

このプレイブックで作るもの

検索付き一覧画面で実現する体験は次のとおりです。

  • 一覧を開く
  • キーワードで検索する
  • フィルタで絞り込む
  • 並び替える
  • ページを移動する
  • カードをクリックして詳細へ進む
  • 詳細から元の絞り込み条件へ戻る
  • 0 件時に次の操作が分かる
  • Playwright E2E で主要フローを守る

完成例

完成例は /samples で確認できます。検索、フィルタ、並び替え、詳細復帰、0 件時の導線まで含めて実装済みです。

完成例を見る

5 つのフェーズに分けて、この画面を段階的に構築します。フェーズごとに「画面として成立している」状態を保ちながら機能を積み上げます。

フェーズゴール
Phase 1一覧と詳細が表示できる
Phase 2検索・フィルタ・ソート・ページネーションで絞り込める
Phase 3URL で状態を管理し、詳細から一覧へ戻れる
Phase 4E2E テストで主要フローを保護する
Phase 5Playbook と Build で見せ方を整理する

実装の最終形はこのファイル構成になります。

ファイル役割
src/lib/url/sample-query.tsURL クエリのパース・ビルド(純粋関数)
src/app/samples/page.tsx一覧ページ(サーバーコンポーネント)
src/app/samples/[slug]/page.tsx詳細ページ(from パラメータ対応)
src/app/samples/loading.tsxローディング骨格
src/components/sample/SampleCard.tsxカードリンク(from 付き)
src/components/sample/SearchInput.tsx検索入力(クライアント)
src/components/sample/FilterPanel.tsxサイドバーフィルタ(PC)
src/components/sample/MobileFilterDrawer.tsxモバイル用フィルタドロワー
src/components/ui/EmptyState.tsx0 件時 UI
src/components/sample/SearchSuggestions.tsx候補キーワード一覧
src/components/ui/Pagination.tsxページネーション
tests/samples-search-flow.spec.tsPlaywright E2E テスト

Phase 1: 画面として成立させる

ゴール: 一覧ページと詳細ページが表示できる。

まず一覧画面として成立させます。検索やフィルタはまだ入れません。

一覧ページ

サーバーコンポーネントでデータを取得し、カードを並べるだけから始めます。

// app/samples/page.tsx
import { getPublicSamples } from "@/lib/content/loader";
import { SampleCard } from "@/components/sample/SampleCard";

export default async function SamplesPage() {
  const samples = getPublicSamples();

  return (
    <main>
      <p>全 {samples.length} 件</p>
      <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
        {samples.map((sample) => (
          <SampleCard key={sample.slug} sample={sample} />
        ))}
      </div>
    </main>
  );
}

SampleCard

title / summary / badge と詳細リンクを持つ最小構成です。

// components/sample/SampleCard.tsx
type Props = { sample: SampleListItem };

export function SampleCard({ sample }: Props) {
  return (
    <a href={`/samples/${sample.slug}`} data-testid="sample-card">
      <h3>{sample.title}</h3>
      <p>{sample.summary}</p>
    </a>
  );
}

詳細ページ

// app/samples/[slug]/page.tsx
export default async function SamplePage({ params }) {
  const { slug } = await params;
  const sample = getSampleBySlug(slug);
  if (!sample) notFound();

  return (
    <article>
      <a href="/samples">サンプル一覧に戻る</a>
      <h1>{sample.title}</h1>
      {/* MDX 本文 */}
    </article>
  );
}

Phase 1 完了条件: /samples にカードが並び、クリックで詳細が開く。


Phase 2: 検索・フィルタを足す

ゴール: キーワード・カテゴリ等で絞り込め、ソートとページネーションが動く。

q 検索

Enter 送信で q パラメータをセットするクライアントコンポーネントです。送信時に page を消してページをリセットします。

"use client";
// components/sample/SearchInput.tsx
export function SearchInput({ defaultValue }: { defaultValue?: string }) {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();

  function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const q = new FormData(e.currentTarget).get("q") as string;
    const params = new URLSearchParams(searchParams.toString());
    if (q) { params.set("q", q); } else { params.delete("q"); }
    params.delete("page");
    router.push(`${pathname}?${params.toString()}`);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="q" defaultValue={defaultValue} placeholder="キーワードで検索..." />
    </form>
  );
}

フィルタ・ソート

FilterPanel(PC サイドバー)と MobileFilterDrawer(モバイル)に同じ update 関数を使います。フィルタ・ソート変更時は page を必ず消します。

const update = useCallback((key: keyof FilterParams, value: string) => {
  const params = new URLSearchParams(searchParams.toString());
  if (value) { params.set(key, value); } else { params.delete(key); }
  params.delete("page"); // フィルタ・ソート変更時はページリセット
  router.push(`${pathname}?${params.toString()}`);
}, [router, pathname, searchParams]);

PC サイドバーとモバイルドロワーに同名の <select> が 2 つ存在します。id を使うと htmlFor が誤った要素を参照するため、aria-label で識別し id は廃止します。

アクティブフィルタチップ

絞り込み中の条件をチップとして画面上部に表示します。各チップには「そのキーだけを除いた URL」が付き、クリックで個別解除できます。

{activeChips.map((chip) => (
  <a
    key={chip.label}
    href={chip.href}
    aria-label={`${chip.label} を解除`}
  >
    {chip.label} ×
  </a>
))}

aria-label は Playwright テストの getByRole("link", { name: /カテゴリ:.*を解除/ }) でも使えます。

ページネーション

Pagination コンポーネントに buildHref: (page: number) => string を渡します。フィルタ・ソートの条件が一緒に保持されるよう、searchParams から page だけ差し替えた URL を生成します。

const buildPageHref = (page: number) => {
  const params = new URLSearchParams(searchParams.toString());
  if (page > 1) { params.set("page", String(page)); } else { params.delete("page"); }
  return `${pathname}?${params.toString()}`;
};

page > 1 のときだけ page パラメータを設定し、1 ページ目は URL から省きます。

Phase 2 完了条件: 検索・フィルタ・ソート・ページネーションが動作し、チップで個別解除できる。


Phase 3: URL設計と詳細復帰

ゴール: URL で状態を完全管理し、詳細ページから元の絞り込み一覧へ戻れる。

URL クエリを純粋関数に集約する

Phase 2 で各コンポーネントが個別に URLSearchParams を操作していた部分を、src/lib/url/sample-query.ts の 2 つの純粋関数に集約します。

parseSampleListQuery はサーバーコンポーネントの searchParams を受け取り、型付き FilterParams & { page: number } を返します。同名パラメータが複数ある場合(string[])は最初の値のみ使います。

buildSampleListHref は現在のパラメータに変更・削除を適用した URL 文字列を返します。page=1 は冗長なので自動で省略します。

buildSampleListHref({ params, remove: ["q"], resetPage: true })
// → "/samples?category=form&sort=created_desc"

チップ解除・ページネーション・SearchSuggestions のすべての href 生成をこの関数に統一することで、パラメータの欠落や余分な page 残留を防げます。

詳細への from パラメータ

一覧から詳細へ遷移するとき、現在の URL(絞り込み状態)を from として詳細の URL に付加します。

const href = returnHref
  ? `/samples/${sample.slug}?from=${encodeURIComponent(returnHref)}`
  : `/samples/${sample.slug}`;

open redirect 対策

詳細ページは searchParams から from を受け取り、サーバー側で検証します。

function parseReturnHref(from: unknown): string {
  if (typeof from === "string" && (from === "/samples" || from.startsWith("/samples?"))) return from;
  return "/samples";
}

/samples または /samples?... 以外は /samples にフォールバックします。https://example.com//evil.com も弾かれます。戻りリンクのラベルも from の内容で切り替えます。

{returnHref === "/samples" ? "サンプル一覧に戻る" : "絞り込み結果に戻る"}

EmptyState と SearchSuggestions

0 件のとき、絞り込み条件の種類に応じて解除ボタンを使い分けます。

const emptyActions: EmptyStateAction[] = [
  ...(filter.q
    ? [{ label: "キーワードを解除", href: buildSampleListHref({ params, remove: ["q"], resetPage: true }) }]
    : []),
  ...(hasFiltersOnly
    ? [{ label: "フィルタを解除", href: buildSampleListHref({ params, remove: ["framework", "language", "library", "category", "status"], resetPage: true }) }]
    : []),
  ...(hasActiveFilter
    ? [{ label: "すべてクリア", href: "/samples", variant: "secondary" }]
    : []),
];

data-testid="samples-empty-state" を付けて E2E から識別できるようにします。q が設定されて 0 件のとき、別のキーワードを提案する SearchSuggestions も表示します。各候補リンクも buildSampleListHref で生成します。

ローディング骨格

src/app/samples/loading.tsx を置くだけで Next.js が自動的にサスペンス境界を設けます。カード 9 枚分の animate-pulse 矩形を並べて遷移中の先行表示を提供します。

Phase 3 完了条件: URL をコピーしてリロードしても同じ表示になる。詳細から元の一覧クエリへ戻れる。


Phase 4: E2Eで守る

ゴール: 主要フローを Playwright で常時検証し、リグレッションを防ぐ。

テストは tests/samples-search-flow.spec.ts にまとめます。

テスト確認内容
基本表示検索入力・件数・カードが表示される
q 検索URL に q が入り、件数が絞り込み形式になる
category フィルタチップが出る、解除で categorypage が消える
sort 変更sortq が同時に保持される
paginationpage=2sort が保持、page=1 は URL に残らない(件数が十分ある場合)
詳細遷移from 付き URL で進み、「絞り込み結果に戻る」が出る
open redirect 防止不正な from/samples にフォールバックする
EmptyState 解除キーワード解除で qpage が消える
サジェストクリックで q が差し替わり page が消える

フィルタ・ソートの <select> はサイドバーとモバイルドロワーに 2 つ存在するため、getByLabel ではなく URL ナビゲーションで条件をセットして UI 反映を確認します。

// select をクリックする代わりに URL で直接セット
await page.goto(`/samples?q=${HIT_Q}&sort=created_desc`);
await expect(page).toHaveURL(/sort=created_desc/);

Phase 4 完了条件: npx playwright test がすべてパスし、CI に組み込まれている。


Phase 5: Playbook / Build 化する

ゴール: 実装したものをサイト内でどう見せるかを整理する。

このサイトでは 3 種類のコンテンツを使い分けます。

種別役割検索付き一覧画面での扱い
Samples個別のコード例/samples = 完成例として機能している
Playbooks作り方の記録このページ
Builds専用の小システム問い合わせ管理など独立した画面ができたときに使う

検索付き一覧画面の完成例は /samples に集約されています。Builds は「問い合わせ管理」「タスク管理」のように、フィルタ・一覧・詳細・編集を組み合わせた独立した小システムができたときに使います。

Phase 5 完了条件: Playbook が status: reviewed で公開され、/samples への完成例リンクが入っている。


実務画面への転用

この画面構成は一覧を持つあらゆるシステムに転用できます。

  • 商品一覧 — カテゴリ・価格帯フィルタ、名称検索、在庫あり優先ソート
  • 問い合わせ一覧 — ステータス・担当者フィルタ、キーワード検索、未対応優先ソート
  • タスク一覧 — 優先度・担当者フィルタ、期限順ソート、詳細からの直接編集
  • ユーザー一覧 — 権限・部門フィルタ、名前・メール検索

フィルタの種類や件数が変わっても、URL クエリ管理と EmptyState の構造はそのまま使えます。


設計判断まとめ

フィルタ状態を URL に持つ理由。ブラウザの戻る・シェア・リロードで同じ画面を再現できます。サーバーコンポーネントが searchParams を読むだけでデータ取得・表示が完結し、グローバル状態管理が不要になります。

フィルタ・ソート変更時に page をリセットする理由。条件が変わると総件数も変わるため、以前のページ番号を保持すると存在しないページを指す可能性があります。

URL ビルドを buildSampleListHref に統一した理由。チップ解除・ページネーション・サジェストがそれぞれ URLSearchParams を操作すると、パラメータの欠落や page の残留が起きやすくなります。一関数に集約することで一貫性を保ちます。

from 方式にした理由。フィルタ状態を URL で管理しているため、詳細ページは「どこから来たか」を知りません。from パラメータで一覧の URL を渡すことで、戻り先を正確に再現できます。

EmptyState を条件別に分けた理由。「キーワードを解除」「フィルタを解除」「すべてクリア」は操作の影響範囲が異なります。まとめて消すと意図しない条件が消えるため、個別解除を優先します。

E2E を追加した理由。URL クエリの組み合わせが多く、手動テストで全パターンを確認するのは困難です。主要フローを Playwright で固定することで、変更時のリグレッション検出を自動化します。

Builds を無理に公開しない理由。Builds は「完成した独立の小システム」のためのものです。/samples 自体が完成例として機能しているため、別途 Build を公開する必要がありません。


次に足せるもの

  • Unit test — フィルタ関数・URL ビルド関数のユニットテスト追加
  • server search / client search 比較 — SSR vs インクリメンタル検索のトレードオフ整理
  • offset vs cursor pagination — ページネーション方式の判断基準
  • 問い合わせ管理 — ステータス・担当者フィルタ + 詳細編集を組み合わせた小システム
  • タスク管理 — 優先度・期限フィルタ + ステータス変更を組み合わせた小システム