Ruby On Rails Pagination With Kaminari

Implement simple pagination mechanisms using the Kaminari gem with Ruby on Rails 7.

On this page

Pagination is a common problem in Rails applications. Make that problem simpler with the Kaminari gem.

Source code can be found here

Prerequisites

  1. Basic familiarity with setting up a new Rails project.

Getting started

We will use Rails to initialize the project demo-kaminari-pagination:

# Create a new rails project
$ rails new demo-kaminari-pagination -d postgresql
$ cd demo-kaminari-pagination

# Add gems
$ bundler add kaminari

## Set up model
$ bin/rails g model Post title:string rating:integer
$ bin/rails g controller posts index

Next, let's update our seeds.rb file to seed our database:

Post.create([
  { title: 'First Post', rating: 5 },
  { title: 'Second Post', rating: 5 },
  { title: 'Third Post', rating: 3 },
  { title: 'Fourth Post', rating: 5 },
  { title: 'Fifth Post', rating: 1 },
  { title: 'Sixth Post', rating: 5 },
  { title: 'Seventh Post', rating: 3 },
  { title: 'Eighth Post', rating: 2 },
  { title: 'Ninth Post', rating: 4 },
  { title: 'Tenth Post', rating: 5 },
  { title: 'Eleventh Post', rating: 5 },
  { title: 'Twelfth Post', rating: 5 },
  { title: 'Thirteenth Post', rating: 5 },
  { title: 'Fourteenth Post', rating: 5 },
  { title: 'Fifteenth Post', rating: 3 },
  { title: 'Sixteenth Post', rating: 3 },
  { title: 'Seventeenth Post', rating: 5 },
  { title: 'Eighteenth Post', rating: 4 },
  { title: 'Nineteenth Post', rating: 4 },
  { title: 'Twentieth Post', rating: 5 }
])

Inside of app/controllers/posts_controller.rb, update the file contents:

class PostsController < ApplicationController
  def index
    posts = Post.all
    render json: posts
  end
end

Update the routes.rb file:

Rails.application.routes.draw do
  resources :posts, only: [:index]
  # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

  # Defines the root path route ("/")
  # root "articles#index"
end

Finally, we need to update our config/application.rb to set our default_protect_from_forgery configuration:

require_relative 'boot'

require 'rails/all'

# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)

module DemoKaminariPagination
  class Application < Rails::Application
    # Initialize configuration defaults for originally generated Rails version.
    config.load_defaults 7.0

    # Configuration for the application, engines, and railties goes here.
    #
    # These settings can be overridden in specific environments using the files
    # in config/environments, which are processed later.
    #
    # config.time_zone = "Central Time (US & Canada)"
    # config.eager_load_paths << Rails.root.join("extras")
    config.action_controller.default_protect_from_forgery = false if ENV['RAILS_ENV'] == 'development'
  end
end

To create, migrate and insert the data then run the app:

# Create dbs, then run migrations and finally seed
$ bin/rails db:create db:migrate db:seed

# Start up the server
$ bin/rails s

At this stage, our project will be up and running on localhost:3000.

A first look at our endpoint

If I now use httpie for route localhost:3000/posts, we will get all posts:

$ http GET localho
st:3000/posts
HTTP/1.1 200 OK
Cache-Control: max-age=0, private, must-revalidate
Content-Type: application/json; charset=utf-8
ETag: W/"6c25d25ce273f41948efbdea1a498892"
Referrer-Policy: strict-origin-when-cross-origin
Server-Timing: start_processing.action_controller;dur=0.08984375, sql.active_record;dur=6.348876953125, instantiation.active_record;dur=7.771728515625, process_action.action_controller;dur=16.616943359375
Transfer-Encoding: chunked
Vary: Accept
X-Content-Type-Options: nosniff
X-Download-Options: noopen
X-Frame-Options: SAMEORIGIN
X-Permitted-Cross-Domain-Policies: none
X-Request-Id: 736e3368-280d-40ec-9f3a-ee663062c67c
X-Runtime: 0.047404
X-XSS-Protection: 0

