Next.js ページネーションの実装についてまとめてみた

## はじめに
Webアプリの開発において、ページネーションはユーザーにとって使いやすいインターフェースを提供するために頻繁に使用されます。今回はNext.jsを用いて、userテーブルから取得したユーザー情報に対してページネーションを実装する方法を紹介します。


## 階層図
```
Project 
│
├── src
│   ├── app
│   │   ├── api
│   │   │   └── user
│   │   │       └── route.ts // バックエンドのAPIルート
│   │   └── user
│   │       └── page.tsx     // ユーザー一覧ページ（フロントエンドのエントリポイント）

```
## バックエンド部分の実装
・全体件数を取得。
・1ページに表示するデータの最大件数を設定。
・開始位置に応じた表示データを取得。
・ページ数を計算。(全体件数/1ページに表示するデータの最大件数)


## バックエンド部分のソースコード
src\app\api\user\route.ts
```
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export async function GET(req: NextRequest) {

  //クエリパラメータからページ番号を取得し、整数に変換（デフォルトは1）
  const page = parseInt(req.nextUrl.searchParams.get('page') || '1', 10);
  //クエリパラメータからページごとの表示数を取得し、整数に変換（デフォルトは10）
  const limit = parseInt(req.nextUrl.searchParams.get('limit') || '10', 10);
  //検索の開始位置を取得。
  const offset = (page - 1) * limit;

  try {
    const data = await prisma.user.findMany({
      skip: offset,
      take: limit,
      select:{
        id:true,
        name:true,
        email:true
      }
    });

    const totalItems = await prisma.user.count();

    const totalPages = Math.ceil(totalItems / limit);

    return NextResponse.json({
      items: data,
      totalPages,
      currentPage: page,
    }, { status: 200 });
  } catch (error:any) {
    console.error("Error fetching data: ", error);
    return new NextResponse(JSON.stringify({ message: 'Error', error: error.message }), {
      status: 500,
      headers: {
        'Content-Type': 'application/json',
      },
    });
  } finally {
    await prisma.$disconnect();
  }
}


```

### 全体件数を取得：
・全体件数を取得するクエリを実行します。
```
const totalItems = await prisma.user.count();
```
### 1ページに表示するデータの最大件数を設定：
・ページごとの表示件数（limit）を設定します。
```
const limit = parseInt(req.nextUrl.searchParams.get('limit') || '10', 10);
```

### 開始位置に応じた表示データを取得

Prismaでデータを取得する際には、skip と take というオプションを使用して、どこからデータを取得し、何件取得するかを指定します。

例えば、全体の取得対象データが50件あり、1ページに表示するデータの上限数が10件、そして2ページ目を表示する場合です。このとき、次のように計算します。

・skip オプションには offset を設定します。offset は (2 - 1) * 10 = 10 となり、最初の10件をスキップします。

・take オプションには 10 を設定し、スキップされた後の11件目から20件目までのデータを10件取得します。
```
const page = 2;  // 例: 2ページ目
const limit = 10;  // 1ページに表示するデータの数
const offset = (page - 1) * limit;  // スキップするデータの数

const data = await prisma.user.findMany({
  skip: offset,  // ここでは最初の10件をスキップ
  take: limit,   // 11件目から20件目までの10件を取得
  select: {
    id: true,
    name: true,
    email: true,
  },
});
```

公式ドキュメント
https://www-prisma-io.translate.goog/docs/orm/prisma-client/queries/pagination?_x_tr_sl=en&_x_tr_tl=ja&_x_tr_hl=ja&_x_tr_pto=sc

### ページ数を計算：
・全体件数と1ページあたりの件数を基に総ページ数を計算します。
```
const totalPages = Math.ceil(totalItems / limit);
```

## フロントエンド部分の実装
①データのフェッチ
②ページネーションの生成
③前へ・次へボタンの表示
④対象ページのクリック処理

