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 when needed?

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: Secondary-priority records (e.g. everything else)

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
  • Handle datasets of different types cleanly
  • 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—or even the same type.

Here are some real-world scenarios:

📁 Folder View: Subfolders First, Then Files

Say you’re building a file explorer. You want to show contents of a folder, but always list subfolders before files, and paginate the whole thing in a unified list.

Here, subfolders and files are separate models, so this is a good example of paginating across different types of content in a single interface.

HybridPaginator.new(
  top_scope: folder.ordered_subfolders,
  bottom_scope: folder.ordered_files,
  per_page: 20,
  page: params[:page]
)

Now each page:

  • Fills first with subfolders
  • Then includes files if there’s space
  • Returns one clean list, just like a desktop file browser

🛌 Featured vs Regular Products

On an e-commerce page, show sponsored or high-priority products first, then the rest of the catalog.

🔖 Editor Picks + Recent News

In a content platform, blend curated “editor picks” with algorithmically sorted latest posts. Prioritize the human touch, but still show what’s new.

🛠️ Dashboard Alerts

Show high-priority or critical items first, followed by lower-severity data, all in the same list.


Example Data Model: Folders and Files

Here’s how you might model this:

CREATE TABLE folders (
  id SERIAL PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  parent_id INTEGER REFERENCES folders(id),
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE TABLE files (
  id SERIAL PRIMARY KEY,
  folder_id INTEGER NOT NULL REFERENCES folders(id),
  name VARCHAR(255) NOT NULL,
  size INTEGER,
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_folders_parent_id ON folders(parent_id);
CREATE INDEX idx_files_folder_id ON files(folder_id);

And the Rails associations:

class Folder < ApplicationRecord
  has_many :subfolders, class_name: 'Folder', foreign_key: :parent_id
  belongs_to :parent, class_name: 'Folder', optional: true

  has_many :files

  def ordered_subfolders
    subfolders.order(:name)
  end

  def ordered_files
    files.order(:name)
  end
end

class File < ApplicationRecord
  belongs_to :folder
end

Example Usage

paginator = HybridPaginator.new(
  top_scope: folder.ordered_subfolders,
  bottom_scope: folder.ordered_files,
  per_page: 20,
  page: params[:page]
)

render json: {
  data: paginator.call,
  meta: paginator.meta
}

Credits

This solution was built in collaboration with Anushka Shukla, whose insights and attention to edge cases were instrumental in shaping the final implementation. 👏


Final Thoughts

Hybrid pagination solves a common but under-documented problem: merging priority-ordered data sources—even across different models—without overcomplicating your app or dumping logic into the frontend.

It’s:

  • SQL-efficient
  • Easy to test
  • Simple to integrate
  • Flexible across use cases

If you're dealing with mixed-priority data or content curation, this approach keeps your stack clean and your UI consistent.

Let me know if you'd like this turned into a gem or Rails concern. We built it lean on purpose.