StandardPagination
Shared pagination conventions for the estate's Rails apps, built on pagy 43.
Before this gem, sidekick-web, nutripod-web, fundbright-web and luminality-web each kept their own copy of the same helpers, and shared the same bugs:
-
Clients could set the page size on every list. Each app's
Pagy::OPTIONS[:max_limit] = 100became pagy 43.7'sclient_limit, which lets a client pick any page size up to 100 with?limit=, even where the call site fixed one. - Rows repeated or went missing between pages. Lists sorted only on a timestamp, so equal rows could land in a different order on the next page's query.
- Errors returned 500 or looped. JSON APIs had no pagy rescue, and an HTML redirect could lose filters or loop.
The gem provides one copy of each helper, and these fixes:
| Piece | What it does |
|---|---|
StandardPagination::Controller |
paginate and pagination_data. HTML pages keep the page size the call site sets, and JSON clients may choose one up to a cap. It adds an id tie-breaker and lets you opt in to rescue_pagination_errors. |
StandardPagination::Mcp.paginate |
The MCP list-tool helper every app's ApplicationTool carried a copy of. It keeps the same response shape. |
StandardPagination::StableOrder / Model#stable_order
|
Appends the primary key to an ORDER BY when that is safe. |
| Railtie | At boot, flags a leftover max_limit or client_limit in Pagy::OPTIONS. It raises in development and test, and logs elsewhere. |
Install
gem "standard_pagination", "~> 0.1"# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
include StandardPagination::Controller # includes Pagy::Method
rescue_from StandardError, with: :render_error # catch-alls first...
rescue_pagination_errors html: :redirect # ...then this, so it wins
end
# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
include StandardPagination::Model
endDelete max_limit (or client_limit) from config/initializers/pagy.rb. Leave raise_range_error out as well: paginate passes config.raise_range_error, which defaults to true.
# config/initializers/standard_pagination.rb (optional; these are the defaults)
StandardPagination.configure do |c|
c.default_limit = 20 # rows when a call site passes no limit:
c.max_limit = 100 # cap on a client-chosen page size
c.limit_key = "limit" # param a client sets it with; an Array accepts aliases, e.g. %w[limit per_page]
c.client_limit_formats = %i[json] # or :non_html for every non-HTML request
c.cursor_key = "cursor" # param paginate_cursor reads the cursor from
c.tiebreak = true
c.raise_range_error = true
c.strict_options = nil # nil = raise in dev/test, log elsewhere
endControllers
pagy, devices = paginate(Device.where(zone:).order(created_at: :desc), limit: 25)
render inertia: "factory/devices/index", props: {
devices: devices.map { DeviceSerializer.new(_1) },
pagination: pagination_data(pagy)
}pagination_data returns { page, pages, count, limit, from, to, previous, next }. previous and next are left out on the first and last page, as pagy's data_hash always did. It is the shape every app's PaginationMetadata TypeScript type already describes.
paginate options:
-
limit:is the page size. It defaults toconfig.default_limit. -
client_limit:lets the request set the page size throughconfig.limit_key. Pass an Integer cap,trueforconfig.max_limit, orfalseto turn it off. A request value that is junk, zero or negative falls back tolimit:. When omitted it falls back to:- the controller's
pagination_client_limit, if set; - otherwise
config.client_limit_formats: on for JSON and off for HTML by default.
- the controller's
An API base controller whose clients may send no Accept: application/json header (Rails treats those requests as HTML) should opt in once, instead of per call:
class Api::BaseController < ActionController::API
include StandardPagination::Controller
self.pagination_client_limit = true # or an Integer cap
end-
tiebreak:defaults toconfig.tiebreak. -
paginator:defaults to:offset.:countlessskips theCOUNT(*)on big append-only tables. Any other pagy option, such aspage_key:orcount:, passes through.
For several tables on one page, give each its own page_key::
pagy_new, new_apps = paginate(new_scope, page_key: "new_page")
pagy_done, done_apps = paginate(done_scope, page_key: "done_page")Errors
rescue_pagination_errors handles Pagy::OptionError, which includes its subclass Pagy::RangeError.
| Request | Out-of-range page | Any other bad option |
|---|---|---|
| JSON | 400 { error: "Page out of range", last_page: 4 }
|
400 { error: "Invalid pagination parameters", last_page: null }
|
HTML, html: :redirect
|
Redirects to the last page, keeping every other param | Redirects without the page and limit params; 400 if there are none to drop |
HTML, html: :not_found
|
404 | 404 |
HTML, html: :my_method
|
Calls my_method(exception)
|
Calls my_method(exception)
|
notice: sets the redirect's flash. Pass a String, or a Proc for I18n: notice: -> { t("flash.application.pagination_out_of_range") }.
Ordering
paginate and Mcp.paginate append the primary key in the direction of the last sort key:
ORDER BY occurred_at DESC -- before
ORDER BY occurred_at DESC, id DESC -- afterAn unordered relation gets ORDER BY id ASC. The relation is left alone when the key is already in the order, and in these unsafe cases:
- it has a
GROUP BY - it uses
DISTINCTwith a customSELECT - it has a custom
FROM - the model's primary key is composite
Outside a paginator (a query object, or an engine scope a host will page through), use Model.stable_order(...), for example AuditLog.where(...).stable_order(occurred_at: :desc).
Cursor pagination
For feeds and append-heavy APIs, paginate_cursor uses pagy's keyset paginator: no COUNT, no OFFSET, and rows inserted while a client pages don't shift or repeat what it sees.
pagy, events = paginate_cursor(Event.where(device:).order(occurred_at: :desc), limit: 50)
render json: { data: events.map { EventSerializer.new(_1) }, **cursor_data(pagy) }This is the one envelope for cursor-paged responses:
{ "data": [...], "next_cursor": "eyJ...", "has_more": true }- The client sends
next_cursorback as?cursor=(config.cursor_key).next_cursorisnullandhas_moreisfalseon the last page. - Order the set with hash or symbol orders (
order(occurred_at: :desc)), not SQL strings: pagy builds the cursor from those columns. The primary key is appended so the order is total. -
limit:,client_limit:andtiebreak:work as inpaginate. - A cursor the server didn't issue raises
Pagy::OptionError:rescue_pagination_errorsanswers 400 for JSON, and for HTML redirects without the cursor.
Use it for new feeds, and when an existing feed is reworked. Feeds that mobile clients already parse keep their current shape until their clients move.
MCP tools
# lib/mcp/tools/application_tool.rb
def paginate(scope, page: 1, per_page: DEFAULT_PER_PAGE)
StandardPagination::Mcp.paginate(scope, page:, per_page:, max_per_page: MAX_PER_PAGE)
endIt returns [records, { page:, per_page:, total_count:, total_pages: }], the shape MCP clients already parse. A GROUP BY relation counts its groups, and a page past the end returns no records.
Not in the gem
-
A shared React
<Pagination>component. It belongs in a frontend package, not a gem. -
Cursor pagination for MCP list tools.
Mcp.paginatekeeps the offset shape MCP clients already parse.
Development
bundle exec rspec # newest pagy
BUNDLE_GEMFILE=gemfiles/pagy-43.6.gemfile bundle exec rspec # oldest a consumer runs
bundle exec rubocop