[
    {
        "created_at": "2022-02-28T23:33:10.137Z",
        "id": 1,
        "rating": 5,
        "title": "First Post",
        "updated_at": "2022-02-28T23:33:10.137Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.142Z",
        "id": 2,
        "rating": 5,
        "title": "Second Post",
        "updated_at": "2022-02-28T23:33:10.142Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.145Z",
        "id": 3,
        "rating": 3,
        "title": "Third Post",
        "updated_at": "2022-02-28T23:33:10.145Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.148Z",
        "id": 4,
        "rating": 5,
        "title": "Fourth Post",
        "updated_at": "2022-02-28T23:33:10.148Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.151Z",
        "id": 5,
        "rating": 1,
        "title": "Fifth Post",
        "updated_at": "2022-02-28T23:33:10.151Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.155Z",
        "id": 6,
        "rating": 5,
        "title": "Sixth Post",
        "updated_at": "2022-02-28T23:33:10.155Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.157Z",
        "id": 7,
        "rating": 3,
        "title": "Seventh Post",
        "updated_at": "2022-02-28T23:33:10.157Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.160Z",
        "id": 8,
        "rating": 2,
        "title": "Eighth Post",
        "updated_at": "2022-02-28T23:33:10.160Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.163Z",
        "id": 9,
        "rating": 4,
        "title": "Ninth Post",
        "updated_at": "2022-02-28T23:33:10.163Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.165Z",
        "id": 10,
        "rating": 5,
        "title": "Tenth Post",
        "updated_at": "2022-02-28T23:33:10.165Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.168Z",
        "id": 11,
        "rating": 5,
        "title": "Eleventh Post",
        "updated_at": "2022-02-28T23:33:10.168Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.171Z",
        "id": 12,
        "rating": 5,
        "title": "Twelfth Post",
        "updated_at": "2022-02-28T23:33:10.171Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.175Z",
        "id": 13,
        "rating": 5,
        "title": "Thirteenth Post",
        "updated_at": "2022-02-28T23:33:10.175Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.177Z",
        "id": 14,
        "rating": 5,
        "title": "Fourteenth Post",
        "updated_at": "2022-02-28T23:33:10.177Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.181Z",
        "id": 15,
        "rating": 3,
        "title": "Fifteenth Post",
        "updated_at": "2022-02-28T23:33:10.181Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.184Z",
        "id": 16,
        "rating": 3,
        "title": "Sixteenth Post",
        "updated_at": "2022-02-28T23:33:10.184Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.188Z",
        "id": 17,
        "rating": 5,
        "title": "Seventeenth Post",
        "updated_at": "2022-02-28T23:33:10.188Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.191Z",
        "id": 18,
        "rating": 4,
        "title": "Eighteenth Post",
        "updated_at": "2022-02-28T23:33:10.191Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.193Z",
        "id": 19,
        "rating": 4,
        "title": "Nineteenth Post",
        "updated_at": "2022-02-28T23:33:10.193Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.196Z",
        "id": 20,
        "rating": 5,
        "title": "Twentieth Post",
        "updated_at": "2022-02-28T23:33:10.196Z"
    }
]

At this stage, we are ready for Kaminari.

A first example of pagination

Let's update our controller to use Kaminari:

class PostsController < ApplicationController
  def index
    page = params[:page] || 1
    page_size = params[:page_size] && Integer(params[:page_size]) <= 100 ? params[:page_size] : 10

    posts = Post.page(page).per(page_size)
    render json: posts
  end
end

A response for not passing a page and passing page=2 is as follows:

