Next.js × microCMSで作ったブログにページネーション機能を実装

# はじめに
ブログサイトを作る過程で学んだことを、備忘録目的で投稿しています。
自身は駆け出しエンジニアであり、React自体がほぼ初学者のため、誤った認識・理解をしている可能性があります。
万が一参考にする場合は、上記の点を考慮した上でご一読ください。
また、スタイリングについては割愛しています。

この記事は、過去に投稿した[Next.js × microCMSでブログサイト作成した際に学んだことを書き留める](https://qiita.com/miyaayim_eng/items/62f4267280a9f3cfaa4d)のプロジェクトを元に説明しています。

# 目的
記事一覧ページに、最大表示件数に基づいたページャー機能を実装する。

# 作業環境
Windows10 Pro x64
Node.js: 20.14.0
npm: 10.3.0
Next.js: 14.2.3（App Router使用）

# 手順

## コンポーネント作成
ページネーションのコンポーネントを作成します。

まずは、記事一覧ページのファイルを編集します。
ページネーションコンポーネントの読み込み、設置。
そして記事データ取得時に、`totalCount`でコンテンツ件数を取得できます、これはmicroCMSの機能になります。

下記のようになりました。
（カテゴリーページに関する記述は、説明の混乱を避けるために消してあります。）
```/src/app/page.jsx
import { notFound } from "next/navigation";
import styles from "./page.module.scss";
import { LIMIT } from "@/constants";
import { getArticlesList } from "@/libs/microcms";
import { Cards } from "@/components/Cards";
import { Pagination } from "@/components/Pagination"; // 追加

export default async function Home() {
  // ブログ一覧を取得
  const articlesListQueries = { limit: LIMIT };
  const articlesListResponse = await getArticlesList(articlesListQueries).catch(() => notFound());
  const { contents: articles, totalCount: totalCount } = articlesListResponse; // totalCountを追加

  return (
    <main className={styles.main}>
      <h1 className={styles.title}>全ての記事</h1>
      <div className={styles.container}>
        <div>
          <p>記事一覧</p>
          <ul className={styles.cards}>
            <Cards articles={articles} />
          </ul>
        </div>
      </div>
      <Pagination totalCount={totalCount} /> {/* 追加 */}
    </main>
  );
}
```

続いてコンポーネントの作成です。
`props`の説明は下記になります。
`totalCount`：コンテンツ件数
`currentPage`：現在のページ番号、初期値を`1`とする
`basePath`：カテゴリページでの記事一覧ページ等で使用。
（例：`basePath`を`category/`にした場合、`http://localhost:3000/category/page/1`）

`const totalPages = Math.ceil(totalCount / LIMIT);`では、ページ総数を計算しています。
> Math.ceil は、与えられた数値を切り上げるためのJavaScriptの関数です。
> 例えば、totalCount / LIMIT が3.2であれば、Math.ceilはそれを4に切り上げます。
> これは、アイテムが余っていても次のページに含める必要があるためです。



```/src/components/Pagination/index.jsx
import Link from "next/link";
import styles from "./index.module.scss";
import { LIMIT } from "@/constants";

export const Pagination = async ({
  totalCount,
  currentPage = 1,
  basePath = "",
}) => {
  // 全ページ数を計算
  const totalPages = Math.ceil(totalCount / LIMIT);

  return (
    <ul className={styles.list}>
      {currentPage == "1" ? (
        <li className={styles.item}>
          <span className={styles.link}>最初へ</span>
        </li>
      ) : (
        <li className={styles.item}>
          <Link href={`${basePath}/page/1`} className={styles.link}>
            最初へ
          </Link>
        </li>
      )}
      {currentPage == "1" ? (
        <li className={styles.item}>
          <span className={styles.link}>前へ</span>
        </li>
      ) : (
        <li className={styles.item}>
          <Link
            href={`${basePath}/page/${currentPage - 1}`}
            className={styles.link}
          >
            前へ
          </Link>
        </li>
      )}
      <li className={styles.item}>
        {currentPage} / {totalPages}
      </li>
      {currentPage == totalPages ? (
        <li className={styles.item}>
          <span className={styles.link}>次へ</span>
        </li>
      ) : (
        <li className={styles.item}>
          <Link
            href={`${basePath}/page/${currentPage + 1}`}
            className={styles.link}
          >
            次へ
          </Link>
        </li>
      )}
      {currentPage == totalPages ? (
        <li className={styles.item}>
          <span className={styles.link}>最後へ</span>
        </li>
      ) : (
        <li className={styles.item}>
          <Link href={`${basePath}/page/${totalPages}`} className={styles.link}>
            最後へ
          </Link>
        </li>
      )}
    </ul>
  );
};

```

テストとして、ページ表示件数は`1`にしておきます。
```/src/constants/index.js
// 1ページの表示件数
export const LIMIT = 1;
```

このようになりました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3310435/8ce81ad4-b093-3758-733f-32acafc5130f.png)

