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
Add to Gemfile
bundle add kaminari
Generate configuration file (optional)
rails g kaminari:config
Generate view templates for customization (optional)
rails g kaminari:views default
Basic Usage
Controller Pagination
app/controllers/posts_controller.rb
class PostsController < ApplicationController def index @posts = Post.order(:created_at).page(params[:page]) # Returns 25 items per page by default end end
View Helper
<!-- app/views/posts/index.html.erb --> <%= paginate @posts %>
That's it! Kaminari automatically adds pagination links.
Core Methods
Page Scope
Basic pagination
User.page(1) # First page, 25 items User.page(params[:page]) # Dynamic page from params
Custom per-page
User.page(1).per(50) # 50 items per page
Chaining with scopes
User.active.order(:name).page(params[:page]).per(20)
With associations
User.includes(:posts).page(params[:page])
Pagination Metadata
users = User.page(2).per(20)
users.current_page #=> 2 users.total_pages #=> 10 users.total_count #=> 200 users.limit_value #=> 20 users.first_page? #=> false users.last_page? #=> false users.next_page #=> 3 users.prev_page #=> 1 users.out_of_range? #=> false
Configuration
Global Configuration
config/initializers/kaminari_config.rb
Kaminari.configure do |config| config.default_per_page = 25 # Default items per page config.max_per_page = 100 # Maximum allowed per page config.max_pages = nil # Maximum pages (nil = unlimited) config.window = 4 # Inner window size config.outer_window = 0 # Outer window size config.left = 0 # Left outer window config.right = 0 # Right outer window config.page_method_name = :page # Method name (change if conflicts) config.param_name = :page # URL parameter name end
Per-Model Configuration
app/models/post.rb
class Post < ApplicationRecord paginates_per 50 # This model shows 50 per page max_paginates_per 100 # User can't request more than 100 max_pages 100 # Limit to 100 pages total 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
Get URLs for navigation
path_to_next_page(@posts) #=> "/posts?page=3" path_to_prev_page(@posts) #=> "/posts?page=1"
Customization
Generating Custom Views
Generate default theme
rails g kaminari:views default
Generate with namespace
rails g kaminari:views default --views-prefix admin
Generate specific theme
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
app/controllers/api/v1/posts_controller.rb
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
app/controllers/concerns/paginatable.rb
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:
Controller
def index @posts = Post.order(:created_at).page(params[:page]).without_count end
View - use simple navigation only
<%= 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
Prevent N+1 queries
@posts = Post.includes(:user, :comments) .order(:created_at) .page(params[:page])
Caching
Fragment caching
<% cache ["posts-page", @posts.current_page] do %> <%= render @posts %> <%= paginate @posts %> <% end %>
Advanced Features
Paginating Arrays
Controller
@items = expensive_operation_returning_array @paginated_items = Kaminari.paginate_array(@items, total_count: @items.count) .page(params[:page]) .per(10)
Or with known total
@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
config/routes.rb
resources :posts do get 'page/:page', action: :index, on: :collection end
Creates URLs like: /posts/page/2 instead of /posts?page=2
Or using concerns
concern :paginatable do get '(page/:page)', action: :index, on: :collection, as: '' end
resources :posts, concerns: :paginatable resources :articles, concerns: :paginatable
Infinite Scroll
app/controllers/posts_controller.rb
def index @posts = Post.order(:created_at).page(params[:page])
respond_to do |format| format.html format.js # For infinite scroll end end
// app/views/posts/index.js.erb $('#posts').append('<%= j render @posts %>');
<% if @posts.next_page %> $('.pagination').replaceWith('<%= j paginate @posts %>'); <% else %> $('.pagination').remove(); <% end %>
Custom Scopes with Pagination
app/models/post.rb
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) }
Chainable with pagination
Post.published.recent_first.page(1)
end
Internationalization
config/locales/en.yml
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
app/controllers/posts_controller.rb
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
app/controllers/posts_controller.rb
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
app/controllers/admin/users_controller.rb
module Admin class UsersController < AdminController def index @users = User.order(:email).page(params[:page]).per(50) end end end
Testing
RSpec
spec/models/post_spec.rb
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
spec/requests/posts_spec.rb
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
spec/controllers/posts_controller_spec.rb
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
If using custom routes, ensure page param is permitted
params.permit(:page, :per_page)
Total Count Performance
For large tables, use counter cache or without_count
class Post < ApplicationRecord
Option 1: Counter cache
belongs_to :category, counter_cache: true
Option 2: Skip count query
Use .without_count in controller
end
Styling Issues
Ensure you've generated views
rails g kaminari:views default
Or use a theme
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:
-
API Pagination: references/api-pagination.md
-
Custom Themes: references/custom-themes.md
-
Performance Optimization: references/performance.md
Resources
-
Kaminari GitHub
-
Kaminari Wiki