Introduction
APIs serving collections must paginate responses to avoid returning thousands of records in a single request. Rails has excellent pagination gems that integrate cleanly with API responses.
Key Concepts
- Offset Pagination: Uses
pageandper_pageparameters. Simple but slow for deep pages. - Cursor Pagination: Uses an opaque cursor pointing to the last item. Fast and consistent.
- Pagy: A fast, lightweight pagination gem with API-friendly features.
- Link Headers: Pagination metadata returned in HTTP headers following the RFC 5988 standard.
Real World Context
Every API endpoint that returns collections needs pagination. Without it, endpoints become slower as data grows, clients download unnecessary data, and servers waste memory. GitHub, Stripe, and Shopify all use pagination in their APIs.
Deep Dive
Offset Pagination with Pagy
ruby# Gemfile gem "pagy"
ruby# app/controllers/api/v1/products_controller.rb class Api::V1::ProductsController < ApplicationController include Pagy::Backend def index pagy, products = pagy(Product.all, items: params[:per_page] || 25) render json: { data: products, meta: { current_page: pagy.page, total_pages: pagy.pages, total_count: pagy.count, per_page: pagy.items } } end end
Pagy is significantly faster than Kaminari (15x) and WillPaginate (40x) in benchmarks. The meta block gives clients all the information they need for pagination controls.
Cursor Pagination
For large datasets, cursor pagination is more efficient:
rubydef index limit = (params[:limit] || 25).to_i.clamp(1, 100) products = Product.order(:id) if params[:after] products = products.where("id > ?", params[:after]) end products = products.limit(limit + 1) # Fetch one extra to check for next page has_next = products.size > limit products = products.first(limit) render json: { data: products, meta: { has_next_page: has_next, next_cursor: has_next ? products.last.id : nil } } end
Cursor pagination uses the last item's ID as a cursor. It doesn't suffer from the offset problem where deep pages require scanning many rows.
Common Pitfalls
- Not clamping per_page — Without limits, clients can request
per_page=100000and crash your server. - Using offset for large tables —
OFFSET 10000requires the database to scan 10,000 rows before returning results.
Best Practices
- Default to 25 items per page — A sensible default that balances payload size and request count.
- Always clamp the limit —
(params[:limit] || 25).to_i.clamp(1, 100)prevents abuse.
Summary
- Use Pagy for fast offset pagination with API-friendly metadata.
- Use cursor pagination for large datasets where offset becomes slow.
- Always clamp page size to prevent abuse.
- Include pagination metadata in the response for client navigation.
Code Examples
# Cursor pagination — efficient for large datasets
def index
limit = (params[:limit] || 25).to_i.clamp(1, 100)
scope = Product.order(:id)
scope = scope.where("id > ?", params[:after]) if params[:after]
items = scope.limit(limit + 1).to_a
has_next = items.size > limit
items = items.first(limit)
render json: {
data: items,
meta: { has_next_page: has_next, next_cursor: items.last&.id }
}
end