## 2ページ目以降のファイルを作成
記事一覧ページのファイルをベースに作成します。
`/src/app/page/[current]`配下に、`page.jsx`を作成します。

`params`でURLからページ番号を取得します。
`offset`は、コンテンツを取得開始する位置を、指定した値だけ後ろにずらします。

```/src/app/page/[current]/page.jsx
import { notFound } from "next/navigation";
import styles from "./page.module.scss";
import { LIMIT } from "@/constants";
import { getArticlesList } from "@/libs/microcms";
import { Cards } from "@/components/Cards";
import { Pagination } from "@/components/Pagination";

export default async function Page({ params }) { // paramsを追加
  // URLから現在のページ番号を数値として取得
  const currentPage = parseInt(params.current, 10); // 追加

  // ブログ一覧を取得
  const articlesListQueries = {
    limit: LIMIT,
    offset: (currentPage - 1) * LIMIT, // 追加
  };
  const articlesListResponse = await getArticlesList(articlesListQueries).catch(() => notFound());
  const { contents: articles, totalCount: totalCount } = articlesListResponse;

  return (
    <main className={styles.main}>
      <h1 className={styles.title}>全ての記事</h1>
      <div className={styles.container}>
        <div>
          <p>記事一覧</p>
          <ul className={styles.cards}>
            <Cards articles={articles} />
          </ul>
        </div>
      </div>
      <Pagination totalCount={totalCount} currentPage={currentPage} /> {/* currentPageを追加 */}
    </main>
  );
}
```

それでは、記事一覧ページで「次へ」ボタンをクリックし、2ページ目を表示してみます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3310435/6d82e705-025e-aa3d-6a53-dfc15e8214e5.png)

続いて3ページ目
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3310435/aa3b907d-4294-fb12-ac09-8eb48dd412a8.png)

問題なく表示できました。

## カテゴリページ等でも、2ページ目以降の作成
あとはカテゴリページがある場合、カテゴリページにも同様に`page`フォルダを作成してファイルを作成します。
内容はほぼ同じで、異なる箇所は`Pagination`の`props`に`basePath`を設定して渡すことです。
```page.jsx
<Pagination
  totalCount={totalCount}
  basePath={`/category/${currentCategory}`}
  currentPage={currentPage}
/>
```

