ページネーションの設計は、単に「何件ずつ返すか」を決めるだけの話ではありません。
最初は?page=2 や ?limit=20 くらいで十分に見える。
ただ、実際にAPIとして設計しようとすると、すぐにいろいろな問題が出てくる。
- どの順序で結果を固定するのか
- 次ページの境界を何で表すのか
- URLとして共有、再訪できるのか
- データ更新中に重複や取りこぼしが起きてもよいのか
- DBはそのクエリをどう実行するのか
- フロントエンドのキャッシュやブラウザバックとどう合わせるのか
つまりページネーションは、UI、API、DB、運用の境界にある設計テーマだと思います。
この記事では、ページネーションのクエリ設計をpage-based、offset-based、cursor-based、time-based に分けて整理してみる。
先に結論
個人的には、最初に考えるべき問いはこれだと思います。
その一覧で、ページ番号へ飛ぶ体験は本当に必要か。
必要ならpage / offset 系を検討する。
不要なら、まず cursor を検討する。
さらに「何ページ目か」より「いつからいつまでか」が意味を持つなら、time-based を前面に出す。
ざっくり使い分けると、こうなる。
| 方式 | 代表クエリ | 向いている場面 | 強み | 弱み |
|---|---|---|---|---|
| page-based | ?page=3&perPage=20 | 検索結果、管理画面、SEOを意識した一覧 | 人間にわかりやすい。URLとして共有しやすい | 深いページで重くなりやすい。更新中のデータに弱い |
| offset-based | ?offset=40&limit=20 | 小〜中規模一覧、単純なページ送り | 実装が簡単。任意位置へ移動しやすい | 大きな offset が重い。重複、取りこぼしが起きやすい |
| cursor-based | ?cursor=opaque&limit=20 | タイムライン、通知、チャット、同期API、無限スクロール | 深いページでも性能が安定しやすい。更新中のデータに比較的強い | 任意ページジャンプが難しい。カーソル設計が必要 |
| time-based | ?since=...&until=...&limit=50 | ログ、イベント、監査履歴、メッセージ履歴 | 時間範囲で探す要件と相性がよい | timestampだけでは境界が不安定になりやすい |
page を選ぶと、あとからつらくなることがある。
逆に、管理画面のように「10ページ目へ飛びたい」という体験があるなら、cursorだけでは扱いにくい。
ページネーションは、技術的に優れている方式を一つ選ぶというより、一覧の使われ方に合わせて選ぶものだと思います。
page-based pagination
page-based pagination は、外部仕様としてpage と perPage を受け取る方式です。
GET /articles?page=3&perPage=20
ユーザーから見ると一番わかりやすい。
「3ページ目を開く」「URLを共有する」「ブラウザバックで戻る」といった体験とも相性がよい。
検索結果やカテゴリ一覧、管理画面のテーブルなどでは、今でもかなり自然な選択肢になる。
ただし、実装上は多くの場合、内部で offset に変換される。
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 40;
つまり、APIとしては page-based でも、DBに対しては offset-based の性質を持つことが多い。
このため、深いページをたくさん掘るような使い方や、データが頻繁に追加・削除される一覧では、offset の弱点が表に出てくる。
page-based が向いているのは、次のようなケースです。
- ページ番号へ直接移動したい
- URLとして
?page=nを共有したい - SEOを意識した一覧ページにしたい
- データ量がそこまで大きくない
- 深いページを大量に巡回する使い方ではない
逆に、タイムラインや通知一覧のように「次を読む」だけでよいなら、page番号はなくても困らないことが多い。
offset-based pagination
offset-based pagination は、offset と limit をそのまま指定する方式です。
GET /articles?offset=40&limit=20
SQLにも素直に落とせる。
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 40;
実装が簡単で、考え方もわかりやすい。
小さな一覧や、社内管理画面ではこれで十分なことも多い。
ただ、offset は深くなるほど重くなりやすい。
LIMIT 20 OFFSET 100000 は「20件だけ返すから軽い」わけではありません。
DBは、100000件目までの位置を見つけたうえで、その次の20件を返す必要がある。
もちろんインデックスが効けばある程度は速くなるが、「スキップする行が増えるほど仕事が増える」という性質は残る。
もう一つの問題は、更新中のデータに弱いことです。
たとえば、1ページ目を見たあと、2ページ目を見る前に新しい記事が先頭に追加されたとする。
最初の1ページ目:
A B C D E
新しい記事 X が先頭に追加される:
X A B C D E
offset=5 で次を取る:
E ...
この場合、E が重複して見える可能性がある。
逆に、途中のデータが削除されると、まだ見ていないはずの1件を飛ばしてしまうこともある。
offset は「現在の並びの何番目か」を基準にしているため、並び自体が変わると境界がずれる。
offset-based が向いているのは、次のようなケースです。
- データ量が小さい
- 深いページをあまり開かない
- 更新頻度が低い
- 実装の簡単さを優先したい
- ページ番号ジャンプが必要
逆に、大量データの全件同期や、頻繁に更新されるタイムラインでは避けたい。
実務ケース:5000ページ目の注文一覧が遅い
たとえば、注文一覧APIに次のようなリクエストが来たとする。
GET /api/orders?page=5000&size=100
一見すると、100件の注文を取得するだけの普通のリクエストに見える。
しかし、page-based pagination は内部で offset に変換されることが多い。
page が1始まりなら、offset は次の式で求められる。
offset = (page - 1) × size
今回の場合は、次のようになる。
(5000 - 1) × 100 = 499900
SQLにすると、だいたいこのような形になる。
SELECT
id,
status,
created_at
FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 100
OFFSET 499900;
返すデータは100件ですが、DBはその100件を取得する前に、先頭から499900件分の位置をたどる必要がある。
そのため、同じsize=100 でも、ページが深くなるほど処理量が増えていく。
page=1 → OFFSET 0
page=100 → OFFSET 9900
page=1000 → OFFSET 99900
page=5000 → OFFSET 499900
これは、API上では page を使っていても、DB上では offset-based pagination の性質を持っているために起きる。
まず確認すること
この問題に遭遇したとき、すぐに cursor-based pagination へ変更すればよいとは限らない。
最初に確認したいのは、次のような点です。
- 実際にユーザーが5000ページ目を開いているのか
- バッチやクローラーが全ページを順番に巡回しているのか
- ページ番号を指定して直接移動する必要があるのか
- 一覧に検索条件や期間指定を追加できないか
ORDER BYに対応するインデックスが存在するか- 正確な
totalCountが必要なのか
たとえば、人間が注文を探すために5000ページ目まで移動しているなら、ページネーション方式だけでなく、検索UIにも問題があるかもしれない。
注文番号、顧客名、ステータス、作成日時などで絞り込めるようにしたほうが、利用者にとってもDBにとっても扱いやすい。
一方、バッチ処理が注文を全件走査しているなら、ページ番号へ移動する機能は必要ない。
その場合は、cursor-based pagination のほうが要件に合っている。
cursor-based paginationへ変更する
注文一覧を次の順序で取得するとする。
ORDER BY created_at DESC, id DESC
前回取得した最後の注文が、次の値だったとする。
{
"createdAt": "2026-07-01T10:00:00Z",
"id": 12345
}
次のページは、この値より後ろにある注文を取得する。
SELECT
id,
status,
created_at
FROM orders
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 100;
APIでは、境界値を cursor として渡す。
GET /api/orders?limit=100&cursor=opaqueCursor
offset のように「先頭から何件飛ばすか」を指定するのではなく、「どの注文の続きから取得するか」を指定する。
対応するインデックスも、並び順に合わせて用意する。
CREATE INDEX idx_orders_created_at_id
ON orders (created_at DESC, id DESC);
これにより、5000ページ分読み進めたあとでも、毎回499900件をスキップする必要はなくなる。
ページ番号ジャンプが必要な場合
cursor-based pagination では、「5000ページ目へ直接移動する」という操作は難しくなる。
そのため、ページ番号ジャンプが本当に必要な画面では、page-based pagination を維持する選択もある。
その場合は、次のような対策を検討する。
- 検索条件や期間指定で、先に対象件数を絞る
- 移動できる最大ページ数を制限する
- 古いデータを年月単位で分ける
- 深いページへのアクセスを計測する
ORDER BYとインデックスが合っているか確認する- 全件取得は非同期エクスポートとして分離する
たとえば、管理画面では page-based pagination を維持し、バッチや外部連携用のAPIでは cursor-based pagination を使うこともできる。
# 管理画面用
GET /api/orders?page=10&size=100
# バッチ、同期処理用
GET /api/order-exports?cursor=opaqueCursor&limit=1000
同じデータを扱っていても、利用目的によってページネーション方式を分けてよい。
実行計画で確認する
深いページが遅いときは、推測だけで判断せず、実行計画を確認したい。
EXPLAIN (ANALYZE, BUFFERS)
SELECT
id,
status,
created_at
FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 100
OFFSET 499900;
見るべきなのは、最終的に返された100行だけではありません。
- 何行を走査したか
- どのインデックスを使ったか
- Sortが発生していないか
- Buffersがどれくらい増えているか
- offsetが深くなるにつれて実行時間がどう変わるか
浅いページと深いページを比較すると、返却件数は同じでも、深い offset ほど多くの行を処理していることがわかる。
このケースからわかること
page=5000 が遅いという問題は、単にSQLを少し最適化すれば終わるとは限らない。
考えるべきなのは、次の3つです。
- DBが深い offset をどのように処理しているか
- 利用者が任意のページへ移動する必要があるか
- 一覧を全件走査している処理を別のAPIに分離できないか
ページネーション方式は、クエリの性能だけでなく、UIやバッチ処理、データの探し方まで含めて選ぶ必要がある。
cursor-based pagination
cursor-based pagination は、ページ番号ではなく「前回どこまで読んだか」を渡す方式です。
GET /articles?limit=20
GET /articles?cursor=opaqueCursor&limit=20
レスポンスでは、次ページ用のカーソルを返す。
{
"items": [
{ "id": "a1", "title": "Paginationのクエリ設計" }
],
"pageInfo": {
"nextCursor": "opaque-next",
"hasNextPage": true
}
}
cursor の本質は、結果集合の境界を一意に決める値です。
たとえばid が単調増加で、一覧の並びも id DESC でよいなら、こう書ける。
SELECT id, title, created_at
FROM articles
WHERE id < $1
ORDER BY id DESC
LIMIT 20;
offset と違って、「何件スキップするか」ではなく「どの値より後か」を条件にする。
そのため、深いページでも比較的安定した性能になりやすい。
ただし、実際の一覧ではid DESC だけで十分とは限らない。
よくあるのは created_at DESC の一覧です。
このとき、created_at だけを cursor にするのは危ない。
複数の記事が同じ created_at を持つことがあるからです。
ORDER BY created_at DESC
この並びは、人間には自然に見える。
しかし、同じcreated_at の記事が複数ある場合、その中の順序は安定しない。
ページ境界がその同値グループに当たると、重複や取りこぼしの原因になる。
そのため、実務ではcreated_at + id のような複合キーで一意な順序を作ることが多い。
SELECT id, title, created_at
FROM articles
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;
この場合、インデックスも並び順に合わせる。
CREATE INDEX idx_articles_created_at_id
ON articles (created_at DESC, id DESC);
フィルタ条件があるなら、それも含めて設計する。
CREATE INDEX idx_articles_tenant_created_at_id
ON articles (tenant_id, created_at DESC, id DESC);
cursor-based pagination で大事なのは、ソート順とカーソルとインデックスをセットで考えることです。
APIの形だけ cursor にしても、順序が不安定だったり、インデックスが合っていなかったりすると、期待したメリットは出にくい。
cursorはopaqueにしたい
cursor は、クライアントから見ると opaque な文字列にしておくのが扱いやすい。
つまり、クライアントが中身を解釈しない token として返す。
{
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA3LTA5VDAwOjAwOjAwWiIsImlkIjoiMTIzIn0="
}
中身としては、たとえば次のような情報を持たせる。
{
"createdAt": "2026-07-09T00:00:00Z",
"id": "123"
}
ただし、これをそのままURLに出すより、base64などでエンコードしておく。
こうしておくと、あとから cursor の中身を変更しやすい。
たとえば最初はid だけだった cursor を、後から created_at + id に変えたいことがある。
もしクライアントが cursor の中身に依存していると、変更が難しくなる。
opaque cursor にしておけば、クライアントはただ受け取った文字列を次のリクエストに渡すだけでよい。
また、filter や sort を変えられるAPIでは、cursor がどの条件に対するものかも重要になる。
GET /articles?tag=api&sort=createdAtDesc&cursor=...
この cursor は、tag=api かつ sort=createdAtDesc の結果集合に対する cursor です。
tag や sort が変わったあとに古い cursor を使うと、意味が壊れる。
そのため、cursor 内に filter/sort の情報を含めて検証するか、条件が変わったら cursor を無効にする設計にしたい。
time-based pagination
time-based pagination は、ページ送りというより「時間範囲で切る」方式です。
GET /events?since=2026-07-01T00:00:00Z&until=2026-07-31T23:59:59Z&limit=50
ログ、イベント、監査履歴、メッセージ履歴、時系列メトリクスなどでは、「何ページ目か」よりも「いつからいつまでか」のほうが重要になる。
この場合、time-based はかなり自然です。
SELECT id, event_type, created_at
FROM events
WHERE created_at >= $1
AND created_at < $2
ORDER BY created_at DESC, id DESC
LIMIT 50;
ただし、時間だけで完全なページングをしようとすると、境界で同じ timestamp を持つデータが問題になる。
そこで、実運用では time-based と cursor-based を組み合わせることが多い。
GET /events?since=2026-07-01T00:00:00Z&until=2026-07-31T23:59:59Z&cursor=opaque&limit=50
SQLにすると、時間範囲で絞りつつ、ページ境界は複合 cursor で見る。
SELECT id, event_type, created_at
FROM events
WHERE created_at >= $1
AND created_at < $2
AND (created_at, id) < ($3, $4)
ORDER BY created_at DESC, id DESC
LIMIT 50;
time-based は、検索条件としての時間と、ページ境界としての cursor を分けて考えると整理しやすい。
APIレスポンスはitemsとpageInfoを分ける
REST APIなら、個人的にはitems と pageInfo を分ける形が扱いやすい。
GET /articles?limit=20&cursor=opaque
{
"items": [
{
"id": "a1",
"title": "Paginationのクエリ設計",
"createdAt": "2026-07-09T00:00:00Z"
}
],
"pageInfo": {
"nextCursor": "opaque-next",
"prevCursor": "opaque-prev",
"hasNextPage": true,
"hasPreviousPage": false
}
}
items は実データ。
pageInfo はページング制御に必要な情報。
この2つを分けておくと、フロントエンド側も扱いやすい。
hasNextPage は、limit + 1 件取ると実装しやすい。
たとえば limit=20 なら、DBからは21件取る。
SELECT id, title, created_at
FROM articles
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 21;
21件取れたら、20件だけ返して hasNextPage: true にする。
20件以下なら hasNextPage: false にする。
この方法だと、別途 COUNT(*) を毎回打たなくても、次があるかを判断できる。
totalCount は便利ですが、巨大な一覧では重くなりやすい。
特に検索条件が複雑だったり、行数が多かったりすると、毎回正確な件数を返すだけでコストが大きくなる。
そのため、必要な画面だけtotalCount を返す、概算にする、非同期で返す、といった選択肢も考えたい。
GraphQLならConnection
GraphQLでは、Relay Connection 形式がよく使われる。
query {
articles(first: 20, after: "opaque-cursor") {
edges {
node {
id
title
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
REST の items + pageInfo と比べると少し複雑に見えるが、考え方は近い。
node: 実データcursor: その要素の位置pageInfo: 次や前があるか
GraphQLの場合は、前方向と後方向のページングも仕様として表現しやすい。
articles(first: 20, after: "cursor")
articles(last: 20, before: "cursor")
RESTで同じことをするなら、Stripeのように starting_after / ending_before のような名前を分けるのもわかりやすい。
GET /articles?limit=20&startingAfter=...
GET /articles?limit=20&endingBefore=...
後方向ページングは、SQLでは逆順に取ってからアプリ側で反転する実装にすることが多い。
現在の表示順がcreated_at DESC, id DESC なら、前のページを取るときは > 条件で上方向へ取りにいく。
SELECT id, title, created_at
FROM articles
WHERE (created_at, id) > ($1, $2)
ORDER BY created_at ASC, id ASC
LIMIT 20;
取得した配列をアプリ側で反転して、表示順を DESC に戻す。
フロントエンドではURLとキャッシュを分けて考える
フロントエンド実装では、ページネーション方式によって状態の持ち方が変わる。
page-based なら、URLに状態を寄せやすい。
/articles?page=3&tag=api&sort=createdAtDesc
この形なら、共有、再訪、ブラウザバックと相性がよい。
検索結果やカテゴリ一覧ではかなり扱いやすい。
一方、無限スクロールは cursor-based と相性がよい。
useInfiniteQuery({
queryKey: ["articles", filters, sort, pageSize],
queryFn: ({ pageParam }) =>
fetchArticles({
cursor: pageParam,
limit: pageSize,
filters,
sort,
}),
initialPageParam: null,
getNextPageParam: (lastPage) =>
lastPage.pageInfo.nextCursor ?? undefined,
});
ここで大事なのは、queryKey に一覧を定義する条件を全部入れることです。
["articles", filters, sort, pageSize]
cursor は、その queryKey の中での継続状態として扱う。
filters や sort が変わったら、別の一覧として扱う。
そうしないと、別条件の結果に対して古い cursor を使ってしまう。
ブラウザバックまで自然にしたい場合は、どこまでURLに残すかも考える必要がある。
page-based なら?page=3 をURLに置けばよい。
cursor-based の無限スクロールでは、URLに最後の cursor を置くのか、読み込み済みページ列を history state に持つのか、あるいは戻ったときに再取得するのかを決める。
ここはUIの期待値次第だと思います。
PostgreSQLで試すなら
理屈だけで終わらせるより、PostgreSQLで実際に試すとかなり理解しやすい。
たとえば、検証用に100万件くらいのデータを作る。
CREATE TABLE articles (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL DEFAULT 1,
title TEXT NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_articles_created_at_id
ON articles (created_at DESC, id DESC);
CREATE INDEX idx_articles_tenant_created_at_id
ON articles (tenant_id, created_at DESC, id DESC);
INSERT INTO articles (tenant_id, title, body, created_at)
SELECT
(random() * 9)::int + 1,
'title-' || gs,
repeat('x', 200),
now() - ((gs % 100000) || ' seconds')::interval
FROM generate_series(1, 1000000) AS gs;
ANALYZE articles;
offset-based は、浅い offset と深い offset を比べる。
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 0;
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000;
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 900000;
cursor-based は、複合 cursor を使う。
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title, created_at
FROM articles
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;
見るポイントは、実行時間だけではありません。
Buffers- scan type
- sort の有無
- 上流ノードの rows
- index が期待通り使われているか
深い offset では、返すのは20件でも、その位置に到達するまでに多くの行を処理していることが見えてくるはずです。
一方、cursor-based は、index 条件で開始位置を絞れるので、深い位置でも比較的一定に近い挙動になりやすい。
まとめ
ページネーションは、見た目以上に設計の影響が大きい。
page=2 は簡単ですが、それだけで十分とは限らない。
考えるべきことは、主に次のあたりです。
- ページ番号へ飛ぶ必要があるか
- 一覧の順序は一意に固定されているか
- データ更新中の重複や取りこぼしをどこまで許容するか
- DBが深いページをどう処理するか
- APIレスポンスで次に何をすればよいか伝えているか
- フロントエンドのURL、キャッシュ、ブラウザバックと合っているか
管理画面や検索結果なら、page-based は今でも自然な選択肢です。
ただし、大量データを順番にたどるAPI、タイムライン、通知、チャット、同期処理では cursor-based をまず検討したい。
ログや監査履歴のように時間範囲そのものが意味を持つなら、time-based と cursor-based を組み合わせるのが扱いやすい。
ページネーションは、単なるlimit と offset の話ではありません。
その一覧をどう読むのか、どの順序で固定するのか、次に何を返せばクライアントが迷わないのか。
そこまで含めて設計すると、APIもフロントエンドもかなり実装しやすくなる。