はじめに
ブログサイトを作る過程で学んだことを、備忘録目的で投稿しています。
自身は駆け出しエンジニアであり、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の機能になります。
下記のようになりました。
(カテゴリーページに関する記述は、説明の混乱を避けるために消してあります。)
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に切り上げます。
これは、アイテムが余っていても次のページに含める必要があるためです。
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
にしておきます。
// 1ページの表示件数
export const LIMIT = 1;
2ページ目以降のファイルを作成
記事一覧ページのファイルをベースに作成します。
/src/app/page/[current]
配下に、page.jsx
を作成します。
params
でURLからページ番号を取得します。
offset
は、コンテンツを取得開始する位置を、指定した値だけ後ろにずらします。
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ページ目を表示してみます。
問題なく表示できました。
カテゴリページ等でも、2ページ目以降の作成
あとはカテゴリページがある場合、カテゴリページにも同様にpage
フォルダを作成してファイルを作成します。
内容はほぼ同じで、異なる箇所はPagination
のprops
にbasePath
を設定して渡すことです。
<Pagination
totalCount={totalCount}
basePath={`/category/${currentCategory}`}
currentPage={currentPage}
/>
できあがったのが下記になります。
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>
);
}
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>
);
}
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対応にします。
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
を追記します。
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
を確認すると何も取得できていませんでした。
console.log("params => ", params);
// 出力結果
params => {}
解消方法としては、カテゴリページの静的パスを作成をcategory/[categoryId]/page.jsx
で行うのではなく、category/[categoryId]/layout.jsx
で行うようにしないと、params
でカテゴリIDを受け取ることができないようでした。
どうやらgenerateStaticParams
が下層ページで連続する場合は、lauout.jsx
を経由する必要があるみたいです。
修正していきましょう。
page.jsx
からgenerateStaticParams
の記述を削除
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
を記述
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
を確認すると、無事受け取れていることが確認できました。
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)
参考