できあがったのが下記になります。
```/src/app/category/[categoryId]/page.jsx
import { notFound } from "next/navigation";
import styles from "./page.module.scss";
import { LIMIT } from "@/constants";
import { getArticlesList, getCategoriesList } from "@/libs/microcms";
import { Cards } from "@/components/Cards";
import { Categories } from "@/components/Categories";
import { Pagination } from "@/components/Pagination";

export default async function Page({ params }) {
  // URLからカテゴリIDを取得
  const currentCategory = params.categoryId;

  // ブログ一覧を取得
  const filters = `category[equals]${currentCategory}`;
  const articlesListQueries = {
    limit: LIMIT,
    filters: filters,
  };
  const articlesListResponse = await getArticlesList(articlesListQueries).catch(
    () => notFound()
  );
  const { contents: articles, totalCount: totalCount } = articlesListResponse;

  // カテゴリ一覧を取得
  const categoriesListQueries = { limit: 100 };
  const categoriesListResponse = await getCategoriesList(
    categoriesListQueries
  ).catch(() => notFound());
  const { contents: categories } = categoriesListResponse;

  return (
    <main className={styles.main}>
      <h1 className={styles.title}>「{currentCategory}」の記事</h1>
      <div className={styles.container}>
        <div>
          <p>記事一覧</p>
          <ul className={styles.cards}>
            <Cards articles={articles} />
          </ul>
        </div>
        <div>
          <p>カテゴリ一覧</p>
          <ul className={styles.cards}>
            <Categories categories={categories} />
          </ul>
        </div>
      </div>
      <Pagination
        totalCount={totalCount}
        basePath={`/category/${currentCategory}`}
      />
    </main>
  );
}
```
```/src/app/category/[categoryId]/page/[current]/page.jsx
import { notFound } from "next/navigation";
import styles from "./page.module.scss";
import { LIMIT } from "@/constants";
import { getArticlesList, getCategoriesList } from "@/libs/microcms";
import { Cards } from "@/components/Cards";
import { Categories } from "@/components/Categories";
import { Pagination } from "@/components/Pagination";

export default async function Page({ params }) {
  // URLからカテゴリIDを取得
  const currentCategory = params.categoryId;
  // URLから現在のページ番号を数値として取得
  const currentPage = parseInt(params.current, 10);

  // ブログ一覧を取得
  const filters = `category[equals]${currentCategory}`;
  const articlesListQueries = {
    limit: LIMIT,
    filters: filters,
    offset: (currentPage - 1) * LIMIT,
  };
  const articlesListResponse = await getArticlesList(articlesListQueries).catch(
    () => notFound()
  );
  const { contents: articles, totalCount: totalCount } = articlesListResponse;

  // カテゴリ一覧を取得
  const categoriesListQueries = { limit: 100 };
  const categoriesListResponse = await getCategoriesList(
    categoriesListQueries
  ).catch(() => notFound());
  const { contents: categories } = categoriesListResponse;

  return (
    <main className={styles.main}>
      <h1 className={styles.title}>「{currentCategory}」の記事</h1>
      <div className={styles.container}>
        <div>
          <p>記事一覧</p>
          <ul className={styles.cards}>
            <Cards articles={articles} />
          </ul>
        </div>
        <div>
          <p>カテゴリ一覧</p>
          <ul className={styles.cards}>
            <Categories categories={categories} />
          </ul>
        </div>
      </div>
      <Pagination
        totalCount={totalCount}
        basePath={`/category/${currentCategory}`}
        currentPage={currentPage}
      />
    </main>
  );
}
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3310435/a8ff96c1-2a81-22ed-733a-3212267c6d09.png)

## SSGに対応する
SSG（Static Site Generation）の静的なファイルとして生成するようにします。

現時点でnpm run buildを実行すると、下記のようになっており、ページネーションのページは動的レンダリングになっているのがわかります。
```console
Route (app)                                Size     First Load JS
┌ ○ /                                      427 B          99.3 kB
├ ○ /_not-found                            871 B          87.9 kB
├ ● /articles/[slug]                       285 B          92.4 kB
├   ├ /articles/3bqtpjwckpa6
├   ├ /articles/2la1jxzdhmy
├   └ /articles/yy3_bxzq2f-0
├ ƒ /category/[categoryId]                 431 B          99.3 kB
├ ● /category/[categoryId]/page/[current]  430 B          99.3 kB
└ ƒ /page/[current]                        434 B          99.3 kB
+ First Load JS shared by all              87 kB
  ├ chunks/23-0627c91053ca9399.js          31.5 kB
  ├ chunks/fd9d1056-2821b0f0cabcd8bd.js    53.6 kB
  └ other shared chunks (total)            1.9 kB


