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

Next.js Route Handler でクエリパラメータを使ってソートを実装する

sort キーと order(asc/desc)をクエリパラメータで受け取り、Route Handler でデータをソートして返すパターンと、クライアント側のソートボタン UI を示す例。

nextjsapisearch-filter

対応バージョン

nextjs 15react 19

前提環境

Next.js App Router の Route Handler と URLSearchParams の基本を理解していること

概要

GET /api/items?sort=price&order=asc のように sort(キー名)と orderasc / 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 で現在のソート状態を読み取り、同じキーを再クリックしたときに ascdesc を切り替える。URL に状態を持つため、ブラウザバックや URL 共有でもソートが維持される
  • useEffect の依存配列に currentSortcurrentOrder を含めることで、URL パラメータの変化を検知してデータを再取得する
  • Suspense でラップすることで useSearchParams をサーバー側のビルドエラーなしに使用できる(Next.js App Router の要件)

注意点

nextjs-api-search は検索キーワードによるフィルタリング。nextjs-api-pagination はページネーション付き一覧。これは sort キー + asc/desc order の 2 パラメータによるソートに特化。許可キーの allowlist バリデーション・クライアント UI でのソート状態管理も示す。

関連サンプル