概要
Prisma のページネーションには オフセット方式(skip / take)と カーソル方式(cursor / take)がある。オフセット方式はページ番号を URL パラメータで扱いやすくシンプル。カーソル方式は大量データでも一定の性能を保ちやすく、無限スクロールや「次のページ」ナビゲーションに向く。
インストール
npm install prisma @prisma/client
npx prisma init
実装
Prisma スキーマ
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model Post {
id Int @id @default(autoincrement())
title String
content String
createdAt DateTime @default(now())
}
Prisma クライアント(シングルトン)
// lib/prisma.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["query"] : [],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
オフセットページネーション(Route Handler)
// app/api/posts/route.ts
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const page = Math.max(1, Number(searchParams.get("page") ?? "1"));
const limit = Math.min(100, Math.max(1, Number(searchParams.get("limit") ?? "10")));
const skip = (page - 1) * limit;
const [posts, total] = await prisma.$transaction([
prisma.post.findMany({
skip,
take: limit,
orderBy: { createdAt: "desc" },
select: { id: true, title: true, createdAt: true },
}),
prisma.post.count(),
]);
const totalPages = Math.ceil(total / limit);
return NextResponse.json({
posts,
pagination: {
page,
limit,
total,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1,
},
});
}
カーソルベースページネーション(無限スクロール向け)
// app/api/posts/cursor/route.ts
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const cursor = searchParams.get("cursor");
const limit = Math.min(50, Math.max(1, Number(searchParams.get("limit") ?? "10")));
const posts = await prisma.post.findMany({
take: limit + 1, // 次ページ存在確認のため 1 件多く取得
...(cursor
? {
cursor: { id: Number(cursor) },
skip: 1, // カーソル自身をスキップ
}
: {}),
orderBy: { id: "desc" },
select: { id: true, title: true, createdAt: true },
});
const hasNextPage = posts.length > limit;
const items = hasNextPage ? posts.slice(0, limit) : posts;
const nextCursor = hasNextPage ? String(items[items.length - 1].id) : null;
return NextResponse.json({ posts: items, nextCursor });
}
クライアントコンポーネント(オフセットページネーション UI)
// components/PostList.tsx
"use client";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
type Post = { id: number; title: string; createdAt: string };
type Pagination = {
page: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
};
export default function PostList() {
const router = useRouter();
const searchParams = useSearchParams();
const page = Number(searchParams.get("page") ?? "1");
const [posts, setPosts] = useState<Post[]>([]);
const [pagination, setPagination] = useState<Pagination | null>(null);
useEffect(() => {
fetch(`/api/posts?page=${page}&limit=5`)
.then((r) => r.json())
.then((data: { posts: Post[]; pagination: Pagination }) => {
setPosts(data.posts);
setPagination(data.pagination);
});
}, [page]);
function goTo(p: number) {
router.push(`?page=${p}`);
}
return (
<div className="space-y-4">
<ul className="divide-y rounded-lg border">
{posts.map((post) => (
<li key={post.id} className="px-4 py-3">
<p className="font-medium">{post.title}</p>
<p className="text-xs text-gray-400">{new Date(post.createdAt).toLocaleDateString("ja-JP")}</p>
</li>
))}
</ul>
{pagination && (
<div className="flex items-center justify-between text-sm">
<button
onClick={() => goTo(page - 1)}
disabled={!pagination.hasPrev}
className="rounded border px-3 py-1 disabled:opacity-40"
>
前へ
</button>
<span className="text-gray-500">
{page} / {pagination.totalPages} ページ
</span>
<button
onClick={() => goTo(page + 1)}
disabled={!pagination.hasNext}
className="rounded border px-3 py-1 disabled:opacity-40"
>
次へ
</button>
</div>
)}
</div>
);
}
ポイント
prisma.$transaction([findMany, count])で 1 トランザクション内に一覧取得とカウントをまとめる。個別に実行すると 2 回の DB ラウンドトリップが発生し、件数が変わった場合に数値がずれる可能性があるpageとlimitパラメータはMath.max/Math.minでサニタイズし、負の値や極端に大きな値による意図しないクエリを防ぐ- カーソル方式では
take: limit + 1で 1 件多く取得し、結果がlimitを超えていれば次ページが存在すると判定する。最後の要素のidをnextCursorとして返し、クライアントが次リクエストで使用する skip: 1はカーソルで指定した行自身を除外するために必要。Prisma のcursorはその行 "から" 始めるため、skip: 1を省略するとカーソル行が重複して返る- オフセット方式は
skipの値が大きくなるほど DB のスキャン量が増える。大量データ(数万件以上)ではカーソル方式の方がパフォーマンスが安定する