skills/shoebtamboli/rails_claude_skills/rails-pagination-kaminari

rails-pagination-kaminari

SKILL.md

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">&larr;</span> Older
<% end %>

<%= link_to_next_page @posts do %>
  Newer <span aria-hidden="true">&rarr;</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: "&laquo; First"
      last: "Last &raquo;"
      previous: "&lsaquo; Prev"
      next: "Next &rsaquo;"
      truncate: "&hellip;"
  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}&nbsp;-&nbsp;%{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

  1. Always order before paginating: Ensures consistent results across pages
  2. Use per wisely: Set reasonable limits with max_paginates_per
  3. Eager load associations: Prevent N+1 queries with includes
  4. Cache pagination: Use fragment caching for expensive queries
  5. Handle out of range: Check out_of_range? and redirect if needed
  6. API pagination: Always include metadata in JSON responses
  7. SEO: Use rel_next_prev_link_tags for better search indexing
  8. Test edge cases: Empty results, last page, out of range pages
  9. Use without_count for large datasets: Skip COUNT queries when possible
  10. Preserve filters: Pass filter params to paginate helper

Additional Resources

For more advanced patterns, see:

Resources

Weekly Installs
4
GitHub Stars
4
First Seen
Mar 1, 2026
Installed on
opencode4
gemini-cli4
github-copilot4
codex4
amp4
cline4