| name | rails-pagination-kaminari |
| description | Pagination for Ruby on Rails applications using Kaminari. Use when: (1) Implementing pagination for database records, (2) Building paginated API endpoints, (3) Customizing pagination UI with themes, (4) Handling large datasets efficiently, (5) Creating infinite scroll, (6) Paginating arrays or custom collections, (7) Adding SEO-friendly pagination URLs, (8) Internationalizing pagination labels |
Rails Pagination with Kaminari
Kaminari is a scope and engine-based pagination library that provides a clean, powerful, customizable paginator for Rails applications. It's non-intrusive, chainable with ActiveRecord, and highly customizable.
Quick Setup
bundle add kaminari
rails g kaminari:config
rails g kaminari:views default
Basic Usage
Controller Pagination
class PostsController < ApplicationController
def index
@posts = Post.order(:created_at).page(params[:page])
end
end
View Helper
<!-- app/views/posts/index.html.erb -->
<%= paginate @posts %>
That's it! Kaminari automatically adds pagination links.
Core Methods
Page Scope
User.page(1)
User.page(params[:page])
User.page(1).per(50)
User.active.order(:name).page(params[:page]).per(20)
User.includes(:posts).page(params[:page])
Pagination Metadata
users = User.page(2).per(20)
users.current_page
users.total_pages
users.total_count
users.limit_value
users.first_page?
users.last_page?
users.next_page
users.prev_page
users.out_of_range?
Configuration
Global Configuration
Kaminari.configure do |config|
config.default_per_page = 25
config.max_per_page = 100
config.max_pages = nil
config.window = 4
config.outer_window = 0
config.left = 0
config.right = 0
config.page_method_name = :page
config.param_name = :page
end
Per-Model Configuration
class Post < ApplicationRecord
paginates_per 50
max_paginates_per 100
max_pages 100
end
View Helpers
Basic Pagination
<!-- Simple pagination links -->
<%= paginate @posts %>
<!-- With options -->
<%= paginate @posts, window: 2 %>
<%= paginate @posts, outer_window: 1 %>
<%= paginate @posts, left: 1, right: 1 %>
<!-- Custom parameter name -->
<%= paginate @posts, param_name: :pagina %>
<!-- For AJAX/Turbo -->
<%= paginate @posts, remote: true %>
Navigation Links
<!-- Previous/Next links -->
<%= link_to_prev_page @posts, 'Previous', class: 'btn' %>
<%= link_to_next_page @posts, 'Next', class: 'btn' %>
<!-- With custom content -->
<%= link_to_prev_page @posts do %>
<span aria-hidden="true">←</span> Older
<% end %>
<%= link_to_next_page @posts do %>
Newer <span aria-hidden="true">→</span>
<% end %>
Page Info
<!-- Shows: "Displaying posts 1 - 25 of 100 in total" -->
<%= page_entries_info @posts %>
<!-- Custom format -->
<%= page_entries_info @posts, entry_name: 'item' %>
SEO Helpers
<!-- Add rel="next" and rel="prev" link tags to <head> -->
<%= rel_next_prev_link_tags @posts %>
URL Helpers
path_to_next_page(@posts)
path_to_prev_page(@posts)
Customization
Generating Custom Views
rails g kaminari:views default
rails g kaminari:views default --views-prefix admin
rails g kaminari:views bootstrap4
This creates templates in app/views/kaminari/:
_first_page.html.erb
_prev_page.html.erb
_page.html.erb
_next_page.html.erb
_last_page.html.erb
_gap.html.erb
_paginator.html.erb
Using Themes
<!-- Default theme -->
<%= paginate @posts %>
<!-- Custom theme -->
<%= paginate @posts, theme: 'my_theme' %>
<!-- Bootstrap theme -->
<%= paginate @posts, theme: 'twitter-bootstrap-4' %>
Custom Pagination Template
<!-- app/views/kaminari/_paginator.html.erb -->
<nav class="pagination" role="navigation" aria-label="Pagination">
<ul class="pagination-list">
<%= first_page_tag %>
<%= prev_page_tag %>
<% each_page do |page| %>
<% if page.left_outer? || page.right_outer? || page.inside_window? %>
<%= page_tag page %>
<% elsif !page.was_truncated? %>
<%= gap_tag %>
<% end %>
<% end %>
<%= next_page_tag %>
<%= last_page_tag %>
</ul>
</nav>
API Pagination
JSON Response
module Api
module V1
class PostsController < ApplicationController
def index
@posts = Post.page(params[:page]).per(params[:per_page] || 20)
render json: {
posts: @posts.map { |p| PostSerializer.new(p) },
meta: pagination_meta(@posts)
}
end
private
def pagination_meta(collection)
{
current_page: collection.current_page,
next_page: collection.next_page,
prev_page: collection.prev_page,
total_pages: collection.total_pages,
total_count: collection.total_count
}
end
end
end
end
API Response Helper
module Paginatable
extend ActiveSupport::Concern
def paginate(collection)
collection
.page(params[:page] || 1)
.per(params[:per_page] || default_per_page)
end
def pagination_links(collection)
{
self: request.original_url,
first: url_for(page: 1),
prev: collection.prev_page ? url_for(page: collection.prev_page) : nil,
next: collection.next_page ? url_for(page: collection.next_page) : nil,
last: url_for(page: collection.total_pages)
}
end
def pagination_meta(collection)
{
current_page: collection.current_page,
total_pages: collection.total_pages,
total_count: collection.total_count,
per_page: collection.limit_value
}
end
private
def default_per_page
20
end
end
Performance Optimization
Without Count Query
For very large datasets, skip expensive COUNT queries:
def index
@posts = Post.order(:created_at).page(params[:page]).without_count
end
<%= link_to_prev_page @posts, 'Previous' %>
<%= link_to_next_page @posts, 'Next' %>
Note: total_pages, total_count, and numbered page links won't work with without_count.
Eager Loading
@posts = Post.includes(:user, :comments)
.order(:created_at)
.page(params[:page])
Caching
<% cache ["posts-page", @posts.current_page] do %>
<%= render @posts %>
<%= paginate @posts %>
<% end %>
Advanced Features
Paginating Arrays
@items = expensive_operation_returning_array
@paginated_items = Kaminari.paginate_array(@items, total_count: @items.count)
.page(params[:page])
.per(10)
@paginated_items = Kaminari.paginate_array(
@items,
total_count: 145,
limit: 10,
offset: (params[:page].to_i - 1) * 10
).page(params[:page]).per(10)
SEO-Friendly URLs
resources :posts do
get 'page/:page', action: :index, on: :collection
end
concern :paginatable do
get '(page/:page)', action: :index, on: :collection, as: ''
end
resources :posts, concerns: :paginatable
resources :articles, concerns: :paginatable
Infinite Scroll
def index
@posts = Post.order(:created_at).page(params[:page])
respond_to do |format|
format.html
format.js
end
end
$('#posts').append('<%= j render @posts %>');
<% if @posts.next_page %>
$('.pagination').replaceWith('<%= j paginate @posts %>');
<% else %>
$('.pagination').remove();
<% end %>
Custom Scopes with Pagination
class Post < ApplicationRecord
scope :published, -> { where(published: true) }
scope :by_author, ->(author_id) { where(author_id: author_id) }
scope :recent_first, -> { order(created_at: :desc) }
end
Internationalization
en:
views:
pagination:
first: "« First"
last: "Last »"
previous: "‹ Prev"
next: "Next ›"
truncate: "…"
helpers:
page_entries_info:
one_page:
display_entries:
zero: "No %{entry_name} found"
one: "Displaying <b>1</b> %{entry_name}"
other: "Displaying <b>all %{count}</b> %{entry_name}"
more_pages:
display_entries: "Displaying %{entry_name} <b>%{first} - %{last}</b> of <b>%{total}</b> in total"
Common Patterns
Search with Pagination
def index
@posts = Post.all
@posts = @posts.where('title LIKE ?', "%#{params[:q]}%") if params[:q].present?
@posts = @posts.order(:created_at).page(params[:page])
end
<!-- app/views/posts/index.html.erb -->
<%= form_with url: posts_path, method: :get do |f| %>
<%= f.text_field :q, value: params[:q], placeholder: 'Search...' %>
<%= f.submit 'Search' %>
<% end %>
<%= render @posts %>
<%= paginate @posts, params: { q: params[:q] } %>
Filtered Pagination
def index
@posts = Post.all
@posts = @posts.where(category_id: params[:category_id]) if params[:category_id]
@posts = @posts.where(status: params[:status]) if params[:status]
@posts = @posts.order(:created_at).page(params[:page])
end
<%= paginate @posts, params: { category_id: params[:category_id], status: params[:status] } %>
Admin Pagination
module Admin
class UsersController < AdminController
def index
@users = User.order(:email).page(params[:page]).per(50)
end
end
end
Testing
RSpec
RSpec.describe Post, type: :model do
describe '.page' do
let!(:posts) { create_list(:post, 30) }
it 'returns first page with default per_page' do
page = Post.page(1)
expect(page.count).to eq(25)
expect(page.current_page).to eq(1)
end
it 'returns correct page' do
page = Post.page(2).per(10)
expect(page.count).to eq(10)
expect(page.current_page).to eq(2)
expect(page.total_pages).to eq(3)
end
end
end
RSpec.describe 'Posts', type: :request do
describe 'GET /posts' do
let!(:posts) { create_list(:post, 30) }
it 'paginates posts' do
get posts_path, params: { page: 2 }
expect(response).to have_http_status(:ok)
expect(assigns(:posts).current_page).to eq(2)
end
it 'handles out of range pages' do
get posts_path, params: { page: 999 }
expect(response).to have_http_status(:ok)
expect(assigns(:posts)).to be_empty
expect(assigns(:posts).out_of_range?).to be true
end
end
end
Controller Tests
RSpec.describe PostsController, type: :controller do
describe 'GET #index' do
let!(:posts) { create_list(:post, 30) }
it 'assigns paginated posts' do
get :index, params: { page: 1 }
expect(assigns(:posts).count).to eq(25)
expect(assigns(:posts).total_count).to eq(30)
end
it 'respects per_page parameter' do
get :index, params: { page: 1, per_page: 10 }
expect(assigns(:posts).count).to eq(10)
end
end
end
Troubleshooting
Page Parameter Not Working
params.permit(:page, :per_page)
Total Count Performance
class Post < ApplicationRecord
belongs_to :category, counter_cache: true
end
Styling Issues
rails g kaminari:views default
rails g kaminari:views bootstrap4
Best Practices
- Always order before paginating: Ensures consistent results across pages
- Use
per wisely: Set reasonable limits with max_paginates_per
- Eager load associations: Prevent N+1 queries with
includes
- Cache pagination: Use fragment caching for expensive queries
- Handle out of range: Check
out_of_range? and redirect if needed
- API pagination: Always include metadata in JSON responses
- SEO: Use
rel_next_prev_link_tags for better search indexing
- Test edge cases: Empty results, last page, out of range pages
- Use
without_count for large datasets: Skip COUNT queries when possible
- Preserve filters: Pass filter params to
paginate helper
Additional Resources
For more advanced patterns, see:
Resources