このプレイブックで作るもの
検索付き一覧画面で実現する体験は次のとおりです。
- 一覧を開く
- キーワードで検索する
- フィルタで絞り込む
- 並び替える
- ページを移動する
- カードをクリックして詳細へ進む
- 詳細から元の絞り込み条件へ戻る
- 0 件時に次の操作が分かる
- Playwright E2E で主要フローを守る
完成例
完成例は
/samplesで確認できます。検索、フィルタ、並び替え、詳細復帰、0 件時の導線まで含めて実装済みです。
5 つのフェーズに分けて、この画面を段階的に構築します。フェーズごとに「画面として成立している」状態を保ちながら機能を積み上げます。
| フェーズ | ゴール |
|---|---|
| Phase 1 | 一覧と詳細が表示できる |
| Phase 2 | 検索・フィルタ・ソート・ページネーションで絞り込める |
| Phase 3 | URL で状態を管理し、詳細から一覧へ戻れる |
| Phase 4 | E2E テストで主要フローを保護する |
| Phase 5 | Playbook と Build で見せ方を整理する |
実装の最終形はこのファイル構成になります。
| ファイル | 役割 |
|---|---|
src/lib/url/sample-query.ts | URL クエリのパース・ビルド(純粋関数) |
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.tsx | 0 件時 UI |
src/components/sample/SearchSuggestions.tsx | 候補キーワード一覧 |
src/components/ui/Pagination.tsx | ページネーション |
tests/samples-search-flow.spec.ts | Playwright 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 フィルタ | チップが出る、解除で category と page が消える |
| sort 変更 | sort と q が同時に保持される |
| pagination | page=2 で sort が保持、page=1 は URL に残らない(件数が十分ある場合) |
| 詳細遷移 | from 付き URL で進み、「絞り込み結果に戻る」が出る |
| open redirect 防止 | 不正な from は /samples にフォールバックする |
| EmptyState 解除 | キーワード解除で q と page が消える |
| サジェスト | クリックで 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 — ページネーション方式の判断基準
- 問い合わせ管理 — ステータス・担当者フィルタ + 詳細編集を組み合わせた小システム
- タスク管理 — 優先度・期限フィルタ + ステータス変更を組み合わせた小システム