🎉 I'm releasing 12 products in 12 months! If you love product, checkout my new blog workingoutloud.dev

Back to home

Ruby On Rails Pagination With Kaminari

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 <Adsense /> ## 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

Personal image

Dennis O'Keeffe

@dennisokeeffe92
  • Melbourne, Australia

Hi, I am a professional Software Engineer. Formerly of Culture Amp, UsabilityHub, Present Company and NightGuru.
I am currently working on Visibuild.

1,200+ PEOPLE ALREADY JOINED ❤️️

Get fresh posts + news direct to your inbox.

No spam. We only send you relevant content.

Ruby On Rails Pagination With Kaminari

Introduction

Share this post