○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses getStaticProps)
ƒ  (Dynamic)  server-rendered on demand
```

SSGに対応するために、generateStaticParamsを追記します。
考え方は記事詳細ページでのgenerateStaticParamsと同じですので説明は割愛します。

### 記事一覧ページをSSGに対応
まずは、通常の記事一覧ページをSSG対応にします。
```/src/app/category/[categoryId]/page/[current]/page.jsx
import { notFound } from "next/navigation";
import styles from "./page.module.scss";
import { LIMIT } from "@/constants";
import { getArticlesList, getCategoriesList } from "@/libs/microcms";
import { Cards } from "@/components/Cards";
import { Categories } from "@/components/Categories";
import { Pagination } from "@/components/Pagination";

// ページネーションページの静的パスを作成
export async function generateStaticParams() {
  // ブログ一覧を取得
  const queries = { limit: LIMIT, fields: "id" };
  const articlesListResponse = await getArticlesList(queries);
  const { totalCount: totalCount } = articlesListResponse;

  if (totalCount <= LIMIT) {
    return []; // ページ数が1ページ以下の場合はパスを生成しない
  }

  // ページ範囲を生成する関数
  const range = (start, end) =>
    Array.from({ length: end - start + 1 }, (_, i) => start + i);

  // パスの配列を生成
  const paths = range(1, Math.ceil(totalCount / LIMIT)).map((page) => ({
    current: page.toString(),
  }));

  // 作成したパスの配列を返します。
  return [...paths];
}

export default async function Page({ params }) {
  // URLから現在のページ番号を数値として取得
  const currentPage = parseInt(params.current, 10);

  // ブログ一覧を取得
  const articlesListQueries = {
    limit: LIMIT,
    offset: (currentPage - 1) * LIMIT,
  };
  const articlesListResponse = await getArticlesList(articlesListQueries).catch(
    () => notFound()
  );
  const { contents: articles, totalCount: totalCount } = articlesListResponse;

  // カテゴリ一覧を取得
  const categoriesListQueries = { limit: 100 };
  const categoriesListResponse = await getCategoriesList(
    categoriesListQueries
  ).catch(() => notFound());
  const { contents: categories } = categoriesListResponse;

  return (
    <main className={styles.main}>
      <h1 className={styles.title}>全ての記事</h1>
      <div className={styles.container}>
        <div>
          <p>記事一覧</p>
          <ul className={styles.cards}>
            <Cards articles={articles} />
          </ul>
        </div>
        <div>
          <p>カテゴリ一覧</p>
          <ul className={styles.cards}>
            <Categories categories={categories} />
          </ul>
        </div>
      </div>
      <Pagination totalCount={totalCount} currentPage={currentPage} />
    </main>
  );
}
```
この状態でnom run buildを実行してみると、カテゴリーページが静的生成に変わったのがわかります。
```console
Route (app)                                Size     First Load JS
┌ ○ /                                      427 B          99.3 kB
├ ○ /_not-found                            871 B          87.9 kB
├ ● /articles/[slug]                       285 B          92.4 kB
├   ├ /articles/3bqtpjwckpa6
├   ├ /articles/2la1jxzdhmy
├   └ /articles/yy3_bxzq2f-0
├ ƒ /category/[categoryId]                 431 B          99.3 kB
├ ● /category/[categoryId]/page/[current]  430 B          99.3 kB
└ ● /page/[current]                        434 B          99.3 kB
    ├ /page/1
    ├ /page/2
    └ /page/3
+ First Load JS shared by all              87 kB
  ├ chunks/23-0627c91053ca9399.js          31.5 kB
  ├ chunks/fd9d1056-2821b0f0cabcd8bd.js    53.6 kB
  └ other shared chunks (total)            1.9 kB