$ http GET localhost:3000/posts
HTTP/1.1 200 OK
Cache-Control: max-age=0, private, must-revalidate
Content-Type: application/json; charset=utf-8
ETag: W/"1a803062aa0f401d615e8f892da04644"
Referrer-Policy: strict-origin-when-cross-origin
Server-Timing: start_processing.action_controller;dur=0.107177734375, sql.active_record;dur=46.23681640625, instantiation.active_record;dur=47.073974609375, process_action.action_controller;dur=55.307861328125
Transfer-Encoding: chunked
Vary: Accept
X-Content-Type-Options: nosniff
X-Download-Options: noopen
X-Frame-Options: SAMEORIGIN
X-Permitted-Cross-Domain-Policies: none
X-Request-Id: 4515b80c-a759-472a-a868-c69cb45c04cb
X-Runtime: 0.069243
X-XSS-Protection: 0

[
    {
        "created_at": "2022-02-28T23:33:10.137Z",
        "id": 1,
        "rating": 5,
        "title": "First Post",
        "updated_at": "2022-02-28T23:33:10.137Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.142Z",
        "id": 2,
        "rating": 5,
        "title": "Second Post",
        "updated_at": "2022-02-28T23:33:10.142Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.145Z",
        "id": 3,
        "rating": 3,
        "title": "Third Post",
        "updated_at": "2022-02-28T23:33:10.145Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.148Z",
        "id": 4,
        "rating": 5,
        "title": "Fourth Post",
        "updated_at": "2022-02-28T23:33:10.148Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.151Z",
        "id": 5,
        "rating": 1,
        "title": "Fifth Post",
        "updated_at": "2022-02-28T23:33:10.151Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.155Z",
        "id": 6,
        "rating": 5,
        "title": "Sixth Post",
        "updated_at": "2022-02-28T23:33:10.155Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.157Z",
        "id": 7,
        "rating": 3,
        "title": "Seventh Post",
        "updated_at": "2022-02-28T23:33:10.157Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.160Z",
        "id": 8,
        "rating": 2,
        "title": "Eighth Post",
        "updated_at": "2022-02-28T23:33:10.160Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.163Z",
        "id": 9,
        "rating": 4,
        "title": "Ninth Post",
        "updated_at": "2022-02-28T23:33:10.163Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.165Z",
        "id": 10,
        "rating": 5,
        "title": "Tenth Post",
        "updated_at": "2022-02-28T23:33:10.165Z"
    }
]
# With page=2
$ http GET localhost:3000/posts page=2
HTTP/1.1 200 OK
Cache-Control: max-age=0, private, must-revalidate
Content-Type: application/json; charset=utf-8
ETag: W/"92407a462bcde135d6b72eeeb71eac83"
Referrer-Policy: strict-origin-when-cross-origin
Server-Timing: start_processing.action_controller;dur=0.205078125, sql.active_record;dur=1.18798828125, instantiation.active_record;dur=0.112060546875, process_action.action_controller;dur=4.8330078125
Transfer-Encoding: chunked
X-Content-Type-Options: nosniff
X-Download-Options: noopen
X-Frame-Options: SAMEORIGIN
X-Permitted-Cross-Domain-Policies: none
X-Request-Id: f01b9c3e-2986-4be0-8527-d8ad1c05ebe0
X-Runtime: 0.013718
X-XSS-Protection: 0

