Next.jsでページネーション実装とWebアプリケーションの状態管理を完全理解
はじめに
技術ブログの記事が16本を超えて、一覧ページが長くなりすぎる問題に直面しました。今回はNext.js App Routerでページネーション機能を実装しながら、Webアプリケーションの状態管理について深く理解していきます。
「なぜHTMLとCSS/JavaScriptで状態を管理できるのか?」「内部ストレージに保持しているの?」といった疑問も解決していきます。
現在の問題点
// 現在の実装:全記事を一度に表示
export default function BlogIndex() {
const posts = getSortedPostsData() // 全16記事を取得
return (
<ul>
{posts.map((post) => ( // 全記事を表示
<li key={post.slug}>{post.title}</li>
))}
</ul>
)
}
問題点の整理
-
パフォーマンス問題
- 記事数が増えるとHTMLサイズが肥大化
- ブラウザのレンダリング負荷が増大
- 初期表示時間が遅くなる
-
ユーザビリティ問題
- スクロールが長すぎて目的の記事を見つけにくい
- 古い記事にアクセスしづらい
Webアプリケーションの状態管理とは
状態(State)とは何か
状態とは、アプリケーションが「今どのような状態にあるか」を表す情報です。
例えば:
- 現在表示しているページ番号
- ユーザーがログインしているか
- フォームに入力された値
- 選択されたタブ
状態を管理する3つの場所
1. クライアントサイド(ブラウザ)の状態管理
// 1. JavaScriptのメモリ(変数)
let currentPage = 1;
// 2. LocalStorage(永続的)
localStorage.setItem('currentPage', '1');
// ブラウザを閉じても保持される
// 3. SessionStorage(一時的)
sessionStorage.setItem('currentPage', '1');
// タブを閉じると消える
// 4. Cookie
document.cookie = 'currentPage=1; path=/';
// サーバーにも送信される
特徴:
- メモリ:ページをリロードすると消える
- LocalStorage:最大5MB程度、永続的に保存
- SessionStorage:タブごとに独立、タブを閉じると消える
- Cookie:最大4KB、サーバーとの通信時に自動送信
2. URL(アドレスバー)による状態管理
https://example.com/blog/page/2/
↑
この部分が状態を表す
なぜURLで状態管理ができるのか:
-
ブラウザの仕組み
- URLはブラウザが管理する「現在地」情報
- 履歴API(History API)でURLを操作可能
- ブックマーク可能、共有可能
-
HTTPプロトコルの特性
- URLはリソースの識別子
/blog/page/2/は「2ページ目」というリソースを指す
// ブラウザの履歴APIを使った例
window.history.pushState({page: 2}, '', '/blog/page/2/');
// URLが変わるが、ページはリロードされない
3. サーバーサイドの状態管理
// データベース
const user = await db.user.findUnique({
where: { id: userId }
});
// セッション(サーバー側)
req.session.currentPage = 2;
// キャッシュ
redis.set('user:123:page', '2');
Next.jsの静的サイト生成(SSG)での状態管理
SSGの仕組み
Next.jsのoutput: 'export'設定では、ビルド時にすべてのページを静的HTMLとして生成します。
// next.config.ts
const nextConfig = {
output: 'export', // 静的サイト生成モード
}
ビルド時の処理フロー
1. ビルド開始
↓
2. generateStaticParams()を実行
- 必要なページ数を計算
- [1, 2, 3...]のパスを生成
↓
3. 各ページのHTMLを生成
- /blog/page/1/index.html
- /blog/page/2/index.html
↓
4. 静的ファイルとして出力
なぜ内部ストレージは不要なのか
静的サイトでは、各ページが独立したHTMLファイルとして存在します。
out/
├── blog/
│ ├── page/
│ │ ├── 1/
│ │ │ └── index.html ← 1ページ目の完全なHTML
│ │ └── 2/
│ │ └── index.html ← 2ページ目の完全なHTML
状態はURLそのものが表現:
/blog/page/1/→ 1ページ目を表示/blog/page/2/→ 2ページ目を表示
内部ストレージは不要で、URLとファイルシステムの対応だけで状態管理が完結します。
実装手順
1. ページネーション用の関数を追加
// src/lib/posts.ts
export const POSTS_PER_PAGE = 10;
export function getPostsWithPagination(page: number) {
const allPosts = getSortedPostsData();
const totalPosts = allPosts.length;
const totalPages = Math.ceil(totalPosts / POSTS_PER_PAGE);
// ページ番号の検証
if (page < 1 || page > totalPages) {
return {
posts: [],
currentPage: page,
totalPages,
hasNextPage: false,
hasPrevPage: false,
};
}
// 該当ページのデータを切り出し
const startIndex = (page - 1) * POSTS_PER_PAGE;
const endIndex = startIndex + POSTS_PER_PAGE;
const posts = allPosts.slice(startIndex, endIndex);
return {
posts,
currentPage: page,
totalPages,
hasNextPage: page < totalPages,
hasPrevPage: page > 1,
};
}
2. 動的ルートの実装
// src/app/blog/page/[pageNum]/page.tsx
// ビルド時に全ページ分のパスを生成
export async function generateStaticParams() {
const totalPages = getTotalPages();
const pages = [];
for (let i = 1; i <= totalPages; i++) {
pages.push({ pageNum: i.toString() });
}
return pages;
// これにより以下のHTMLが生成される:
// /blog/page/1/index.html
// /blog/page/2/index.html
}
export default async function BlogPage({ params }: PageProps) {
const pageNumber = parseInt(params.pageNum, 10);
const { posts, currentPage, totalPages } = getPostsWithPagination(pageNumber);
return (
<div>
<h1>記事一覧</h1>
<p>ページ {currentPage} / {totalPages}</p>
<ul>
{posts.map((post) => (
<li key={post.slug}>{post.title}</li>
))}
</ul>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
/>
</div>
);
}
3. Paginationコンポーネント
// src/components/Pagination.tsx
export default function Pagination({ currentPage, totalPages }: Props) {
// ページ番号の配列を生成(例:[1, 2, '...', 5, 6, 7, '...', 10])
const getPageNumbers = () => {
const delta = 2; // 現在のページの前後に表示するページ数
const range: number[] = [];
const rangeWithDots: (number | string)[] = [];
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages ||
(i >= currentPage - delta && i <= currentPage + delta)) {
range.push(i);
}
}
// 省略記号(...)を追加
let l: number | undefined;
range.forEach((i) => {
if (l) {
if (i - l === 2) {
rangeWithDots.push(l + 1);
} else if (i - l !== 1) {
rangeWithDots.push('...');
}
}
rangeWithDots.push(i);
l = i;
});
return rangeWithDots;
};
return (
<nav aria-label="ページネーション">
{/* 前へボタン */}
{currentPage > 1 ? (
<Link href={`/blog/page/${currentPage - 1}/`}>
← 前へ
</Link>
) : (
<span className="disabled">← 前へ</span>
)}
{/* ページ番号 */}
{getPageNumbers().map((pageNum, index) => {
if (pageNum === '...') {
return <span key={`dots-${index}`}>…</span>;
}
const page = pageNum as number;
if (page === currentPage) {
return <span key={page} aria-current="page">{page}</span>;
}
return (
<Link key={page} href={`/blog/page/${page}/`}>
{page}
</Link>
);
})}
{/* 次へボタン */}
{currentPage < totalPages ? (
<Link href={`/blog/page/${currentPage + 1}/`}>
次へ →
</Link>
) : (
<span className="disabled">次へ →</span>
)}
</nav>
);
}
状態管理パターンの比較
1. URLベース(今回採用)
/blog/page/2/
メリット:
- ブックマーク可能
- 共有可能(URLを送れば同じページが見れる)
- SEOに有利
- ブラウザの戻る/進むボタンが機能
デメリット:
- URLが変わるとページ全体がリロード(SPAでない場合)
2. クエリパラメータベース
/blog?page=2
メリット:
- 実装が簡単
- 動的に変更しやすい
デメリット:
- URLが美しくない
- 静的生成には向かない
3. JavaScriptの状態管理
const [currentPage, setCurrentPage] = useState(1);
メリット:
- 高速(ページリロードなし)
- 複雑な状態も管理可能
デメリット:
- リロードで状態が消える
- 共有不可
- SEOに不利
パフォーマンスの改善効果
Before(全記事表示)
- HTML サイズ: 約50KB(16記事)
- DOM ノード数: 約500個
- 初期レンダリング: 約200ms
After(10記事ずつ)
- HTML サイズ: 約30KB(10記事)
- DOM ノード数: 約300個
- 初期レンダリング: 約120ms
- 改善率: 約40%
トラブルシューティング
遭遇したエラーと解決方法
1. 動的パスの競合エラー
Error: You cannot use different slug names for the same dynamic path
原因:同じ階層に[slug]と[page]があった
解決:ディレクトリ構造を変更
blog/
├── [slug]/ # 記事詳細
└── page/ # ページネーション用
└── [pageNum]/
2. TypeScriptの型エラー
// エラー:rangeWithDots has implicit type 'any[]'
const rangeWithDots = [];
// 解決:明示的な型定義
const rangeWithDots: (number | string)[] = [];
まとめ
学んだこと
-
状態管理の本質
- 状態は「情報をどこに保存するか」の問題
- URL、メモリ、ストレージ、サーバーなど選択肢は多様
-
静的サイトでの状態管理
- URLとファイルシステムの対応で実現
- 内部ストレージは不要
- ビルド時にすべてのパターンを生成
-
Next.jsの強み
- generateStaticParamsで静的ページを量産
- URLベースの美しい状態管理
- SEOとパフォーマンスの両立
今後の拡張案
-
検索機能の追加
- タグによるフィルタリング
- 全文検索の実装
-
無限スクロール
- Intersection Observer APIの活用
- 段階的な記事読み込み
-
ユーザー設定の保存
- 1ページあたりの表示件数をLocalStorageに保存
- ダークモード設定の永続化