○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses getStaticProps)
ƒ  (Dynamic)  server-rendered on demand
```

### カテゴリページをSSGに対応
続いてカテゴリページもSSGに対応させますが、やり方が少し異なります。
まずは失敗例をお見せします、さきほどと同じ作りでカテゴリページのファイルに`generateStaticParams`を追記します。

```/src/app/category/[categoryId]/page/[current]/page.jsx
import { notFound } from "next/navigation";
import styles from "./page.module.scss";
import { LIMIT } from "@/constants";
import { getArticlesList, getCategoriesList } from "@/libs/microcms";
import { Cards } from "@/components/Cards";
import { Categories } from "@/components/Categories";
import { Pagination } from "@/components/Pagination";

// ページネーションページの静的パスを作成
export async function generateStaticParams({ params }) {
  // URLからカテゴリIDを取得
  const currentCategory = params.categoryId;

  // ブログ一覧を取得
  const filters = `category[equals]${currentCategory}`;
  const queries = { limit: LIMIT, filters: filters, fields: "id" };
  const articlesListResponse = await getArticlesList(queries);
  const { totalCount: totalCount } = articlesListResponse;

  if (totalCount <= LIMIT) {
    return []; // ページ数が1ページ以下の場合はパスを生成しない
  }

  // ページ範囲を生成する関数
  const range = (start, end) =>
    Array.from({ length: end - start + 1 }, (_, i) => start + i);

  // パスの配列を生成
  const paths = range(1, Math.ceil(totalCount / LIMIT)).map((page) => ({
    current: page.toString(),
  }));

  // 作成したパスの配列を返します。
  return [...paths];
}

export default async function Page({ params }) {
  // URLからカテゴリIDを取得
  const currentCategory = params.categoryId;
  // URLから現在のページ番号を数値として取得
  const currentPage = parseInt(params.current, 10);

  // ブログ一覧を取得
  const filters = `category[equals]${currentCategory}`;
  const articlesListQueries = {
    limit: LIMIT,
    filters: filters,
    offset: (currentPage - 1) * LIMIT,
  };
  const articlesListResponse = await getArticlesList(articlesListQueries).catch(
    () => notFound()
  );
  const { contents: articles, totalCount: totalCount } = articlesListResponse;

  // カテゴリ一覧を取得
  const categoriesListQueries = { limit: 100 };
  const categoriesListResponse = await getCategoriesList(
    categoriesListQueries
  ).catch(() => notFound());
  const { contents: categories } = categoriesListResponse;

  return (
    <main className={styles.main}>
      <h1 className={styles.title}>「{currentCategory}」の記事</h1>
      <div className={styles.container}>
        <div>
          <p>記事一覧</p>
          <ul className={styles.cards}>
            <Cards articles={articles} />
          </ul>
        </div>
        <div>
          <p>カテゴリ一覧</p>
          <ul className={styles.cards}>
            <Categories categories={categories} />
          </ul>
        </div>
      </div>
      <Pagination
        totalCount={totalCount}
        basePath={`/category/${currentCategory}`}
        currentPage={currentPage}
      />
    </main>
  );
}

```
しかしこの状態で`generateStaticParams`内の`params`を確認すると何も取得できていませんでした。
```/src/app/category/[categoryId]/page/[current]/page.jsx
console.log("params => ", params);

// 出力結果
params =>  {}
```

解消方法としては、カテゴリページの静的パスを作成を`category/[categoryId]/page.jsx`で行うのではなく、`category/[categoryId]/layout.jsx`で行うようにしないと、`params`でカテゴリIDを受け取ることができないようでした。

どうやら`generateStaticParams`が下層ページで連続する場合は、`lauout.jsx`を経由する必要があるみたいです。
修正していきましょう。

`page.jsx`から`generateStaticParams`の記述を削除
```/src/app/category/[categoryId]/page.jsx
import { notFound } from "next/navigation";
import styles from "./page.module.scss";
import { LIMIT } from "@/constants";
import { getArticlesList, getCategoriesList } from "@/libs/microcms";
import { Cards } from "@/components/Cards";
import { Categories } from "@/components/Categories";
import { Pagination } from "@/components/Pagination";

