9
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

フロントエンジニアである以上、CMSを利用するサイトなどで「ページネーション」を実装する機会はよくあると思います。今回はmicroCMSとNext.js(App Router)を例に、サーバーサイドで制御するページネーションの基本となる仕組みをまとめました!

目的

  • ページネーションの普遍的なメカニズムを知り、あらゆる環境での実装に役立てる
  • microCMSとNext.js(App Router)の環境でのページネーションの実装記事が少ないので、同じ実装パターンで悩んでいる方への参考となれば(嬉しい!)

この記事で扱わないこと

  • エンドポイント指定時の型安全性の担保
  • アクセシビリティの担保

構成

※ 関連するパッケージのみ抜粋

  • Next.js:v15.5.9
  • microcms-js-sdk:v3.2.0
  • shadcn/ui

準備

microcms-js-sdk でmicroCMS からブログ記事の情報を取得するためGETメソッドを作成します。その際、引数にpagelimit を持たせ、リクエスト時にlimitoffset クエリパラメーターを渡すように設計します(その理由は後述します)。

  • limit:1度の取得件数を指定
  • offset:コンテンツを取得開始する位置を、指定した値だけ後ろにずらす
getBlogsService.ts
// microcms-js-sdkクライアントオブジェクトの呼び出し
import { client } from "@/lib/client";

export const getBlogs = async ({
  page,
  limit,
}: {
  page: number;
  limit: number;
}) => {
  const blogs = await client.getList<Blog>({
    endpoint: "blog",
    queries: {
      limit,
      offset: (page - 1) * limit,
    },
  });
  return blogs;
};

クエリパラメーターについて
limitoffset といったクエリパラメーターは、コンテンツ一覧を取得するmicroCMSのAPIが指定可能なパラメーターです。詳細については公式ドキュメントにてご確認ください。

実装(シンプル版)

まずは完成図

img_simple_pagination.png

01. ページネーションUIの用意(shadcn)

ロジックの実装に集中するため、shadcn/UI のページネーションコンポーネントを利用しました。Manual インストールで、コードベースで管理しておくことである程度見た目の自由も効くので非常に便利ですね✨

02. ページネーションのロジック

ざっくりですが実装におけるUI・URL(クエリパラメーター)・APIの関係性は以下のとおりです。
img_pagination_relation.png

ページネーション

blog-pagination.tsx
import {
  Pagination as UIPagination,
  PaginationContent as UIPaginationContent,
  PaginationLink as UIPaginationLink,
  PaginationItem as UIPaginationItem,
  PaginationPrevious as UIPaginationPrevious,
  PaginationNext as UIPaginationNext,
} from "@/components/ui/pagination";

type BlogPaginationProps = {
  totalCount: number;
  currentPage: number;
  perPage: number;
};

export function BlogPagination({ totalCount, currentPage, perPage }: BlogPaginationProps) {
  const totalPages = Math.ceil(totalCount / perPage);

  return (
    <UIPagination>
      <UIPaginationContent>
        <UIPaginationItem>
          <UIPaginationPrevious
            href={currentPage > 1 ? `?page=${currentPage - 1}` : undefined}
            aria-disabled={currentPage === 1}
            className={currentPage === 1 ? "invisible" : ""}
          />
        </UIPaginationItem>
        {Array.from({ length: totalPages }, (_, i) => (
          <UIPaginationItem key={i}>
            <UIPaginationLink
              href={`?page=${i + 1}`}
              isActive={currentPage === i + 1}
            >
              {i + 1}
            </UIPaginationLink>
          </UIPaginationItem>
        ))}
        <UIPaginationItem>
          <UIPaginationNext
            href={currentPage < totalPages ? `?page=${currentPage + 1}` : undefined}
            aria-disabled={currentPage === totalPages}
            className={currentPage === totalPages ? "invisible" : ""}
          />
        </UIPaginationItem>
      </UIPaginationContent>
    </UIPagination>
  );
}

全記事数(totalCount)と1ページあたりの記事数(perPage)情報から必要なページ数を算出します。

const totalPages = Math.ceil(totalCount / perPage);

ブログ一覧ページ

blogPage.tsx
const PER_PAGE = 4; // 1ページあたりの記事数

const Page = async ({searchParams: { page } }: PageProps) => {
  const currentPage = Number(page ?? "1");

  const { contents, totalCount } = await getBlogs({
    page: currentPage,
    limit: PER_PAGE,
  });

  return (

    // 他のコンテンツは省略

    <BlogPagination totalCount={totalCount} currentPage={currentPage} perPage={PER_PAGE} />
  )
}

ここで重要なのは以下のとおりです。

  • 1ページあたりの記事数(PER_PAGE)を設定します。
  • 作成したgetBlogs() ではgetList メソッドを内包しており、全記事数を取得できるレスポンスの型としてtotalCount を指定できます。ページネーション側でページャーの数を計算するために全記事数の情報が必要なので、contents と合わせて取得します。
  • 現在のページはsearchParams を利用して、URLからクエリパラメーターを取得します。取得できない場合は"1"を返して1ページ目を表示します。

