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,
  };
};

各フィールドの役割

フィールド使いどころ
currentPagePagination コンポーネントにアクティブページを伝える
totalPagesPagination コンポーネントの最終ページ判定に使う
start / endposts.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}
      >
        &lsaquo;
      </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}
      >
        &rsaquo;
      </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.jsoutput: "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 でページ数分のルートを事前生成することを忘れずに