【Next.js】管理画面の検索条件とページネーションをURLクエリと連動する
こんにちは、ライトです。
これまで、管理画面で使うコンポーネントとして以下を作成してきました。
管理画面レイアウト
StatusBadge
DataTable
Pagination
SearchFilter
一覧画面まわりでは、DataTable、Pagination、SearchFilterBar を作成しました。
ここまでで、
データを表示する
検索・絞り込みを行う
ページを切り替える
表示件数を変更する
ところまではできています。
ただ、現在の状態管理が useState だけだと、ページを再読み込みしたときに検索条件やページ番号がリセットされてしまいます。
管理画面では、次のようなURLで一覧状態を復元できると便利です。
/users?keyword=yamada&status=active&role=admin&page=2&pageSize=20今回は、検索条件とページネーションを URLクエリ と連動させて、より実務に近い一覧画面にしていきます。
URLクエリと連動する理由
検索条件やページ番号をURLに持たせると、次のようなメリットがあります。
ページを再読み込みしても状態が残る
URLを共有できる
ブラウザバックで前の検索状態に戻れる
検索条件をブックマークできる
管理画面らしい挙動になる
たとえば、ユーザー一覧で「有効な管理者だけを表示して2ページ目を見ている」場合、URLに状態が入っていれば、再読み込みしても同じ状態を表示できます。
/users?status=active&role=admin&page=2一覧画面では、URLと状態を連動させておくと使いやすくなります。
今回作成するもの
今回は、以下の状態をURLクエリと連動します。
keyword
キーワード検索status
ステータス絞り込みrole
権限絞り込みpage
現在ページpageSize
表示件数
URLの例です。
/users?keyword=yamada&status=active&role=admin&page=2&pageSize=10画面では、これまで作成した以下のコンポーネントを使います。
SearchFilterBar
DataTable
Pagination
StatusBadge
DashboardLayout
完成形
以下のような一覧画面を作成します。