↓ここでサイドGETメソッドを確認

getBlogsService.ts
offset: (page - 1) * limit,

getBlogs() メソッドでoffset を指定しますが、現在ページとページあたりの記事数を元に次点で必要なページ開始点を算出します。

実装(要件追加版)

ページング機能は追加できましたが、このままでは記事数が多くなるとその分ページャーが増えてしまいます。
そこで以下の要件を追加します。

  • カレントページ前後1個ずつページャーを表示する
  • 1ページ目と最後のページは常時ページャーを表示させる
  • 残りのページャーはドット"..."で省略する

完成図

img_pagination.png

ソースコード

変更後のコードは以下になります!(インポートや型の記述は省略)

blog-pagination.tsx
export function BlogPagination({
  totalCount,
  currentPage,
  perPage,
}: BlogPaginationProps) {
  const totalPages = Math.ceil(totalCount / perPage);

  // ページ番号配列を生成(カレント前後1ページ+先頭・末尾、間はドット)
  const pageItems: (number | 'dots')[] = [];
  for (let i = 1; i <= totalPages; i++) {
    if (
      i === 1 ||
      i === totalPages ||
      Math.abs(i - currentPage) <= 1
    ) {
      pageItems.push(i);
    } else if (
      pageItems[pageItems.length - 1] !== 'dots'
    ) {
      pageItems.push('dots');
    }
  }

  return (
    <UIPagination className="w-max p-2">
      <UIPaginationContent>
        <UIPaginationItem>
          <UIPaginationPrevious
            href={currentPage > 1 ? `?page=${currentPage - 1}` : undefined}
            aria-disabled={currentPage === 1}
            className={currentPage === 1 ? "invisible" : ""}
          />
        </UIPaginationItem>
        {pageItems.map((item, idx) =>
          item === 'dots' ? (
            <UIPaginationItem key={`dots-${idx}`}>
              <span className="px-2">...</span>
            </UIPaginationItem>
          ) : (
            <UIPaginationItem key={item}>
              <UIPaginationLink
                href={`?page=${item}`}
                isActive={currentPage === item}
              >
                {item}
              </UIPaginationLink>
            </UIPaginationItem>
          )
        )}
        <UIPaginationItem>
          <UIPaginationNext
            href={
              currentPage < totalPages ? `?page=${currentPage + 1}` : undefined
            }
            aria-disabled={currentPage === totalPages}
            className={currentPage === totalPages ? "invisible" : ""}
          />
        </UIPaginationItem>
      </UIPaginationContent>
    </UIPagination>
  );
}

ページ番号配列を生成

const pageItems: (number | 'dots')[] = [];
for (let i = 1; i <= totalPages; i++) {
  if (
    i === 1 ||
    i === totalPages ||
    Math.abs(i - currentPage) <= 1
  ) {
    pageItems.push(i);
  } else if (
    pageItems[pageItems.length - 1] !== 'dots'
  ) {
    pageItems.push('dots');
  }
}

具体的な流れは以下の通りです。

1. 全ページ数(totalPages)を計算
2. 1からtotalPagesまでループし、下記の条件で配列(pageItems)に値を追加する。

  • 先頭ページ(1)、末尾ページ(totalPages)、カレントページの前後1ページ(currentPage-1, currentPage, currentPage+1)は数字で追加する
  • それ以外のページは、直前がドットでなければ「'dots'」を追加する

Math.abs で絶対値に変換することで前後の指定数を網羅しています。そのためこちらの許容範囲を増やせばその分前後の表示されるページャーを変更することができます。
また、配列に余分にドットを格納しないように条件分岐を設定します。

UIの調整

{pageItems.map((item, idx) =>
  item === 'dots' ? (
    <UIPaginationItem key={`dots-${idx}`}>
      <span className="px-2">...</span>
    </UIPaginationItem>
  ) : (
    <UIPaginationItem key={item}>
      <UIPaginationLink
        href={`?page=${item}`}
        isActive={currentPage === item}
      >
        {item}
      </UIPaginationLink>
    </UIPaginationItem>
  )
)}

pageItems配列へpush で格納しているので、要素が最初から最後まで順番に格納されています
そのためmap() でページャーは正しい順番で表示されます。

参考:

まとめ

microCMS とNext.js(App Router)にてサーバーサイドのページネーションを実装しました!
今回はページネーションの実装方法に着目して、さまざまな技術要件下でも応用できるようにまとめております。ただ他にも考慮すべき要素はたくさんあり、特にクエリパラメーターのバリデーション対応は不可欠です。

いわゆるオフセットベースのページネーションを紹介しましたが、シーク法といったパフォーマンスを考慮した実装方法もあるとのこと。

結局は何事も深く知り、適切な実装方法を選択することが重要ですね!

9
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?