ruby 82 lines · 3 tabs

Cursor-Style Pagination With RFC 5988 Link Headers in Sinatra

Shared by codesnips Jul 2026
3 tabs
require 'uri'
require 'rack/utils'

module PaginationLinks
  module_function

  def build(request_url, page:, per_page:, total:)
    last_page = [(total.to_f / per_page).ceil, 1].max
    rels = {}
    rels[:first] = 1
    rels[:last]  = last_page
    rels[:prev]  = page - 1 if page > 1
    rels[:next]  = page + 1 if page < last_page

    rels.map { |rel, target| %(<#{page_url(request_url, target, per_page)}>; rel="#{rel}") }
        .join(', ')
  end

  def page_url(request_url, page, per_page)
    uri = URI.parse(request_url)
    params = Rack::Utils.parse_nested_query(uri.query)
    params['page'] = page
    params['per_page'] = per_page
    uri.query = Rack::Utils.build_nested_query(params)
    uri.to_s
  end
end
3 files · ruby Explain with highlit

This snippet shows how a Sinatra JSON endpoint exposes offset-based pagination while advertising navigation through the standard Link header defined by RFC 5988, so clients can follow next, prev, first, and last relations without guessing URL shapes. The core idea is that pagination metadata belongs in HTTP semantics rather than being buried in the response body, which keeps the payload a clean array and lets generic HTTP clients discover navigation automatically.

The LinkHeader helper module builds the header value. PaginationLinks.build computes which relations are valid given page, per_page, and total, then constructs absolute URLs by cloning the current request URI and rewriting only the page query parameter. Using URI and Rack::Utils.build_nested_query here avoids brittle string concatenation and correctly re-encodes existing filters, so a request like ?status=active&page=2 keeps its filter across every emitted link. The relations are assembled into the comma-separated <url>; rel="name" grammar the RFC mandates.

In app.rb, the /articles route treats page and per_page as untrusted input: clamp bounds per_page to a sane ceiling and both values are floored to 1, which prevents a client from requesting a million rows or a negative offset. The route counts the total once, fetches a single window with limit/offset via Sequel, and then delegates to the helper to set both the Link header and a convenience X-Total-Count header. Returning the collection as a bare JSON array keeps the contract simple, and the total is derived server-side so last always points somewhere real.

The trade-off with offset pagination is that deep pages get slower as the offset grows and rows shifting between requests can cause skips or duplicates; for large or write-heavy datasets a keyset/cursor approach is preferable, though the Link header contract shown here stays identical. pagination_spec.rb locks in the behavior with Rack::Test, asserting the header contains a rel="next" on a middle page and omits next on the final page. This pattern is worth reaching for whenever an API should be navigable by clients that already understand Link, such as anything modeled on the GitHub API.


Related snips

Share this code

Here's the card — post it anywhere.

Cursor-Style Pagination With RFC 5988 Link Headers in Sinatra — share card
Link copied