[
    {
        "created_at": "2022-02-28T23:33:10.168Z",
        "id": 11,
        "rating": 5,
        "title": "Eleventh Post",
        "updated_at": "2022-02-28T23:33:10.168Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.171Z",
        "id": 12,
        "rating": 5,
        "title": "Twelfth Post",
        "updated_at": "2022-02-28T23:33:10.171Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.175Z",
        "id": 13,
        "rating": 5,
        "title": "Thirteenth Post",
        "updated_at": "2022-02-28T23:33:10.175Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.177Z",
        "id": 14,
        "rating": 5,
        "title": "Fourteenth Post",
        "updated_at": "2022-02-28T23:33:10.177Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.181Z",
        "id": 15,
        "rating": 3,
        "title": "Fifteenth Post",
        "updated_at": "2022-02-28T23:33:10.181Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.184Z",
        "id": 16,
        "rating": 3,
        "title": "Sixteenth Post",
        "updated_at": "2022-02-28T23:33:10.184Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.188Z",
        "id": 17,
        "rating": 5,
        "title": "Seventeenth Post",
        "updated_at": "2022-02-28T23:33:10.188Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.191Z",
        "id": 18,
        "rating": 4,
        "title": "Eighteenth Post",
        "updated_at": "2022-02-28T23:33:10.191Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.193Z",
        "id": 19,
        "rating": 4,
        "title": "Nineteenth Post",
        "updated_at": "2022-02-28T23:33:10.193Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.196Z",
        "id": 20,
        "rating": 5,
        "title": "Twentieth Post",
        "updated_at": "2022-02-28T23:33:10.196Z"
    }
]

Passing page_size as a value.

$ http GET localhost:3000/posts page=2 page_size=2
HTTP/1.1 200 OK
Cache-Control: max-age=0, private, must-revalidate
Content-Type: application/json; charset=utf-8
ETag: W/"d740a4480a5c674e4cd373748aeabeca"
Referrer-Policy: strict-origin-when-cross-origin
Server-Timing: start_processing.action_controller;dur=0.3408203125, sql.active_record;dur=1.013916015625, instantiation.active_record;dur=0.068115234375, process_action.action_controller;dur=4.370849609375
Transfer-Encoding: chunked
X-Content-Type-Options: nosniff
X-Download-Options: noopen
X-Frame-Options: SAMEORIGIN
X-Permitted-Cross-Domain-Policies: none
X-Request-Id: a930ea0b-b533-4b0f-8e1e-8e78e5860588
X-Runtime: 0.016528
X-XSS-Protection: 0

[
    {
        "created_at": "2022-02-28T23:33:10.145Z",
        "id": 3,
        "rating": 3,
        "title": "Third Post",
        "updated_at": "2022-02-28T23:33:10.145Z"
    },
    {
        "created_at": "2022-02-28T23:33:10.148Z",
        "id": 4,
        "rating": 5,
        "title": "Fourth Post",
        "updated_at": "2022-02-28T23:33:10.148Z"
    }
]

Updating the controller for more Kaminari helpers

Here we can update the controller:

class PostsController < ApplicationController
  def index
    page = params[:page] || 1
    page_size = params[:page_size] && Integer(params[:page_size]) <= 100 ? params[:page_size] : 10

    posts = Post.page(page).per(page_size)
    render json: { posts: posts, total_pages: posts.total_pages, current_page: posts.current_page,
                   prev_page: posts.prev_page, next_page: posts.next_page, out_of_range: posts.out_of_range? }
  end
end

This returns:

$ http GET localhost:3000/posts page_size=10
HTTP/1.1 200 OK
Cache-Control: max-age=0, private, must-revalidate
Content-Type: application/json; charset=utf-8
ETag: W/"6a769e1e57280d4e3f5d66498b54a872"
Referrer-Policy: strict-origin-when-cross-origin
Server-Timing: start_processing.action_controller;dur=0.208740234375, sql.active_record;dur=2.0439453125, instantiation.active_record;dur=0.1279296875, process_action.action_controller;dur=6.1162109375
Transfer-Encoding: chunked
X-Content-Type-Options: nosniff
X-Download-Options: noopen
X-Frame-Options: SAMEORIGIN
X-Permitted-Cross-Domain-Policies: none
X-Request-Id: 786b7b0d-8e55-49bb-9b8d-e512a55d6931
X-Runtime: 0.014969
X-XSS-Protection: 0