検索条件やページ番号を変更すると、URLクエリも更新されます。
ファイル構成
今回は、以下の構成を想定します。
components/
ui/
DataTable.tsx
Pagination.tsx
SearchFilterBar.tsx
StatusBadge.tsx
components/
dashboard/
DashboardLayout.tsx
Header.tsx
icons.tsx
MobileMenu.tsx
navItems.tsx
NavMenu.tsx
Sidebar.tsx
app/
page.tsx今回は主に以下を扱います。
SearchFilterBar.tsx
URLクエリから受け取った値を表示できるようにするapp/page.tsx
useSearchParams / useRouter / usePathname を使ってURLと状態を連動する
SearchFilterBarをURL連動しやすい形にする
以前作成した SearchFilterBar は、内部で検索条件を useState 管理する形でした。
URLクエリと連動する場合は、URLから取得した値を初期値として反映したり、ブラウザバックでURLが変わったときに入力欄も更新したくなります。
そのため、今回は values をpropsで受け取り、内部の入力状態と同期できる形にします。
components/ui/SearchFilterBar.tsx
"use client";
import { FormEvent, useEffect, useState } from "react";
export type SearchFilterValues = {
keyword: string;
status: string;
role: string;
};
type SearchFilterBarProps = {
values: SearchFilterValues;
onSearch: (values: SearchFilterValues) => void;
onReset?: () => void;
};
const initialValues: SearchFilterValues = {
keyword: "",
status: "all",
role: "all",
};
export default function SearchFilterBar({
values,
onSearch,
onReset,
}: SearchFilterBarProps) {
const [draftValues, setDraftValues] =
useState<SearchFilterValues>(values);
useEffect(() => {
setDraftValues(values);
}, [values]);
const handleChange = (name: keyof SearchFilterValues, value: string) => {
setDraftValues((currentValues) => ({
...currentValues,
[name]: value,
}));
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
onSearch(draftValues);
};
const handleReset = () => {
setDraftValues(initialValues);
onSearch(initialValues);
onReset?.();
};
return (
<form
onSubmit={handleSubmit}
className="rounded-lg border border-slate-200 bg-white p-4"
>
<div className="grid gap-4 lg:grid-cols-[1fr_180px_180px_auto] lg:items-end">
<div>
<label
htmlFor="keyword"
className="mb-2 block text-sm font-medium text-slate-700"
>
キーワード
</label>
<input
id="keyword"
type="search"
value={draftValues.keyword}
onChange={(event) =>
handleChange("keyword", event.target.value)
}
placeholder="名前・メールアドレスで検索"
className="h-11 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-primary-500 focus:bg-white"
/>
</div>
<div>
<label
htmlFor="status"
className="mb-2 block text-sm font-medium text-slate-700"
>
ステータス
</label>
<select
id="status"
value={draftValues.status}
onChange={(event) =>
handleChange("status", event.target.value)
}
className="h-11 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700 outline-none transition focus:border-primary-500 focus:bg-white"
>
<option value="all">すべて</option>
<option value="active">有効</option>
<option value="pending">承認待ち</option>
<option value="suspended">停止中</option>
</select>
</div>
<div>
<label
htmlFor="role"
className="mb-2 block text-sm font-medium text-slate-700"
>
権限
</label>
<select
id="role"
value={draftValues.role}
onChange={(event) =>
handleChange("role", event.target.value)
}
className="h-11 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700 outline-none transition focus:border-primary-500 focus:bg-white"
>
<option value="all">すべて</option>
<option value="admin">管理者</option>
<option value="editor">編集者</option>
<option value="viewer">閲覧者</option>
</select>
</div>
<div className="flex gap-2">
<button
type="submit"
className="h-11 rounded-lg bg-primary-500 px-4 text-sm font-medium text-white transition hover:bg-primary-700"
>
検索
</button>
<button
type="button"
onClick={handleReset}
className="h-11 rounded-lg border border-slate-200 bg-white px-4 text-sm font-medium text-slate-600 transition hover:bg-slate-50"
>
リセット
</button>
</div>
</div>
</form>
);
}ポイントは、values を受け取るようにしたことです。
type SearchFilterBarProps = {
values: SearchFilterValues;
onSearch: (values: SearchFilterValues) => void;
onReset?: () => void;
};そして、URLクエリが変わった場合に入力欄も更新できるように、useEffect で同期しています。
useEffect(() => {
setDraftValues(values);
}, [values]);この形にしておくと、ブラウザバックなどでURLが変わった場合にも、検索フォームの表示を合わせやすくなります。
URLクエリを読み取る
次に、app/page.tsx 側でURLクエリを読み取ります。
Next.js App Routerでは、クライアントコンポーネントで以下を使います。
import {
usePathname,
useRouter,
useSearchParams,
} from "next/navigation";役割は以下のようになっています。
useSearchParams
現在のURLクエリを取得するuseRouter
URLを更新するusePathname
現在のパスを取得する
クエリ値を安全に扱う関数を用意する
URLクエリは文字列なので、page や pageSize は数値に変換する必要があります。
まず、数値変換用の関数を用意します。
function toPositiveNumber(value: string | null, fallback: number) {
const numberValue = Number(value);
if (!Number.isFinite(numberValue) || numberValue < 1) {
return fallback;
}
return numberValue;
}page=abc のような値が入っていても、fallbackの値を使うようにします。
URLクエリから検索条件を作る
URLクエリから検索条件を作ります。
const filters: SearchFilterValues = {
keyword: searchParams.get("keyword") ?? "",
status: searchParams.get("status") ?? "all",
role: searchParams.get("role") ?? "all",
};keyword がなければ空文字。
status と role がなければ "all" にします。
ページ番号と表示件数も取得します。
const page = toPositiveNumber(searchParams.get("page"), 1);
const pageSize = toPositiveNumber(searchParams.get("pageSize"), 5);URLクエリを更新する関数を作る
次に、URLクエリを更新する関数を作ります。
const updateQuery = (nextValues: {
keyword?: string;
status?: string;
role?: string;
page?: number;
pageSize?: number;
}) => {
const params = new URLSearchParams(searchParams.toString());
if (nextValues.keyword !== undefined) {
const keyword = nextValues.keyword.trim();
if (keyword) {
params.set("keyword", keyword);
} else {
params.delete("keyword");
}
}
if (nextValues.status !== undefined) {
if (nextValues.status === "all") {
params.delete("status");
} else {
params.set("status", nextValues.status);
}
}
if (nextValues.role !== undefined) {
if (nextValues.role === "all") {
params.delete("role");
} else {
params.set("role", nextValues.role);
}
}
if (nextValues.page !== undefined) {
if (nextValues.page <= 1) {
params.delete("page");
} else {
params.set("page", String(nextValues.page));
}
}
if (nextValues.pageSize !== undefined) {
if (nextValues.pageSize === 5) {
params.delete("pageSize");
} else {
params.set("pageSize", String(nextValues.pageSize));
}
}
const queryString = params.toString();
router.push(queryString ? `${pathname}?${queryString}` : pathname);
};ポイントは、初期値と同じ値の場合はURLから削除していることです。
たとえば、status が "all" の場合はURLに入れません。
if (nextValues.status === "all") {
params.delete("status");
}このようにしておくと、URLが必要以上に長くなりません。
DataTableとPaginationに連動する
ここから、これまで作成した DataTable と Pagination に組み込みます。
app/page.tsx
"use client";
import { useMemo } from "react";
import {
usePathname,
useRouter,
useSearchParams,
} from "next/navigation";
import DashboardLayout from "@/components/dashboard/DashboardLayout";
import DataTable, { DataTableColumn } from "@/components/ui/DataTable";
import Pagination from "@/components/ui/Pagination";
import SearchFilterBar, {
SearchFilterValues,
} from "@/components/ui/SearchFilterBar";
import StatusBadge from "@/components/ui/StatusBadge";
type User = {
id: number;
name: string;
email: string;
role: "admin" | "editor" | "viewer";
status: "active" | "pending" | "suspended";
};
const users: User[] = [
{
id: 1,
name: "山田 太郎",
email: "yamada@example.com",
role: "admin",
status: "active",
},
{
id: 2,
name: "佐藤 花子",
email: "sato@example.com",
role: "editor",
status: "pending",
},
{
id: 3,
name: "田中 一郎",
email: "tanaka@example.com",
role: "viewer",
status: "suspended",
},
{
id: 4,
name: "鈴木 次郎",
email: "suzuki@example.com",
role: "editor",
status: "active",
},
{
id: 5,
name: "高橋 美咲",
email: "takahashi@example.com",
role: "viewer",
status: "pending",
},
{
id: 6,
name: "伊藤 健",
email: "ito@example.com",
role: "admin",
status: "active",
},
{
id: 7,
name: "渡辺 亮",
email: "watanabe@example.com",
role: "viewer",
status: "suspended",
},
{
id: 8,
name: "中村 彩",
email: "nakamura@example.com",
role: "editor",
status: "active",
},
{
id: 9,
name: "小林 翔",
email: "kobayashi@example.com",
role: "viewer",
status: "pending",
},
{
id: 10,
name: "加藤 優",
email: "kato@example.com",
role: "admin",
status: "active",
},
{
id: 11,
name: "吉田 葵",
email: "yoshida@example.com",
role: "editor",
status: "active",
},
{
id: 12,
name: "山本 大輔",
email: "yamamoto@example.com",
role: "viewer",
status: "suspended",
},
];
function toPositiveNumber(value: string | null, fallback: number) {
const numberValue = Number(value);
if (!Number.isFinite(numberValue) || numberValue < 1) {
return fallback;
}
return numberValue;
}
function renderStatus(status: User["status"]) {
switch (status) {
case "active":
return <StatusBadge variant="success">有効</StatusBadge>;
case "pending":
return <StatusBadge variant="warning">承認待ち</StatusBadge>;
case "suspended":
return <StatusBadge variant="danger">停止中</StatusBadge>;
}
}
function renderRole(role: User["role"]) {
switch (role) {
case "admin":
return "管理者";
case "editor":
return "編集者";
case "viewer":
return "閲覧者";
}
}
const columns: DataTableColumn<User>[] = [
{
key: "name",
header: "名前",
render: (user) => (
<div>
<div className="font-medium text-slate-900">{user.name}</div>
<div className="mt-1 text-xs text-slate-400">ID: {user.id}</div>
</div>
),
},
{
key: "email",
header: "メールアドレス",
render: (user) => (
<span className="text-slate-600">{user.email}</span>
),
},
{
key: "role",
header: "権限",
render: (user) => (
<span className="text-slate-600">{renderRole(user.role)}</span>
),
},
{
key: "status",
header: "ステータス",
render: (user) => renderStatus(user.status),
},
];
export default function Page() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const filters: SearchFilterValues = {
keyword: searchParams.get("keyword") ?? "",
status: searchParams.get("status") ?? "all",
role: searchParams.get("role") ?? "all",
};
const requestedPage = toPositiveNumber(searchParams.get("page"), 1);
const pageSize = toPositiveNumber(searchParams.get("pageSize"), 5);
const filteredUsers = useMemo(() => {
return users.filter((user) => {
const keyword = filters.keyword.trim().toLowerCase();
const matchesKeyword =
keyword.length === 0 ||
user.name.toLowerCase().includes(keyword) ||
user.email.toLowerCase().includes(keyword);
const matchesStatus =
filters.status === "all" || user.status === filters.status;
const matchesRole =
filters.role === "all" || user.role === filters.role;
return matchesKeyword && matchesStatus && matchesRole;
});
}, [filters.keyword, filters.status, filters.role]);
const totalItems = filteredUsers.length;
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
const currentPage = Math.min(requestedPage, totalPages);
const currentUsers = useMemo(() => {
const start = (currentPage - 1) * pageSize;
const end = start + pageSize;
return filteredUsers.slice(start, end);
}, [filteredUsers, currentPage, pageSize]);
const updateQuery = (nextValues: {
keyword?: string;
status?: string;
role?: string;
page?: number;
pageSize?: number;
}) => {
const params = new URLSearchParams(searchParams.toString());
if (nextValues.keyword !== undefined) {
const keyword = nextValues.keyword.trim();
if (keyword) {
params.set("keyword", keyword);
} else {
params.delete("keyword");
}
}
if (nextValues.status !== undefined) {
if (nextValues.status === "all") {
params.delete("status");
} else {
params.set("status", nextValues.status);
}
}
if (nextValues.role !== undefined) {
if (nextValues.role === "all") {
params.delete("role");
} else {
params.set("role", nextValues.role);
}
}
if (nextValues.page !== undefined) {
if (nextValues.page <= 1) {
params.delete("page");
} else {
params.set("page", String(nextValues.page));
}
}
if (nextValues.pageSize !== undefined) {
if (nextValues.pageSize === 5) {
params.delete("pageSize");
} else {
params.set("pageSize", String(nextValues.pageSize));
}
}
const queryString = params.toString();
router.push(queryString ? `${pathname}?${queryString}` : pathname);
};
const handleSearch = (values: SearchFilterValues) => {
updateQuery({
...values,
page: 1,
});
};
const handlePageChange = (nextPage: number) => {
updateQuery({
page: nextPage,
});
};
const handlePageSizeChange = (nextPageSize: number) => {
updateQuery({
pageSize: nextPageSize,
page: 1,
});
};
return (
<DashboardLayout>
<div className="space-y-6">
<section>
<p className="text-sm font-medium text-primary-700">Users</p>
<h2 className="mt-1 text-2xl font-semibold text-slate-950">
ユーザー一覧
</h2>
<p className="mt-2 max-w-2xl text-sm leading-7 text-slate-600">
検索条件とページネーションをURLクエリと連動した一覧画面です。
URLを共有したり、再読み込みしても状態を維持できます。
</p>
</section>
<SearchFilterBar
values={filters}
onSearch={handleSearch}
/>
<div className="space-y-4">
<DataTable
columns={columns}
data={currentUsers}
getRowKey={(user) => user.id}
emptyMessage="条件に一致するユーザーが見つかりません。"
/>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
pageSize={pageSize}
pageSizeOptions={[5, 10, 20]}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
/>
</div>
</div>
</DashboardLayout>
);
}これで、検索条件、ページ番号、表示件数がURLクエリと連動するようになります。
検索条件をURLに反映する
検索ボタンを押したときは、handleSearch が呼ばれます。
const handleSearch = (values: SearchFilterValues) => {
updateQuery({
...values,
page: 1,
});
};検索条件を変更した場合、ページ番号は1に戻しています。
検索結果が変わると総ページ数も変わるため、検索時に1ページ目へ戻すのが自然です。
ページ番号をURLに反映する
ページを変更したときは、page をURLに反映します。
const handlePageChange = (nextPage: number) => {
updateQuery({
page: nextPage,
});
};たとえば、2ページ目へ移動すると、URLは次のようになります。
/users?page=2検索条件がある場合は、検索条件を残したままページだけ更新します。
/users?keyword=yamada&status=active&page=2表示件数をURLに反映する
表示件数を変更した場合は、pageSize をURLに反映します。
const handlePageSizeChange = (nextPageSize: number) => {
updateQuery({
pageSize: nextPageSize,
page: 1,
});
};表示件数を変えると総ページ数が変わるため、ここでも1ページ目に戻しています。
たとえば、表示件数を10件にした場合は、URLが次のようになります。
/users?pageSize=10初期値と同じ場合はURLを短くする
今回の updateQuery では、初期値と同じ場合はURLから削除しています。
たとえば、status が "all" の場合です。
if (nextValues.status === "all") {
params.delete("status");
}page が1の場合も削除しています。
if (nextValues.page <= 1) {
params.delete("page");
}表示件数が初期値の5件の場合も削除します。
if (nextValues.pageSize === 5) {
params.delete("pageSize");
}これにより、初期状態のURLはシンプルになります。
/users検索条件がある場合だけ、必要なクエリを付けます。
/users?keyword=yamada&status=activeブラウザバックにも対応しやすくなる
URLクエリと連動すると、ブラウザバックでも前の検索状態に戻りやすくなります。
たとえば、次のように操作したとします。
/users
/users?status=active
/users?status=active&page=2
ブラウザバックを押すと、前のURLに戻ります。
/users?status=activeSearchFilterBar は values をpropsで受け取り、URLクエリが変わると入力欄も更新されるようにしているため、画面表示もURLに追従できます。
useEffect(() => {
setDraftValues(values);
}, [values]);実装のポイント
今回のポイントは、一覧画面の状態をURLクエリに寄せたことです。
検索条件
keyword / status / roleページネーション
page / pageSize
これらをURLに持たせることで、状態を共有しやすくなります。
また、役割を分けているのもポイントです。
SearchFilterBar
検索条件の入力を担当DataTable
絞り込み後のデータを表示Pagination
ページ変更と表示件数変更を担当page.tsx
URLクエリの読み取りと更新を担当
コンポーネント側にURL操作を持たせすぎず、ページ側でまとめて管理しています。
この方が、他の一覧画面にも展開しやすいです。
まとめ
今回は、Next.jsで管理画面の検索条件とページネーションをURLクエリと連動しました。
今回連動した値は以下です。
keyword
status
role
page
pageSize
これにより、
ページを再読み込みしても検索状態が残る
URLを共有できる
ブラウザバックで前の状態に戻れる
検索条件とページ番号を一緒に管理できる
ようになります。
これまで作成してきた、
SearchFilterBar
DataTable
Pagination
StatusBadge
DashboardLayout
を組み合わせることで、管理画面の一覧がかなり実務に近づいてきました。
いいなと思ったら応援しよう!
もしよろしければ応援をお願いいたします。
いただいたチップでコーヒーを飲んでがんばります!