LoginSignup
0
0

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

Last updated at Posted at 2024-06-09

はじめに

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

この記事は、過去に投稿したNext.js × microCMSでブログサイト作成した際に学んだことを書き留めるのプロジェクトを元に説明しています。

目的

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

作業環境

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:カテゴリページでの記事一覧ページ等で使用。
(例:basePathcategory/にした場合、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

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

続いて3ページ目
image.png

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

カテゴリページ等でも、2ページ目以降の作成

あとはカテゴリページがある場合、カテゴリページにも同様にpageフォルダを作成してファイルを作成します。
内容はほぼ同じで、異なる箇所はPaginationpropsbasePathを設定して渡すことです。

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

SSGに対応する

SSG(Static Site Generation)の静的なファイルとして生成するようにします。

現時点でnpm run buildを実行すると、下記のようになっており、ページネーションのページは動的レンダリングになっているのがわかります。

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を実行してみると、カテゴリーページが静的生成に変わったのがわかります。

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ページが生成されています。

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)

参考

0
0
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
0
0