{
    "current_page": 1,
    "next_page": 2,
    "out_of_range": false,
    "posts": [
        {
            "created_at": "2022-02-28T23:33:10.137Z",
            "id": 1,
            "rating": 5,
            "title": "First Post",
            "updated_at": "2022-02-28T23:33:10.137Z"
        },
        {
            "created_at": "2022-02-28T23:33:10.142Z",
            "id": 2,
            "rating": 5,
            "title": "Second Post",
            "updated_at": "2022-02-28T23:33:10.142Z"
        },
        {
            "created_at": "2022-02-28T23:33:10.145Z",
            "id": 3,
            "rating": 3,
            "title": "Third Post",
            "updated_at": "2022-02-28T23:33:10.145Z"
        },
        {
            "created_at": "2022-02-28T23:33:10.148Z",
            "id": 4,
            "rating": 5,
            "title": "Fourth Post",
            "updated_at": "2022-02-28T23:33:10.148Z"
        },
        {
            "created_at": "2022-02-28T23:33:10.151Z",
            "id": 5,
            "rating": 1,
            "title": "Fifth Post",
            "updated_at": "2022-02-28T23:33:10.151Z"
        },
        {
            "created_at": "2022-02-28T23:33:10.155Z",
            "id": 6,
            "rating": 5,
            "title": "Sixth Post",
            "updated_at": "2022-02-28T23:33:10.155Z"
        },
        {
            "created_at": "2022-02-28T23:33:10.157Z",
            "id": 7,
            "rating": 3,
            "title": "Seventh Post",
            "updated_at": "2022-02-28T23:33:10.157Z"
        },
        {
            "created_at": "2022-02-28T23:33:10.160Z",
            "id": 8,
            "rating": 2,
            "title": "Eighth Post",
            "updated_at": "2022-02-28T23:33:10.160Z"
        },
        {
            "created_at": "2022-02-28T23:33:10.163Z",
            "id": 9,
            "rating": 4,
            "title": "Ninth Post",
            "updated_at": "2022-02-28T23:33:10.163Z"
        },
        {
            "created_at": "2022-02-28T23:33:10.165Z",
            "id": 10,
            "rating": 5,
            "title": "Tenth Post",
            "updated_at": "2022-02-28T23:33:10.165Z"
        }
    ],
    "prev_page": null,
    "total_pages": 2
}

Setting a maximum in the model

Under the model, we can set some general configuration options as seen here.

We will demonstrate this by enforcing a max pagination:

class Post < ApplicationRecord
  max_paginates_per 2
end

This enforces a max pagination of 2.

$ http GET localhost:3000/posts page_size=10
HTTP/1.1 200 OK
Cache-Control: max-age=0, private, must-revalidate
Content-Type: application/json; charset=utf-8
ETag: W/"6130c07d440332cbe68917fff659da49"
Referrer-Policy: strict-origin-when-cross-origin
Server-Timing: start_processing.action_controller;dur=0.170166015625, sql.active_record;dur=2.519775390625, instantiation.active_record;dur=0.199951171875, process_action.action_controller;dur=6.281494140625
Transfer-Encoding: chunked
X-Content-Type-Options: nosniff
X-Download-Options: noopen
X-Frame-Options: SAMEORIGIN
X-Permitted-Cross-Domain-Policies: none
X-Request-Id: 98f4946f-6423-4374-b9f8-898510d6f83f
X-Runtime: 0.016851
X-XSS-Protection: 0

{
    "current_page": 1,
    "next_page": 2,
    "out_of_range": false,
    "posts": [
        {
            "created_at": "2022-02-28T23:33:10.137Z",
            "id": 1,
            "rating": 5,
            "title": "First Post",
            "updated_at": "2022-02-28T23:33:10.137Z"
        },
        {
            "created_at": "2022-02-28T23:33:10.142Z",
            "id": 2,
            "rating": 5,
            "title": "Second Post",
            "updated_at": "2022-02-28T23:33:10.142Z"
        }
    ],
    "prev_page": null,
    "total_pages": 10
}

...

Summary

Today's post demonstrated how to ...

Resources and further reading

Photo credit: caroline_grondin