Pagination Gem vs .limit()
When an API returns a list, you have two choices: cap the results with .limit(), or add a pagination gem. They solve different problems.
When .limit() Is Enough
If you just need to prevent a query from returning thousands of rows, .limit() does the job:
Message.order(created_at: :desc).limit(20)
This works fine when the frontend only needs to display a fixed number of records, like a "recent activity" feed, a dashboard widget.
The limitation: .limit() has no concept of pages, total count, or what comes next.
When You Need a Pagination Gem
Once the frontend needs to render page controls or navigate between pages, you need metadata alongside the data:
{
"data": [...],
"pagination": {
"page": 2,
"pages": 12,
"count": 235,
"next": 3,
"prev": 1
}
}
Pagination gem handles the LIMIT/OFFSET math and exposes these values for you.
Choosing a Gem
Three gems come up most often in Rails projects:
will_paginate was the original standard and is still found in many existing codebases. The API is straightforward, but the gem is no longer actively maintained.
Kaminari is widely used and integrates cleanly with ActiveRecord scopes. Most Rails projects you encounter will likely be using this.
Pagy is the modern recommendation for new projects. It is significantly faster and uses far less memory than both alternatives. For a JSON API, this is the clear choice.
Setting Up Pagy for an API
Install
# Gemfile
gem "pagy"
bundle install
Controller
Include Pagy::Method and use pagy(:offset, ...) for standard offset-based pagination:
class MessagesController < ApplicationController
include Pagy::Method
def index
@pagy, @messages = pagy(:offset, Message.order(created_at: :desc), limit: 20)
render json: {
data: @messages,
pagination: @pagy.data_hash
}
end
end
pagy returns two objects: the pagination metadata and the scoped collection. data_hash gives you a plain hash ready to serialize into the response.
The output looks like:
{
"count": 235,
"page": 2,
"limit": 20,
"pages": 12,
"prev": 1,
"next": 3
}
Capping per_page to Prevent Abuse
If you expose limit as a query param, always enforce a maximum. Without a cap, a client could request ?limit=10000 and hit your database hard.
def index
limit = params.fetch(:limit, 20).to_i.clamp(1, 100)
@pagy, @messages = pagy(:offset, Message.order(created_at: :desc), limit: limit)
render json: {
data: @messages,
pagination: @pagy.data_hash
}
end
clamp(1, 100) ensures the value stays between 1 and 100 regardless of what the client sends.