Hybrid Pagination in Rails: Paginating Across Two Prioritized Data Sources

Pagination is simple—until it isn’t. In most apps, paginating one dataset is easy with tools like kaminari or will_paginate. But what if you need to paginate across two datasets, giving one higher priority, and use the second only as a fallback? That’s where hybrid pagination comes in. Here's how I built a clean, scalable hybrid paginator in Ruby. The Problem I had two ActiveRecord scopes: top_scope: High-priority records (e.g. featured or curated) bottom_scope: Regular records (fallback content) The requirements: Combine both into a single, paginated feed Always prioritize top_scope first If the current page isn't filled, pull the remainder from bottom_scope Keep everything performant (no .to_a, no unnecessary loads) Return standard pagination metadata The Solution We built a lightweight Ruby class to handle this logic, using basic SQL pagination (limit and offset) and a clean API. # frozen_string_literal: true class HybridPaginator PER_PAGE = 10 MAX_PER_PAGE = 30 def initialize(top_scope:, bottom_scope: nil, per_page:, page:) raise ArgumentError, 'top_scope must be an ActiveRecord::Relation' unless top_scope.respond_to?(:count) raise ArgumentError, 'bottom_scope must be an ActiveRecord::Relation or nil' unless bottom_scope.nil? || bottom_scope.respond_to?(:count) @page = page.to_i.positive? ? page.to_i : 1 @per_page = per_page.to_i.between?(1, MAX_PER_PAGE) ? per_page.to_i : PER_PAGE @top_scope = top_scope @bottom_scope = bottom_scope end def call top_records = top_scope_paginated remaining = @per_page - top_records.size if remaining > 0 && @bottom_scope bottom_records = bottom_scope_paginated(limit: remaining) top_records + bottom_records else top_records end end def meta { current_page: @page, per_page: @per_page, total_pages: total_pages, count: total_records } end private def total_records @top_scope.count + (@bottom_scope ? @bottom_scope.count : 0) end def total_pages (total_records.to_f / @per_page).ceil end def top_scope_paginated @top_scope.limit(@per_page).offset((@page - 1) * @per_page) end def bottom_scope_paginated(limit:) offset = [(@page - 1) * @per_page - @top_scope.count, 0].max @bottom_scope.limit(limit).offset(offset) end end Where This Is Useful Hybrid pagination is the right solution when you want a single feed, but your records don’t have equal priority. Here are some real-world scenarios:

Apr 7, 2025 - 15:52
 0
Hybrid Pagination in Rails: Paginating Across Two Prioritized Data Sources

Pagination is simple—until it isn’t.

In most apps, paginating one dataset is easy with tools like kaminari or will_paginate. But what if you need to paginate across two datasets, giving one higher priority, and use the second only as a fallback?

That’s where hybrid pagination comes in. Here's how I built a clean, scalable hybrid paginator in Ruby.

The Problem

I had two ActiveRecord scopes:

  • top_scope: High-priority records (e.g. featured or curated)
  • bottom_scope: Regular records (fallback content)

The requirements:

  • Combine both into a single, paginated feed
  • Always prioritize top_scope first
  • If the current page isn't filled, pull the remainder from bottom_scope
  • Keep everything performant (no .to_a, no unnecessary loads)
  • Return standard pagination metadata

The Solution

We built a lightweight Ruby class to handle this logic, using basic SQL pagination (limit and offset) and a clean API.

# frozen_string_literal: true

class HybridPaginator
  PER_PAGE = 10
  MAX_PER_PAGE = 30

  def initialize(top_scope:, bottom_scope: nil, per_page:, page:)
    raise ArgumentError, 'top_scope must be an ActiveRecord::Relation' unless top_scope.respond_to?(:count)
    raise ArgumentError, 'bottom_scope must be an ActiveRecord::Relation or nil' unless bottom_scope.nil? || bottom_scope.respond_to?(:count)

    @page = page.to_i.positive? ? page.to_i : 1
    @per_page = per_page.to_i.between?(1, MAX_PER_PAGE) ? per_page.to_i : PER_PAGE
    @top_scope = top_scope
    @bottom_scope = bottom_scope
  end

  def call
    top_records = top_scope_paginated
    remaining = @per_page - top_records.size

    if remaining > 0 && @bottom_scope
      bottom_records = bottom_scope_paginated(limit: remaining)
      top_records + bottom_records
    else
      top_records
    end
  end

  def meta
    {
      current_page: @page,
      per_page: @per_page,
      total_pages: total_pages,
      count: total_records
    }
  end

  private

  def total_records
    @top_scope.count + (@bottom_scope ? @bottom_scope.count : 0)
  end

  def total_pages
    (total_records.to_f / @per_page).ceil
  end

  def top_scope_paginated
    @top_scope.limit(@per_page).offset((@page - 1) * @per_page)
  end

  def bottom_scope_paginated(limit:)
    offset = [(@page - 1) * @per_page - @top_scope.count, 0].max
    @bottom_scope.limit(limit).offset(offset)
  end
end

Where This Is Useful

Hybrid pagination is the right solution when you want a single feed, but your records don’t have equal priority.

Here are some real-world scenarios: