概要
GET /api/items?sort=price&order=asc のように sort(キー名)と order(asc / desc)をクエリパラメータで受け取る Route Handler を実装する。許可するソートキーを allowlist で制限して任意フィールドアクセスを防ぎ、クライアント側はソートボタンで URL を更新してデータを再取得する。
インストール
# 追加インストールは不要
実装
Route Handler(ソート付き API)
// app/api/items/route.ts
import { NextResponse } from "next/server";
type Item = {
id: number;
name: string;
price: number;
createdAt: string;
};
// サンプルデータ(実際のアプリでは DB から取得)
const items: Item[] = [
{ id: 1, name: "りんご", price: 150, createdAt: "2024-01-03" },
{ id: 2, name: "バナナ", price: 80, createdAt: "2024-01-01" },
{ id: 3, name: "オレンジ", price: 120, createdAt: "2024-01-02" },
{ id: 4, name: "ぶどう", price: 300, createdAt: "2024-01-04" },
];
// 許可するソートキーを明示的に制限する
const ALLOWED_SORT_KEYS = ["name", "price", "createdAt"] as const;
type SortKey = (typeof ALLOWED_SORT_KEYS)[number];
function isAllowedSortKey(key: string): key is SortKey {
return (ALLOWED_SORT_KEYS as readonly string[]).includes(key);
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const sortParam = searchParams.get("sort") ?? "id";
const order = searchParams.get("order") === "desc" ? "desc" : "asc";
// 不正なキーはデフォルト(id)にフォールバック
const sortKey = isAllowedSortKey(sortParam) ? sortParam : "name";
const sorted = [...items].sort((a, b) => {
const aVal = a[sortKey];
const bVal = b[sortKey];
if (typeof aVal === "number" && typeof bVal === "number") {
return order === "asc" ? aVal - bVal : bVal - aVal;
}
const aStr = String(aVal);
const bStr = String(bVal);
return order === "asc"
? aStr.localeCompare(bStr, "ja")
: bStr.localeCompare(aStr, "ja");
});
return NextResponse.json({ items: sorted, sort: sortKey, order });
}
クライアントコンポーネント(ソートボタン UI)
// components/SortableItemList.tsx
"use client";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
type Item = {
id: number;
name: string;
price: number;
createdAt: string;
};
type SortKey = "name" | "price" | "createdAt";
type Order = "asc" | "desc";
const SORT_LABELS: Record<SortKey, string> = {
name: "名前",
price: "価格",
createdAt: "登録日",
};
export default function SortableItemList() {
const router = useRouter();
const searchParams = useSearchParams();
const currentSort = (searchParams.get("sort") ?? "name") as SortKey;
const currentOrder = (searchParams.get("order") ?? "asc") as Order;
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`/api/items?sort=${currentSort}&order=${currentOrder}`)
.then((r) => r.json())
.then((data: { items: Item[] }) => {
setItems(data.items);
setLoading(false);
});
}, [currentSort, currentOrder]);
function handleSort(key: SortKey) {
const nextOrder =
currentSort === key && currentOrder === "asc" ? "desc" : "asc";
const params = new URLSearchParams({ sort: key, order: nextOrder });
router.push(`?${params.toString()}`);
}
function SortIcon({ sortKey }: { sortKey: SortKey }) {
if (currentSort !== sortKey) return <span className="text-gray-300">↕</span>;
return <span>{currentOrder === "asc" ? "↑" : "↓"}</span>;
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-gray-50">
{(Object.keys(SORT_LABELS) as SortKey[]).map((key) => (
<th key={key} className="px-4 py-3 text-left font-medium">
<button
onClick={() => handleSort(key)}
className="flex items-center gap-1 hover:text-blue-600"
>
{SORT_LABELS[key]} <SortIcon sortKey={key} />
</button>
</th>
))}
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={3} className="px-4 py-8 text-center text-gray-400">
読み込み中…
</td>
</tr>
) : (
items.map((item) => (
<tr key={item.id} className="border-b hover:bg-gray-50">
<td className="px-4 py-3">{item.name}</td>
<td className="px-4 py-3">¥{item.price.toLocaleString()}</td>
<td className="px-4 py-3">{item.createdAt}</td>
</tr>
))
)}
</tbody>
</table>
</div>
);
}
ページへの組み込み
// app/items/page.tsx
import { Suspense } from "react";
import SortableItemList from "@/components/SortableItemList";
export default function ItemsPage() {
return (
<main className="mx-auto max-w-2xl p-8">
<h1 className="mb-6 text-2xl font-bold">商品一覧</h1>
<Suspense fallback={<p className="text-gray-400">読み込み中…</p>}>
<SortableItemList />
</Suspense>
</main>
);
}
ポイント
- ソートキーを
ALLOWED_SORT_KEYSの allowlist で制限し、不正な文字列が渡されたときはデフォルトキーにフォールバックする。ユーザー入力をそのままオブジェクトキーに使うと任意フィールドアクセスやインジェクションリスクがある orderパラメータは"desc"のみを明示的に受け入れ、それ以外はすべて"asc"扱いにすることで不正値を無害化する- クライアント側では
useSearchParamsで現在のソート状態を読み取り、同じキーを再クリックしたときにasc↔descを切り替える。URL に状態を持つため、ブラウザバックや URL 共有でもソートが維持される useEffectの依存配列にcurrentSortとcurrentOrderを含めることで、URL パラメータの変化を検知してデータを再取得するSuspenseでラップすることでuseSearchParamsをサーバー側のビルドエラーなしに使用できる(Next.js App Router の要件)