ページネーションの設計は、単に「何件ずつ返すか」を決めるだけの話ではない。
最初は?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 が向いているのは、次のようなケースだ。
- データ量が小さい
- 深いページをあまり開かない
- 更新頻度が低い
- 実装の簡単さを優先したい
- ページ番号ジャンプが必要
逆に、大量データの全件同期や、頻繁に更新されるタイムラインでは避けたい。
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もフロントエンドもかなり実装しやすくなる。