Every list endpoint starts innocent. GET /api/users, return them all, ship it. Then a customer signs up forty thousand users, someone asks to filter by role, and the front end wants the newest first. Now you need pagination, filtering, and sorting, and the way you wire them up decides whether the endpoint stays fast and safe or becomes a security hole that also happens to be slow. Here's how I build it, and where the Ransack gem helps.
The request shape
I accept explicit, named params instead of letting the client send anything that smells like SQL.
GET /api/users?role=admin&created_after=2026-01-01&sort=-created_at&page=2&per_page=25
A handy convention for sort: a leading minus means descending, so sort=-created_at is newest first and sort=created_at is oldest first. page and per_page handle paging, with per_page capped on the server so nobody asks for a million rows at once.
The hand-rolled version
class Api::UsersController < Api::BaseController
MAX_PER_PAGE = 100
SORTABLE = %w[created_at name email].freeze
def index
users = User.all
users = users.where(role: params[:role]) if params[:role].present?
users = users.where("created_at >= ?", params[:created_after]) if params[:created_after].present?
users = users.order(sort_clause)
per_page = [(params[:per_page] || 25).to_i, MAX_PER_PAGE].min
page = [(params[:page] || 1).to_i, 1].max
paged = users.limit(per_page).offset((page - 1) * per_page)
render json: {
data: paged.map { |u| UserSerializer.new(u) },
meta: {
current_page: page,
per_page: per_page,
total_count: users.count,
total_pages: (users.count.to_f / per_page).ceil
}
}
end
private
def sort_clause
field = params[:sort].to_s.delete_prefix("-")
field = "created_at" unless SORTABLE.include?(field)
direction = params[:sort].to_s.start_with?("-") ? :desc : :asc
{ field => direction }
end
end
It reads as a lot, but it's doing four jobs: filter, sort, page, and report back where you are.
The three things that actually matter
Whitelist the columns people can sort and filter by. This is the big one. If you drop params[:sort] straight into order(...), you've handed the client an SQL injection vector and let them sort by some unindexed column that drags the database to its knees. So I keep a SORTABLE allowlist and fall back to a sane default when the field isn't on it. Same thinking for filters: only known params get applied, everything else is ignored.
Cap per_page. Without a ceiling, per_page=1000000 either runs your app out of memory or makes Postgres sweat. Clamp it server side and move on.
Index what you filter and sort by. Here that's role and created_at. Without indexes, every page is a sequential scan, and sorting by an unindexed column means a full sort on every single request. Pagination doesn't save you from a missing index, it just hides the cost until the table grows.
The metadata
The response splits into data and meta. data is the page of records. meta tells the client where it is: current page, per page, total count, total pages. The front end needs that to draw "page 3 of 47" and to know when to stop. On large endpoints I sometimes drop total_count, because counting every matching row on each request gets expensive, and I return a next_cursor instead. Which brings up the real decision.
Offset versus cursor
Offset pagination is what LIMIT and OFFSET give you, and what most gems do by default. It's simple and it lets you jump straight to any page. Two problems show up at scale. OFFSET 1000000 still makes Postgres walk and throw away a million rows before it returns yours, so deep pages crawl. And if rows get inserted or deleted while someone is paging, the window shifts under them and they see the same record twice or miss one entirely.
Cursor pagination, also called keyset pagination, uses a stable pointer instead of a row count. You ask for WHERE created_at < ? ORDER BY created_at DESC LIMIT 25 and hand back the last value you saw as the next cursor. It stays fast at any depth because it seeks straight into the index instead of counting past rows, and it's stable when data changes underneath. The tradeoff is you only get next and previous, not "jump to page 47." I use offset for admin tables and small lists, and cursor for big feeds and exports.
Where Ransack comes in
For paging I reach for Pagy, which is tiny and fast, or Kaminari if I want the more featureful one. For filtering and sorting, Ransack is the usual pick:
# Gemfile: gem "ransack"
def index
q = User.ransack(params[:q]) # ?q[role_eq]=admin&q[created_at_gteq]=2026-01-01
q.sorts = params[:sort] || "created_at desc"
users = q.result.page(params[:page]).per(per_page) # with Kaminari
# render data + meta
end
You get a whole query language for free. role_eq for equality, name_cont for "contains", created_at_gteq for a date floor, sorting through q.sorts, even filtering across associations. For an admin screen with a dozen filter combinations, it saves a real amount of code.
There's a catch worth saying out loud, because it bit a lot of people. By default Ransack exposes your whole schema to the client. Every column becomes queryable, which is both a data leak and a way to run slow queries against unindexed columns. Modern Ransack makes you opt in:
class User < ApplicationRecord
def self.ransackable_attributes(_auth = nil)
%w[role created_at name email]
end
def self.ransackable_associations(_auth = nil)
[]
end
end
That's the exact same whitelist idea from the hand-rolled version, just expressed through Ransack's hooks. For a small public API I usually prefer explicit params, because the attack surface is smaller and I control every query. For a big internal admin, Ransack earns its place.
The short version
Accept explicit params, whitelist the columns you sort and filter on, cap per_page, and index whatever you filter or sort by. Return data plus meta. Use offset pagination for small and admin lists, cursor pagination for large feeds. Reach for Pagy or Kaminari to page and Ransack to filter, and whatever you do, lock Ransack down with ransackable_attributes so it isn't quietly exposing your whole database.
Top comments (0)