API Versioning, Serialization, and Pagination in Rails
Objective
Three unrelated-looking decisions — how you version a route, how you turn an
ActiveRecord object into JSON, and how you page through a collection — are
actually the same decision made three times: what contract am I willing to
break, and when. Get versioning wrong and a client's mobile app silently
breaks on your next deploy. Get serialization wrong and you either leak a
column you didn't mean to expose or eat an N+1 per response. Get pagination
wrong and a client "misses" rows every time someone inserts a record between
two of their page requests — a bug that never shows up in a demo with 20 seed
rows and shows up constantly on a table with 20 million. This concept is about
making those three decisions on purpose, with the actual current state of the
Ruby ecosystem (not five-year-old blog folklore) behind each choice —
active_model_serializers' own README, for instance, says its maintainers
have largely moved on and points readers at alternatives, which is a fact
worth knowing before you bundle add it in 2026.
Use Cases
- Standing up
/api/v1/...for a mobile client you don't control the release cadence of, and needing a real answer for what happens when/api/v2ships. - Reviewing a PR that adds
render json: @user.as_json(or a Jbuilder partial that touches an association inside a loop) and needing to know which failure mode — leaked attribute vs. N+1 — you're actually reviewing for. - Choosing whether a new internal admin index page needs Kaminari's
page/per(simple, page numbers, small table) or whether a public, high-write, infinite-scroll feed needs cursor pagination instead. - Deciding whether a growing set of API responses justifies pulling in a dedicated serializer gem, or whether Jbuilder templates are still the right amount of machinery for the team's size.
- Auditing an existing API for a
Gemfileline pinningactive_model_serializersand deciding whether that's a live risk or a stable-enough dependency to leave alone.
Deep Dive
URL-path versioning
The common shape: a namespaced route plus a namespaced controller.
plaintext# config/routes.rb namespace :api do namespace :v1 do resources :orders, only: %i[index show] end namespace :v2 do resources :orders, only: %i[index show] end end
plaintext# app/controllers/api/v1/orders_controller.rb module Api module V1 class OrdersController < Api::BaseController def show render json: OrderSerializerV1.new(order).as_json end end end end
GET /api/v1/orders/42 and GET /api/v2/orders/42 are two different URLs, so
every layer that caches by URL — a CDN, Rack::Cache, a browser — does the
right thing automatically. It's also trivially debuggable: curl https://api.example.com/api/v2/orders/42 shows exactly what a client sees,
no headers to remember. The cost shows up later: the version lives in every
client's hardcoded base URL, in every internal doc, in every log line's path —
bumping it is not a header flag, it's a coordinated client migration.
Header-based (media-type) versioning
Rails routing supports arbitrary request-based constraints — a class (or
lambda) with a matches?(request) method, as documented for the general
constraints mechanism in the Routing Guide (its own example is IP allow-listing,
but the mechanism is the same one used for version negotiation):
plaintext# app/constraints/api_version_constraint.rb class ApiVersionConstraint def initialize(version:) @version = version end def matches?(request) request.headers["Accept"]&.include?("application/vnd.myapp.v#{@version}+json") end end
plaintext# config/routes.rb namespace :api, path: "api" do scope module: :v1, constraints: ApiVersionConstraint.new(version: 1) do resources :orders, only: %i[index show] end scope module: :v2, constraints: ApiVersionConstraint.new(version: 2) do resources :orders, only: %i[index show] end end
Now GET /api/orders/42 is a single URL for every version — which is the
selling point for REST purists (the resource has one identity; the
representation varies) and the problem for everyone else. You cannot curl
it without remembering to set -H "Accept: application/vnd.myapp.v2+json",
so every support ticket and every quick manual check gets harder. Worse: a
cache keyed on URL alone (most CDNs, by default) cannot tell v1 and v2 apart
and will happily serve one client's cached v1 response to a v2 request unless
you explicitly configure Vary: Accept — a header most teams forget to set
and even more CDNs don't honor well.
Serialization: Jbuilder vs. plain Ruby objects vs. a dedicated gem
Jbuilder ships with Rails and lives in the view layer — it's a template, not a class:
plaintext# app/views/api/v1/orders/show.json.jbuilder json.id order.id json.total order.total_cents json.status order.status json.line_items order.line_items do |item| json.sku item.sku json.quantity item.quantity end
It's flexible (full Ruby in a template, conditionals, partials) and requires
no new dependency or per-model class. The cost is exactly that flexibility:
nothing stops json.line_items order.line_items do |item| from calling
item.discounts.where(...) inside the block — a query method called once per
loop iteration is the same N+1 shape covered in the n-plus-one-and-query-methods-in-models
sibling concept, except here it's hidden inside a template instead of a model
method, which makes it easier to miss in review.
A plain PORO serializer — no gem, just a class with an explicit attribute list:
plaintextclass OrderSerializer def initialize(order) @order = order end def as_json(*) { id: @order.id, total: @order.total_cents, status: @order.status, line_items: @order.line_items.map { |i| { sku: i.sku, quantity: i.quantity } } } end end
Zero dependencies, explicit, easy to unit test in isolation. It's also one more file per model and gives you nothing for free — no root-key convention, no association caching, no benchmarked JSON generation path. It's the right default until the number of serializers or the performance requirement grows enough to want a shared abstraction.
Dedicated serializer gems — and here the ecosystem has actually moved.
active_model_serializers (AMS) is the name most tutorials from the 2015-2018
era reach for, but its own README says plainly: "Almost none of the
maintainers from 0.8, 0.9, or earlier 0.10 are still working on AMS," its
last tagged release is 0.10.0.rc1 from April 2015, and the README's
own "Alternatives" section points readers at Blueprinter and Alba. That's not
the same as "abandoned" — the repo still receives sporadic commits (most
recently in late 2025) and issues still get triaged — so calling it dead would
overstate it; but calling it "the standard" in 2026, which is what most
blog posts still do, is simply out of date. Treat new dependence on AMS as a
decision that needs its own justification, not a safe default.
Two gems that are both currently active (commits within days of each other as of writing, both with real adoption — Blueprinter ~1.3k GitHub stars, Alba ~1.2k):
plaintext# Blueprinter — github.com/procore-oss/blueprinter class OrderBlueprint < Blueprinter::Base identifier :id fields :total_cents, :status association :line_items, blueprint: LineItemBlueprint end OrderBlueprint.render(order)
plaintext# Alba — github.com/okuramasafumi/alba class OrderResource include Alba::Resource attributes :id, :status attribute :total_cents many :line_items, resource: LineItemResource end OrderResource.new(order).serialize
A third option worth knowing about specifically for raw throughput is
Panko (panko_serializer), which is explicitly built for speed — it uses
Oj for JSON generation and precomputes serialization metadata ahead of time
rather than at request time:
plaintextclass OrderSerializer < Panko::Serializer attributes :id, :total_cents, :status has_many :line_items, serializer: LineItemSerializer end render json: Panko::ArraySerializer.new(orders, each_serializer: OrderSerializer).to_json
Panko has a smaller community than Blueprinter or Alba (roughly 600 GitHub stars vs. 1.2k+) — worth weighing if you value a bigger pool of Stack Overflow answers and contributors over raw benchmark numbers.
All three gems share the same trade-off against Jbuilder: an explicit,
declared attribute list per class means a reviewer (and the gem itself) can
see exactly what's exposed, and association declarations are a natural point
to remember to includes/preload upstream — but it's another file, another
class name, and another thing to keep in sync when a column gets renamed.
Pagination: offset vs. cursor (keyset)
Offset-based pagination — the familiar page/per_page — via Kaminari:
plaintextUser.order(:id).page(params[:page]).per(25)
or via Pagy, which markets itself specifically on being lighter than the alternatives — its own docs claim roughly 40x faster, 36x less memory, and 35x simpler object allocation than competing gems in its benchmark, for the same offset-pagination job:
plaintext# app/controllers/application_controller.rb include Pagy::Method # in a controller action @pagy, @orders = pagy(:offset, Order.order(:id))
Both compute LIMIT/OFFSET under the hood. The correctness problem with
OFFSET is independent of which gem generates it: a row inserted or deleted
before the current offset shifts every page after it. If a client fetches
page 1 (rows 1-25), someone deletes row 3, and the client fetches page 2, row
26 has shifted into what page 1 would now return — the client silently skips
one row it never saw. Insert instead of delete, and a row can be duplicated
across two page requests. Neither gem can fix this; it's inherent to OFFSET
against a table that changes between requests.
Cursor (keyset) pagination avoids that by not counting rows at all — it filters on the last-seen value of a stable, ordered column:
plaintext-- the manual version of what keyset pagination does SELECT * FROM orders WHERE id > :last_seen_id ORDER BY id LIMIT 25;
Pagy ships this as a first-class paginator, described in its own docs as "the fastest technique":
plaintext@pagy, @orders = pagy(:keyset, Order.order(:id))
Because each page is defined by "give me rows after cursor X," a row inserted
or deleted elsewhere in the table cannot shift what a page returns — the
result set for a given cursor is stable regardless of concurrent writes. What
it gives up is arbitrary navigation: there is no WHERE id > :cursor_for_page_5
without having walked pages 1 through 4 first, so "jump to page 5" or "show
page count: 40" — both trivial with OFFSET — aren't available with keyset
pagination.
A decision framework
- URL-path vs. header versioning: default to URL-path. It's cache-friendly
by construction, curl-able, and legible in every log line — properties that
matter for any API with external consumers or an on-call rotation debugging
it at 2am. Reach for header-based versioning only when you're already
committed to strict media-type-driven REST (a spec like JSON:API, or a
client ecosystem that already negotiates content types) and you're prepared
to own
Varyheaders correctly at every caching layer. - When a dedicated serializer gem earns its complexity: when a codebase has enough serializers that a shared convention (association declarations, consistent null handling, one place to change the JSON shape globally) beats N slightly-different Jbuilder templates or POROs — or when serialization time is a measured, real cost in a hot endpoint, which is when Panko's Oj-backed speed becomes worth its smaller community. For a handful of endpoints on a small team, plain Jbuilder or a PORO is not under-engineering — it's the right amount of machinery.
- When cursor pagination is worth losing "jump to page 5": any endpoint that's paginated by an end user scrolling forward through a feed (infinite scroll, a mobile timeline, a webhook/event log a client polls incrementally) where correctness under concurrent writes matters more than random access. Keep offset pagination for admin backoffice tables and anywhere a human actually types a page number or needs a total-page-count UI — that's exactly the interaction keyset pagination can't serve.
Trade-offs
- Header versioning is invisible to URL-keyed caches. A CDN or
Rack::Cachelayer that doesn'tVaryonAcceptwill serve a v1 response to a v2 request (or the reverse) because, from the cache's point of view,GET /api/orders/42is a single cache key regardless of headers.plaintext# Without Vary: Accept, a shared cache can't tell these apart: # GET /api/orders/42, Accept: application/vnd.myapp.v1+json # GET /api/orders/42, Accept: application/vnd.myapp.v2+json - Jbuilder's flexibility is also its footgun. Nothing stops a query method
from being called inside a loop in the template — the same N+1 shape as
calling a query method from an instance method (see the
n-plus-one-and-query-methods-in-modelssibling concept), except it's less visible because it's in a.jbuilderfile a code reviewer may skim past rather than a model method they scrutinize.plaintextjson.array! @orders do |order| json.id order.id # Fires once per order unless `line_items` was eager-loaded upstream: json.line_item_count order.line_items.where(refunded: false).count end active_model_serializers's status is genuinely ambiguous, not settled. It is not archived, and it still gets occasional commits and issue activity — but its last tagged release predates most currently-supported Rails versions, and its own maintainers' README says they've largely stopped active development and point elsewhere. Don't treat either "it's dead" or "it's fine, everyone uses it" as settled fact — check its issue tracker and commit history yourself before depending on it for a new project.- A dedicated serializer or paginator gem is a dependency with its own upgrade cadence — Blueprinter, Alba, and Panko are all actively maintained today, but "actively maintained today" is a snapshot, not a guarantee; the same AMS history is the reminder that "the current recommended gem" and "the gem this codebase will still be comfortably on in five years" are not the same claim.
- Keyset pagination requires a genuinely unique, stable, indexed order
column — a non-unique order column produces silently wrong pages, not an
error:plaintext
# BUG: created_at has ties (e.g. bulk-imported rows with identical timestamps), # so rows sharing a timestamp can be skipped or repeated across pages. @pagy, @orders = pagy(:keyset, Order.order(:created_at)) # Correct: order (and index) on a column guaranteed unique, like id, # or a compound (created_at, id) tiebreak. @pagy, @orders = pagy(:keyset, Order.order(:id)) - Offset pagination's
COUNT(*)cost is real on large tables — computingtotal_pagesmeans aCOUNTquery on every request unless you opt into a countless/approximate mode (Pagy offerscountless/COUNTISHvariants specifically to avoid this); the naivepage/percall pays that cost by default.