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

Prisma で skip / take を使ったページネーションクエリを実装する

Prisma の skip / take / orderBy を使ったオフセットページネーションと、cursor ベースのページネーションのパターン、Route Handler との組み合わせ例。

nextjscrudpaginationprisma

対応バージョン

nextjs 15react 19prisma 5

前提環境

Prisma の基本 CRUD(prisma-crud-api)とページネーションの概念を理解していること

概要

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 ラウンドトリップが発生し、件数が変わった場合に数値がずれる可能性がある
  • pagelimit パラメータは Math.max / Math.min でサニタイズし、負の値や極端に大きな値による意図しないクエリを防ぐ
  • カーソル方式では take: limit + 1 で 1 件多く取得し、結果が limit を超えていれば次ページが存在すると判定する。最後の要素の idnextCursor として返し、クライアントが次リクエストで使用する
  • skip: 1 はカーソルで指定した行自身を除外するために必要。Prisma の cursor はその行 "から" 始めるため、skip: 1 を省略するとカーソル行が重複して返る
  • オフセット方式は skip の値が大きくなるほど DB のスキャン量が増える。大量データ(数万件以上)ではカーソル方式の方がパフォーマンスが安定する

注意点

prisma-crud-api は基本 CRUD。prisma-relation-query は include/select による関連取得。nextjs-api-search はページネーション付き検索 API。これは Prisma の skip/take によるオフセットページネーションと cursor ベースの 2 パターンに特化。Route Handler での page/limit パラメータ受け取りも示す。

関連サンプル