export default async function Page({ params }) {
  // URLからカテゴリIDを取得
  const currentCategory = params.categoryId;

  // ブログ一覧を取得
  const filters = `category[equals]${currentCategory}`;
  const articlesListQueries = {
    limit: LIMIT,
    filters: filters,
  };
  const articlesListResponse = await getArticlesList(articlesListQueries).catch(
    () => notFound()
  );
  const { contents: articles, totalCount: totalCount } = articlesListResponse;

  // カテゴリ一覧を取得
  const categoriesListQueries = { limit: 100 };
  const categoriesListResponse = await getCategoriesList(
    categoriesListQueries
  ).catch(() => notFound());
  const { contents: categories } = categoriesListResponse;

  return (
    <main className={styles.main}>
      <h1 className={styles.title}>「{currentCategory}」の記事</h1>
      <div className={styles.container}>
        <div>
          <p>記事一覧</p>
          <ul className={styles.cards}>
            <Cards articles={articles} />
          </ul>
        </div>
        <div>
          <p>カテゴリ一覧</p>
          <ul className={styles.cards}>
            <Categories categories={categories} />
          </ul>
        </div>
      </div>
      <Pagination
        totalCount={totalCount}
        basePath={`/category/${currentCategory}`}
      />
    </main>
  );
}

```

`layout.jsx`を作成し、`generateStaticParams`を記述
```/src/app/category/[categoryId]/layout.jsx
import { getCategoriesList } from "@/libs/microcms";

// カテゴリーページの静的パスを作成
export async function generateStaticParams() {
  // カテゴリ一覧を取得
  const queries = { limit: 100, fields: "id" };
  const categoriesListResponse = await getCategoriesList(queries);
  const { contents: categories } = categoriesListResponse;

  const paths = categories.map((category) => {
    return {
      categoryId: category.id,
    };
  });

  // 作成したパスの配列を返します。
  return [...paths];
}

export default function CategoryLayout({ children }) {
  return <>{children}</>;
}
```

この状態で、再度`generateStaticParams`内の`params`を確認すると、無事受け取れていることが確認できました。
```/src/app/category/[categoryId]/page/[current]/page.jsx
console.log("params => ", params);

// 出力結果
params =>  { categoryId: 'html' }
params =>  { categoryId: 'css' }
params =>  { categoryId: 'javascript' }
```
それでは、`npm run build`を実行してみましょう。
下記のように静的生成されていると思います。
今回は、CSSカテゴリーのみ複数ページがあるので、1～2ページが生成されています。
```console
Route (app)                                Size     First Load JS
┌ ○ /                                      427 B          99.3 kB
├ ○ /_not-found                            871 B          87.9 kB
├ ● /articles/[slug]                       285 B          92.4 kB
├   ├ /articles/3bqtpjwckpa6
├   ├ /articles/2la1jxzdhmy
├   └ /articles/yy3_bxzq2f-0
├ ● /category/[categoryId]                 431 B          99.3 kB
├   ├ /category/html
├   ├ /category/css
├   └ /category/javascript
├ ● /category/[categoryId]/page/[current]  430 B          99.3 kB
├   ├ /category/css/page/1
├   └ /category/css/page/2
└ ● /page/[current]                        434 B          99.3 kB
    ├ /page/1
    ├ /page/2
    └ /page/3
+ First Load JS shared by all              87 kB
  ├ chunks/23-0627c91053ca9399.js          31.5 kB
  ├ chunks/fd9d1056-2821b0f0cabcd8bd.js    53.6 kB
  └ other shared chunks (total)            1.9 kB


○  (Static)  prerendered as static content
●  (SSG)     prerendered as static HTML (uses getStaticProps)
```

# 参考
https://blog.microcms.io/next-pagination/

https://document.microcms.io/content-api/get-list-contents#hae4e49f442

https://document.microcms.io/content-api/get-list-contents#h41838110ca

https://nextjs.org/docs/app/api-reference/functions/generate-static-params