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.