はじめに
Webサイトで一覧ページを作ると、だいたい登場するのがページネーションです。
「前へ / 次へ」「1 2 3 …」「もっと見る」など見た目は似ていても、裏側(APIやDB)の実装方式はいくつかあります。
この記事では、実務でよく使うページネーションを次の3つに分けて整理します。
- オフセットリミット形式(OFFSET/LIMIT)
- ページ指定方式(page=1 のような指定)
- カーソル形式(cursor / nextToken)
「どれが正解」ではなく、用途・データ規模・UI・運用で選ぶのがポイントです。
全件取得してプログラム側で表示件数を制御する方法と、ページが切り替わるごとにSQLを投げて取得する方法がありますが、SQLで制御する方法がおすすめのため、今回はその想定です。
オフセットリミット形式
概要
SQLの OFFSET と LIMIT を使う、いちばん直感的な方式です。
- 例:
?offset=20&limit=10(3ページ目を取るイメージ)
特徴
メリット
- 実装が簡単(フロントもバックエンドも分かりやすい)
- 任意ページにジャンプしやすい(ページ番号UIと相性◎)
- 管理画面など「データ量がそこまで多くない」場面で便利
デメリット
- 大きいoffsetほど遅くなりやすい(DBが前から数えて捨てることが多い)
- 途中でデータが増減すると、重複/抜けが起きやすい
- 例:2ページ目を見てる間に先頭に新着が入る → 3ページ目で同じレコードが出たり、逆に飛んだり
使うときのコツ
- 必ず安定した並び順(ORDER BY)を入れる(idやcreated_atなど)
-
created_atだけだと同時刻があり得るので、(created_at, id) のようにタイブレークを持つと安全
ページ指定方式
概要
ユーザーにとって分かりやすい「ページ番号」をそのままAPIに渡す方式です。
- 例:
?page=3&perPage=10
内部的には多くの場合、次のように変換されます。
offset = (page - 1) * perPagelimit = perPage
特徴
メリット
- URLが読みやすい(SEO・共有にも向く)
- UIが作りやすい(「1 2 3 …」をそのまま表現)
- 総件数(total)と組み合わせて「全nページ」を出せる
デメリット
- 本質的にはOFFSET/LIMITと同じ課題(大きいページで遅い/重複や抜け)
-
totalを出すためにCOUNT(*)が必要になりがち(重いことがある) - “最後のページ” がデータ増減でズレる(ユーザー体験がブレる)
向いている場面
- 記事一覧など「ページ番号で辿りたい」UI
- データが増え続けるが、規模がそこまで巨大ではない
- 管理画面・検索結果など「総件数が必要」な業務UI
SQLのイメージ
SELECT id, name, created_at
FROM items
ORDER BY created_at DESC, id DESC
OFFSET 20
LIMIT 10;
カーソル形式
概要
「次に取得すべき位置」を表す**カーソル(トークン)**を使う方式です。
無限スクロールや「もっと見る」と相性が良く、近年のAPIではよく見かけます。
- 例:初回
GET /items?limit=20 - レスポンスに
nextCursorが返る - 次回
GET /items?limit=20&cursor=xxxxx
カーソルの中身は実装次第ですが、よくあるのは以下です。
- 最後に返した要素の
(created_at, id)をエンコードしたもの - DBの主キーid
- サービス独自の
nextToken
特徴
メリット
- 大量データでもスケールしやすい(OFFSETのように後ろへ行くほど遅くなりにくい)
- データ増減があっても比較的安定(「続きを取る」動きに強い)
- 無限スクロールのUXと相性◎
デメリット
- 任意ページにジャンプしづらい(「10ページ目へ」は苦手)
- フロント側が「トークン管理」をする必要がある
- “前のページに戻る” を自然に作るのが少し難しい(prevCursor設計が必要)
実装の重要ポイント
- ソートキーは一意になるようにする
- 例:
created_at DESC, id DESCのセット
- 例:
- クエリは「最後に見た値より小さいもの」みたいに絞る
- 例:(概念)
(created_at, id) < (:lastCreatedAt, :lastId)
- 例:(概念)
- カーソルは改ざん防止のために署名したり、少なくとも推測しづらい形にするケースが多い
SQLのイメージ
タプル比較が使えるDBの場合(PostgreSQLなど)
-- 初回
SELECT id, name, created_at
FROM items
ORDER BY created_at DESC, id DESC
LIMIT 10;
-- 2回目以降
SELECT id, name, created_at
FROM items
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 10;
タプル比較が使えないDBの場合
SELECT id, name, created_at
FROM items
WHERE created_at < :last_created_at
OR (created_at = :last_created_at AND id < :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 10;
Tips
1) UIで方式を選ぶと失敗しにくい
- ページ番号(1 2 3 …)が欲しい → ページ指定方式(またはOFFSET/LIMIT)
- 無限スクロール / もっと見る → カーソル形式
2) total(総件数)は「本当に必要?」を先に考える
COUNT(*) を毎回やると辛いことがあるので、必要なら:
- キャッシュする
- 検索条件によっては概算にする
- 「次へがあるか」だけ返す(hasNext)に寄せる
などの逃げ道を用意すると運用が楽です。
3) “重複/抜け” は仕様で許容するか、強く防ぐか
- タイムライン系は多少のズレを許容しがち(UX優先)
- 請求や在庫などはズレが致命的(正確性優先)
→ 後者はカーソル形式 + 並び順の厳密化、またはスナップショット(検索時点固定)などを検討
4) インデックスが勝敗を分ける
どの方式でも、結局は ORDER BY と WHERE に効くインデックスが重要です。
特にカーソル形式は (created_at, id) の複合インデックスが効くと強いです。
最後に
ページネーションは「UIの部品」に見えて、裏側では
- 性能(後ろのページの遅さ)
- 体験(重複/抜け、戻る、ジャンプ)
- 運用(totalの扱い、キャッシュ、仕様の一貫性)
が全部絡む、地味に奥が深いテーマです。
迷ったらざっくりこの指針がおすすめです。
- 管理画面・小規模データ:ページ指定(OFFSET/LIMITでもOK)
- フィード・タイムライン・大量データ:カーソル形式
- 総件数とページ番号が必須:ページ指定(ただしCOUNTのコストを意識)