module Paginatable
extend ActiveSupport::Concern
MAX_PER_PAGE = 100
DEFAULT_PER_PAGE = 25
def paginate(relation)
page = params[:page].presence || 1
per_page = resolve_per_page
paged = relation.page(page).per(per_page)
set_pagination_headers(paged)
paged
end
private
def resolve_per_page
requested = params[:per_page].to_i
return DEFAULT_PER_PAGE if requested <= 0
[requested, MAX_PER_PAGE].min
end
def set_pagination_headers(paged)
response.headers["Total-Count"] = paged.total_count.to_s
response.headers["Total-Pages"] = paged.total_pages.to_s
response.headers["Per-Page"] = paged.limit_value.to_s
links = {
first: 1,
prev: (paged.prev_page if !paged.first_page?),
next: (paged.next_page if !paged.last_page?),
last: paged.total_pages
}
header = link_header_for(links)
response.headers["Link"] = header if header.present?
end
def link_header_for(links)
links.compact.map do |rel, page|
query = request.query_parameters.merge(page: page)
url = url_for(query.merge(only_path: false))
%(<#{url}>; rel="#{rel}")
end.join(", ")
end
end
class ArticlesController < ApplicationController
include Paginatable
def index
articles = Article.published
articles = articles.where(author_id: params[:author_id]) if params[:author_id].present?
articles = articles.order(published_at: :desc)
render json: paginate(articles)
end
def show
article = Article.published.find(params[:id])
render json: article
end
end
require "rails_helper"
RSpec.describe "Articles pagination", type: :request do
before { create_list(:article, 30, :published) }
it "exposes total count and per-page headers" do
get "/articles", params: { per_page: 10 }
expect(response).to have_http_status(:ok)
expect(response.headers["Total-Count"]).to eq("30")
expect(response.headers["Per-Page"]).to eq("10")
expect(JSON.parse(response.body).size).to eq(10)
end
it "includes a next link on a non-terminal page" do
get "/articles", params: { per_page: 10, page: 1 }
link = response.headers["Link"]
expect(link).to include('rel="next"')
expect(link).to include("page=2")
expect(link).not_to include('rel="prev"')
end
it "clamps oversized per_page requests" do
get "/articles", params: { per_page: 5000 }
expect(response.headers["Per-Page"]).to eq("100")
end
end
This snippet shows how a Rails JSON API can expose pagination purely through HTTP response headers instead of wrapping every payload in a { data, meta } envelope. The approach follows RFC 5988: a Link header carries first, prev, next, and last relations, while Total-Count and Per-Page headers let clients render page counts without a separate request. Keeping pagination in headers means the response body stays a clean JSON array, which is friendlier to generic hypermedia clients and to tools like GitHub's own API consumers.
The Paginatable concern centralizes the logic so no controller repeats it. paginate scopes an ActiveRecord relation with Kaminari's page and per, clamping per_page to a sane maximum so a client cannot request an unbounded page. It reads page and per_page from params, applies the relation, then delegates header construction to set_pagination_headers. That method writes Total-Count, Total-Pages, and Per-Page, then builds the Link header only for relations that actually exist — prev is omitted on the first page and next on the last, which is what well-behaved clients expect.
The private link_header_for helper walks a hash of relation-to-page-number pairs and renders each as <url>; rel="name". It reuses url_for with the current request.query_parameters merged with the target page, so filters and sort parameters survive across pages automatically. compact drops nil pages so boundary conditions collapse cleanly.
In ArticlesController, the index action is almost trivial: it builds a filtered, ordered relation and passes it through paginate, then renders the resulting page as a plain array. All the header wiring happens in the concern, so the controller expresses only intent.
The request spec verifies the contract that matters to clients: the Total-Count header reflects the full result set, and the Link header includes a rel="next" on a non-terminal page. Testing headers rather than body shape guards against regressions when the serializer changes. A pitfall worth noting is that Kaminari#total_pages triggers a COUNT query, so on very large tables a cursor-based scheme may be preferable; for bounded datasets this offset pagination is simple and cache-friendly.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
Share this code
Here's the card — post it anywhere.