Next.js でページネーションを実装する — createPageData 関数の設計と使い方
記事一覧を全件表示し続けると、記事数が増えるにつれてページの読み込みが重くなり、目当ての記事を探しにくくなる。
この記事では、createPageData() という小さなユーティリティ関数を使って、Next.js App Router のページネーションをシンプルに実装する方法を紹介する。
なぜ createPageData 関数にまとめるのか
ページネーションには毎回同じ計算が必要になる。
- 総ページ数 (
Math.ceil(totalPostCount / POSTS_PER_PAGE)) - 表示開始インデックス (
(currentPage - 1) * POSTS_PER_PAGE) - 表示終了インデックス
- ページ番号の配列 (
[1, 2, 3, ...])
これらをページコンポーネントごとに書くと重複が生まれ、POSTS_PER_PAGE を変更したときに修正漏れが起きやすい。
createPageData() に切り出すことで、呼び出し側はページ番号と総件数を渡すだけで済むようになる。
型定義
// src/app/lib/types.ts
export type PageData = {
currentPage: number;
totalPages: number;
start: number;
end: number;
pages: number[]; // [1, 2, 3, 4, 5]
};
定数
// src/app/lib/constants.ts
export const POSTS_PER_PAGE = 15;
1 ページあたりの表示件数を定数化しておくことで、変更が 1 か所で完結する。
createPageData 関数の実装
// src/app/lib/functions.ts
import { POSTS_PER_PAGE } from "./constants";
import { PageData } from "./types";
const createPageData = (
currentPage: number,
totalPostCount: number
): PageData => {
const totalPages = Math.ceil(totalPostCount / POSTS_PER_PAGE);
const start = (currentPage - 1) * POSTS_PER_PAGE;
const end = start + POSTS_PER_PAGE;
const pages = Array.from({ length: totalPages }, (_, i) => 1 + i);
return {
currentPage,
totalPages,
start,
end,
pages,
};
};
各フィールドの役割
| フィールド | 使いどころ |
|---|---|
currentPage | Pagination コンポーネントにアクティブページを伝える |
totalPages | Pagination コンポーネントの最終ページ判定に使う |
start / end | posts.slice(start, end) で表示する記事を切り出す |
pages | ページ番号ボタンを生成する元配列 |
トップページ (1 ページ目固定) での使い方
// src/app/page.tsx
import { createPageData, getPostData } from "./lib/functions";
import TopPagination from "../components/Pagination";
export default async function Home() {
const posts = await getPostData();
const pageData = createPageData(1, posts.length);
return (
<main>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{posts.slice(pageData.start, pageData.end).map((post) => (
<PostCard key={post.slug} post={post} />
))}
</div>
<TopPagination
type="page"
pages={pageData.pages}
currentPage={pageData.currentPage}
/>
</main>
);
}
動的ルート (/page/[page]) での使い方
2 ページ目以降は /page/2, /page/3 ... の URL でアクセスできるようにする。
generateStaticParams で全ページ分の静的パスを生成しておくのが Next.js の output: "export" 環境では必須となる。
// src/app/page/[page]/page.tsx
import { POSTS_PER_PAGE } from "../../lib/constants";
import { createPageData, getPostData } from "../../lib/functions";
type Props = { params: { page: number } };
// 静的ルートを全ページ分生成
export async function generateStaticParams() {
const posts = await getPostData();
const totalPages = Math.ceil(posts.length / POSTS_PER_PAGE);
return Array.from({ length: totalPages }, (_, i) => ({
page: `${i + 1}`,
}));
}
export default async function Page({ params }: Props) {
const posts = await getPostData();
const pageData = createPageData(Number(params.page), posts.length);
return (
<main>
<h1>{params.page} ページ目</h1>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{posts.slice(pageData.start, pageData.end).map((post) => (
<PostCard key={post.slug} post={post} />
))}
</div>
<TopPagination
type="page"
pages={pageData.pages}
currentPage={pageData.currentPage}
/>
</main>
);
}
タグページでの再利用
同じ createPageData() はタグ別一覧でもそのまま使える。
タグでフィルタした後の配列を posts.length として渡すだけで、タグごとの総ページ数が正しく計算される。
// src/app/tags/[slug]/page.tsx (1 ページ目)
const posts = await getTagsData(params.slug);
const pageData = createPageData(1, posts.length);
// src/app/tags/[slug]/[page]/page.tsx (2 ページ目以降)
const posts = await getTagsData(params.slug);
const pageData = createPageData(Number(params.page), posts.length);
Pagination コンポーネントの設計
// src/components/Pagination.tsx
"use client";
import { useRouter } from "next/navigation";
interface PageProps {
type: string; // "page" | "tags/React" など — URL の prefix として使う
pages: number[]; // createPageData の pages をそのまま渡す
currentPage?: number;
}
const TopPagination = ({ type, pages, currentPage = 1 }: PageProps) => {
const router = useRouter();
const totalPages = pages.length;
const pageLimit = 5; // 一度に表示するページボタン数
// 現在ページを中心に表示範囲を計算
let startPage = Math.max(currentPage - Math.floor(pageLimit / 2), 1);
const endPage = Math.min(startPage + pageLimit - 1, totalPages);
if (endPage - startPage + 1 < pageLimit) {
startPage = Math.max(endPage - pageLimit + 1, 1);
}
const paginationRange: number[] = [];
for (let i = startPage; i <= endPage; i++) {
paginationRange.push(i);
}
return (
<nav className="flex items-center gap-2">
{/* 前へボタン */}
<button
onClick={() => currentPage > 1 && router.push(`/${type}/${currentPage - 1}`)}
disabled={currentPage <= 1}
>
‹
</button>
{/* ページ番号ボタン */}
{paginationRange.map((page) => (
<button
key={page}
onClick={() => router.push(`/${type}/${page}`)}
aria-current={page === currentPage ? "page" : undefined}
>
{page}
</button>
))}
{/* 次へボタン */}
<button
onClick={() => currentPage < totalPages && router.push(`/${type}/${currentPage + 1}`)}
disabled={currentPage >= totalPages}
>
›
</button>
</nav>
);
};
export default TopPagination;
type に "page" を渡せば /page/2 へ、"tags/React" を渡せば /tags/React/2 へと汎用的に遷移できる設計にしている。
ハマりやすいポイント
1. params.page は文字列として受け取られる
URL パラメータはすべて文字列になるため、createPageData() に渡す前に Number() で変換しないと計算が狂う。
// NG: params.page は "2" という文字列
const pageData = createPageData(params.page, posts.length);
// OK
const pageData = createPageData(Number(params.page), posts.length);
2. output: "export" では generateStaticParams が必須
next.config.js に output: "export" を設定している場合、動的ルートは generateStaticParams で事前に全パスを列挙しないとビルドエラーになる。
記事を追加するたびにページ数が変わるため、generateStaticParams 内でも getPostData() を呼んで動的に計算するのが正しい。
export async function generateStaticParams() {
const posts = await getPostData();
const totalPages = Math.ceil(posts.length / POSTS_PER_PAGE);
return Array.from({ length: totalPages }, (_, i) => ({ page: `${i + 1}` }));
}
3. トップページ (/) は /page/1 と別管理になる
/ と /page/1 は別ルートとして存在するため、Pagination の前へボタンで 1 ページ目に戻ると /page/1 に遷移する。
SEO 上の重複を避けたい場合は <link rel="canonical"> で正規化するか、/page/1 へのアクセスを / にリダイレクトするミドルウェアを追加することを検討する。
まとめ
createPageData(currentPage, totalPostCount)に現在ページと総件数を渡すだけで、start/end/pagesをまとめて取得できるposts.slice(pageData.start, pageData.end)で表示する記事を切り出す- タグページなどでも同じ関数を再利用でき、変更は
POSTS_PER_PAGEの 1 か所で済む output: "export"環境ではgenerateStaticParamsでページ数分のルートを事前生成することを忘れずに