## フロントエンド部分のソースコード
```
"use client"

import React, { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';

export default function Top() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const [data, setData] = useState([]);
  const [totalPages, setTotalPages] = useState(1);
  const [currentPage, setCurrentPage] = useState(1);

  async function fetchData(page) {
    try {
      const response = await fetch(`/api/user?page=${page}`);
      if (!response.ok) {
        throw new Error("Bad response");
      }
      const newData = await response.json();
      setTotalPages(newData.totalPages);
      setCurrentPage(newData.currentPage);
      setData(newData.items);
    } catch (error) {
      console.error("Failed:", error);
    }
  }

  useEffect(() => {    
    const page = parseInt(searchParams.get('page') || '1', 10);
    fetchData(page);
  }, [searchParams]);

  const generatePagination = () => {
    const pages = [];
    for (let i = 1; i <= totalPages; i++) {
      pages.push(i);
    }
    return pages;
  };

  const handlePageChange = (page) => {
    router.push(`/user?page=${page}`);
    setCurrentPage(page);
  };

  return (
    <>
      <main className="bg-home-bg bg-no-repeat bg-cover bg-center flex flex-col items-center justify-between p-4 sm:p-8 md:p-24 min-h-screen"> 
        <div className="text-center mb-4 max-w-4xl">
          <h1 className="mt-20 md:mt-0 text-white text-2xl sm:text-5xl md:text-6xl lg:text-4xl font-bold text-shadow-lg mb-4">
            ユーザーリスト
          </h1>
          <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
            {data.map(item => (
              <p className="text-white text-lg sm:text-xl md:text-2xl text-shadow-md mt-8 mb-16" key={item.id}>
                {item.name}
              </p>
            ))}
          </div>
        </div>
        {totalPages > 1 && (
          <div className="flex justify-center w-full max-w-5xl mt-6 space-x-2">
            {currentPage > 1 && (
              <button
                onClick={() => handlePageChange(currentPage - 1)}
                className="bg-gray-300 text-black px-4 py-2 sm:px-6 sm:py-3 rounded text-sm sm:text-base w-20 h-10 sm:w-24 sm:h-12 hover:bg-gray-400"
              >
                前へ
              </button>
            )}
            <div className="flex justify-center space-x-2">
              {generatePagination().map((page, index) => (
                <button
                  key={index}
                  onClick={() => typeof page === 'number' && handlePageChange(page)}
                  className={`w-10 h-10 sm:w-12 sm:h-12 mx-1 rounded text-sm sm:text-base ${currentPage === page ? 'bg-blue-500 text-white' : 'bg-gray-300 text-black hover:bg-gray-400'}`}
                  disabled={typeof page !== 'number'}
                >
                  {page}
                </button>
              ))}
            </div>
            {currentPage < totalPages && (
              <button
                onClick={() => handlePageChange(currentPage + 1)}
                className="bg-gray-300 text-black px-4 py-2 sm:px-6 sm:py-3 rounded text-sm sm:text-base w-20 h-10 sm:w-24 sm:h-12 hover:bg-gray-400"
              >
                次へ
              </button>
            )}
          </div>
        )}
      </main>
    </>
  );
}


```

### データのフェッチ
```
async function fetchData(page) {
    try {
      const response = await fetch(`/api/user?page=${page}`);
      if (!response.ok) {
        throw new Error("Bad response");
      }
      const newData = await response.json();
      setTotalPages(newData.totalPages);
      setCurrentPage(newData.currentPage);
      setData(newData.items);
    } catch (error) {
      console.error("Failed:", error);
    }
  }
```
バックエンドで取得した全体ページ数、現在のページを状態変数に格納します。


### 全体のページ数を表示
```
 const generatePagination = () => {
    const pages = [];
    for (let i = 1; i <= totalPages; i++) {
      pages.push(i);
    }
    return pages;
  };
```
1から totalPages までの数字を pages 配列に追加します。
最終的に pages 配列を返すことで、ページネーションのリンクを動的に生成します。
この generatePagination 関数によって、ユーザーは全てのページ番号を確認でき、任意のページ番号をクリックして移動することができます。

### 前へボタン,次へボタンの表示、非表示について
現在のページの値が１より大きい場合、前ボタンを表示します。
```
{currentPage > 1 && (
  <button
    onClick={() => handlePageChange(currentPage - 1)}
    className="bg-gray-300 text-black px-4 py-2 sm:px-6 sm:py-3 rounded text-sm sm:text-base w-20 h-10 sm:w-24 sm:h-12 hover:bg-gray-400"
  >
    前へ
  </button>
)}
```
![](https://storage.googleapis.com/zenn-user-upload/fda73b4520be-20240824.png)
総ページの値が現在のページの値より大きい場合、次へボタンを表示します。
```
{currentPage < totalPages && (
  <button
    onClick={() => handlePageChange(currentPage + 1)}
    className="bg-gray-300 text-black px-4 py-2 sm:px-6 sm:py-3 rounded text-sm sm:text-base w-20 h-10 sm:w-24 sm:h-12 hover:bg-gray-400"
  >
    次へ
  </button>
)}

```
![](https://storage.googleapis.com/zenn-user-upload/7215cfec85ea-20240824.png)

### 対象ページクリック時の状態
対象ページをクリックし、setCurrentPageに状態変数として、ページを格納する。
```
const handlePageChange = (page) => {
    router.push(`/user?page=${page}`);
    setCurrentPage(page);
  };
```

## まとめ
いかがだったでしょうか。今回はnext.jsにおけるページネーションについて
まとめてみました。
userテーブルから取得したユーザー情報に対して、バックエンドとフロントエンドの両方でページネーションを行う方法を紹介しました。
今回の内容が、Next.jsを使用する上での理解を深める手助けとなれば幸いです。今後も最新の技術をキャッチアップしながら、発信を続けていきたいと思います。