Pagination Rails Components
Elegant, Turbo-friendly pagination components that pair Pagy's speed with modern UI styling. Perfect for lists, tables, and data-heavy pages.
Installation
1. Add the Pagy gem
Add the Pagy gem to your Gemfile. I recommend v43+ for the modern API and built-in Turbo friendliness.
gem "pagy", "~> 43.2.2"
Then run bundle install to install the gem.
2. Configure Pagy in Rails
Include the pagy method where you are going to use it. It will usually be in your application_controller.rb file:
class ApplicationController < ActionController::Base
include Pagy::Method
# ...
end
3. Add pagination partials
Copy the pagination partials to your Rails application. You can download all partials at once or install them individually.
Download all 5 pagination view partials as a ZIP file.
Download all partials (ZIP)How to install
After downloading, unzip the file and copy the partials to your app/views/shared/ directory.
Install each partial individually. Click on a partial to expand and see its code, preview, and download option. I recommend adding them to your app/views/shared/ directory.
Displays the current item range and total count (e.g., "Showing 1–25 of 100").
Showing 1–10 of 84
<% text_class = local_assigns.fetch(:text_class, "text-sm text-neutral-600 dark:text-neutral-300") %>
<p class="<%= text_class %>">
Showing <%= pagy.from %>–<%= pagy.to %> of <%= pagy.count %>
</p>
A form with a number input that lets users jump directly to a specific page number.
<% frame_id = local_assigns.fetch(:frame_id, nil)
page_key = local_assigns.fetch(:page_key, pagy.options[:page_key] || "page")
limit_key = local_assigns.fetch(:limit_key, pagy.options[:limit_key] || "limit")
preserve_params = local_assigns.fetch(:preserve_params, {}).stringify_keys.except(page_key.to_s, limit_key.to_s)
form_class = local_assigns.fetch(:form_class, "flex items-center")
request_path = local_assigns.fetch(:request_path, request.path)
%>
<%= form_with url: request_path, method: :get, data: { turbo_frame: frame_id }, class: form_class, local: true do %>
<% preserve_params.each do |k, v| %>
<%= hidden_field_tag k, v %>
<% end %>
<%= hidden_field_tag limit_key, pagy.limit %>
<label class="text-xs text-neutral-500 dark:text-neutral-300 mr-2">Jump to Page</label>
<%= number_field_tag page_key, pagy.page, min: 1, max: pagy.pages, class: "w-fit rounded-l-md border border-black/10 bg-white pl-2 leading-7 text-sm dark:border-white/10 dark:bg-white/10 dark:text-white focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200" %>
<%= submit_tag "Go", class: "cursor-pointer rounded-r-md border border-l-0 border-black/10 bg-white px-2.5 py-1 text-sm font-medium text-neutral-800 hover:bg-neutral-50 dark:border-white/10 dark:bg-white/10 dark:text-white dark:hover:bg-white/20" %>
<% end %>
A dropdown selector that allows users to change the number of items displayed per page.
<% frame_id = local_assigns.fetch(:frame_id, nil)
page_key = local_assigns.fetch(:page_key, pagy.options[:page_key] || "page")
limit_key = local_assigns.fetch(:limit_key, pagy.options[:limit_key] || "limit")
limit_options = local_assigns.fetch(:limit_options, [15, 30, 60]).map(&:to_i)
preserve_params = local_assigns.fetch(:preserve_params, {}).stringify_keys.except(page_key.to_s, limit_key.to_s)
form_class = local_assigns.fetch(:form_class, "flex items-center gap-2")
request_path = local_assigns.fetch(:request_path, request.path)
%>
<%= form_with url: request_path, method: :get, data: { turbo_frame: frame_id }, class: form_class, local: true do %>
<% preserve_params.each do |k, v| %>
<%= hidden_field_tag k, v %>
<% end %>
<%= hidden_field_tag page_key, 1 %>
<label class="text-xs text-neutral-500 dark:text-neutral-300">Rows</label>
<%= select_tag limit_key,
options_for_select(limit_options, pagy.limit),
class: "rounded-md border border-black/10 bg-white px-2 py-1 text-sm text-neutral-900 shadow-xs dark:border-white/10 dark:bg-neutral-900 dark:text-white",
onchange: "this.form.requestSubmit()" %>
<% end %>
Minimal navigation with just previous and next buttons. Perfect for simple pagination needs.
<% frame_id = local_assigns.fetch(:frame_id, nil)
page_key = local_assigns.fetch(:page_key, pagy.options[:page_key] || "page")
limit_key = local_assigns.fetch(:limit_key, pagy.options[:limit_key] || "limit")
preserve_params = local_assigns.fetch(:preserve_params, {}).stringify_keys.except(page_key.to_s, limit_key.to_s)
wrapper_class = local_assigns.fetch(:wrapper_class, "inline-flex items-center gap-2 text-sm font-medium")
query_params = preserve_params.dup
query_params[limit_key] = pagy.limit.to_s
link_extra_parts = []
link_extra_parts << "data-turbo-frame='#{frame_id}'" if frame_id.present?
link_extra = link_extra_parts.join(" ")
common_opts = {
link_extra: link_extra.presence,
querify: ->(q) { q.merge!(query_params) }
}.compact
prev_html = pagy.previous_tag(**common_opts.merge(aria_label: "Previous"))
next_html = pagy.next_tag(**common_opts.merge(aria_label: "Next"))
%>
<div class="pagy <%= wrapper_class %>" aria-label="Pagination">
<%== prev_html %>
<%== next_html %>
</div>
4. Custom CSS
Add the custom CSS styles for Pagy pagination components. Copy this file to your app/assets/stylesheets/ directory.
.pagy {
--spacing: 0.15rem;
--padding: 0.7rem;
--rounding: 0.5rem;
--border-width: 1px;
--font-size: 0.875rem;
--font-weight: 500;
--line-height: 1.6;
/* light (tailwind neutral) */
--text: #171717; /* neutral-900 */
--text-hover: #0a0a0a; /* neutral-950 */
--text-current: #fafafa; /* neutral-50 */
--background: #fafafa; /* neutral-50 */
--background-hover: #f5f5f5; /* neutral-100 */
--background-current: #171717; /* neutral-900 */
--background-input: #ffffff;
color: var(--text);
font-size: var(--font-size);
line-height: var(--line-height);
font-weight: var(--font-weight);
display: flex;
user-select: none;
}
.pagy > :not([hidden]) ~ :not([hidden]) {
margin-left: calc(var(--spacing) * (1 - var(--space-reverse, 0)));
margin-right: calc(var(--spacing) * var(--space-reverse, 0));
}
.rtl .pagy > :not([hidden]) ~ :not([hidden]) {
--space-reverse: 1;
}
.pagy a:not([role="separator"]) {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
min-height: 2rem;
text-decoration: none;
background-color: var(--background);
padding: 0.25rem;
border: var(--border-width) solid #e5e5e5; /* neutral-200 */
border-radius: var(--rounding);
color: inherit;
}
.pagy a[href]:hover {
background-color: var(--background-hover);
color: var(--text-hover);
}
.pagy a:not([href]) {
cursor: default;
}
.pagy a[role="link"]:not([aria-current]) {
opacity: 0.5;
cursor: not-allowed;
}
.pagy a[aria-current],
.pagy span[aria-current] {
background-color: var(--background-current);
color: var(--text-current);
border-color: var(--background-current);
}
.pagy a[aria-label="Previous"],
.pagy a[aria-label="Next"] {
position: relative;
font-size: 0;
color: var(--text);
}
.pagy a[aria-label="Previous"]::before,
.pagy a[aria-label="Next"]::before {
content: "";
display: block;
width: 1rem;
height: 1rem;
margin: auto;
background-size: 0.75rem 0.75rem;
background-repeat: no-repeat;
background-position: center;
}
.pagy a[aria-label="Previous"]::before {
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cg fill='none' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' stroke='currentColor'%3E%3Cpolyline points='7.75 1.75 3.5 6 7.75 10.25'%3E%3C/polyline%3E%3C/g%3E%3C/svg%3E");
}
.pagy a[aria-label="Next"]::before {
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cg fill='none' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' stroke='currentColor'%3E%3Cpolyline points='4.25 10.25 8.5 6 4.25 1.75'%3E%3C/polyline%3E%3C/g%3E%3C/svg%3E");
}
.pagy [role="separator"] {
background: transparent;
color: #a3a3a3; /* neutral-400 */
padding: 0.25rem 0.4rem;
border: none;
box-shadow: none;
}
.pagy label {
white-space: nowrap;
display: inline-block;
border: var(--border-width) solid #e5e5e5;
border-radius: var(--rounding);
background-color: var(--background);
padding: 0.2rem 0.5rem;
}
.pagy label input {
all: unset;
border: 1px solid #e5e5e5;
border-radius: calc(var(--rounding) / 2) !important;
background-color: var(--background-input);
padding: 0.25rem 0.5rem;
font-size: 0.85rem;
color: var(--text);
}
.pagy-compact .pagy {
--spacing: 0.1rem;
--padding: 0.55rem;
--rounding: 0.35rem;
}
.dark .pagy {
/* dark (tailwind neutral) */
--text: #f5f5f5; /* neutral-100 */
--text-hover: #ffffff;
--text-current: #0a0a0a; /* neutral-950 */
--background: #262626; /* neutral-800 */
--background-hover: #404040; /* neutral-700 */
--background-current: #f5f5f5; /* neutral-100 */
--background-input: #262626; /* neutral-800 */
}
.dark .pagy a:not([role="separator"]) {
border-color: #ffffff1a; /* white/10 */
background-color: var(--background);
}
.dark .pagy a:not([role="separator"]):hover {
border-color: #ffffff1a; /* white/10 */
background-color: var(--background-hover);
color: var(--text-hover);
}
.dark .pagy a[aria-current]:hover {
background-color: var(--background-current);
color: var(--text-current);
}
.dark .pagy a[aria-current] {
border-color: var(--background-current);
background-color: var(--background-current);
}
.dark .pagy a[aria-label="Previous"]::before {
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cg fill='none' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.2' stroke='%23f5f5f5'%3E%3Cpolyline points='7.75 1.75 3.5 6 7.75 10.25'%3E%3C/polyline%3E%3C/g%3E%3C/svg%3E");
}
.dark .pagy a[aria-label="Next"]::before {
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cg fill='none' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.2' stroke='%23f5f5f5'%3E%3Cpolyline points='4.25 10.25 8.5 6 4.25 1.75'%3E%3C/polyline%3E%3C/g%3E%3C/svg%3E");
}
.dark .pagy [role="separator"] {
color: #a3a3a3; /* neutral-400 */
}
.dark .pagy label {
border-color: #262626;
background-color: var(--background);
}
.dark .pagy label input {
border-color: #262626;
background-color: var(--background-input);
color: var(--text);
}
/* Loading state for Turbo Frame pagination */
turbo-frame[aria-busy="true"] .pagy {
position: relative;
pointer-events: none;
opacity: 0.6;
transition: opacity 0.15s ease;
}
/* Loading indicator with overlay */
turbo-frame[aria-busy="true"][data-pagy-loading-indicator] {
position: relative;
cursor: wait;
pointer-events: none;
}
/* Semi-transparent overlay */
turbo-frame[aria-busy="true"][data-pagy-loading-indicator]::before {
content: "";
position: absolute;
inset: 0;
background-color: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(2px);
z-index: 10;
opacity: 0;
animation: pagy-fade-in 0.15s ease forwards;
}
.dark turbo-frame[aria-busy="true"][data-pagy-loading-indicator]::before {
background-color: rgba(23, 23, 23, 0.75);
}
/* Spinning loader */
turbo-frame[aria-busy="true"][data-pagy-loading-indicator]::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 24px;
height: 24px;
border: 2.5px solid #e5e5e5;
border-top-color: #171717;
border-radius: 50%;
opacity: 0;
animation: pagy-spin 0.6s linear infinite, pagy-fade-in 0.15s ease forwards;
z-index: 11;
pointer-events: none;
}
.dark turbo-frame[aria-busy="true"][data-pagy-loading-indicator]::after {
border-color: #404040;
border-top-color: #f5f5f5;
}
@keyframes pagy-spin {
to {
transform: translate(-50%, -50%) rotate(360deg);
}
}
@keyframes pagy-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Mobile responsive styles */
@media (max-width: 640px) {
.pagy {
--spacing: 0.1rem;
--padding: 0.3rem;
--font-size: 0.75rem;
}
.pagy a:not([role="separator"]) {
min-width: 1.75rem;
min-height: 1.75rem;
padding: 0.1rem;
}
.pagy a[role="separator"] {
padding: 0.15rem 0.2rem;
font-size: 0.75rem;
}
.pagy label {
padding: 0.1rem 0.3rem;
}
.pagy label input {
padding: 0.15rem 0.3rem;
font-size: 0.75rem;
}
}
Examples
Note that a sleep(1) is added to the controller to simulate a slow response from the server so you can see the loading states.
Basic pagination
Clean pagination with numbered page buttons and item count display.
devise
Flexible authentication solution
sidekiq
Simple, efficient background processing
pagy
The best pagination ruby gem
pundit
Minimal authorization through OO design
kaminari
A Scope & Engine based paginator
ransack
Object-based searching
draper
View Models for Rails
scenic
Versioned database views for Rails
stimulus-rails
A modest JavaScript framework
turbo-rails
The speed of a SPA
Showing 1–10 of 84
<% frame_id = local_assigns.fetch(:frame_id, "pagy-basic") %>
<turbo-frame id="<%= frame_id %>" data-pagy-loading-indicator class="overflow-hidden w-full rounded-xl border border-black/10 bg-white shadow-xs dark:border-white/10 dark:bg-neutral-900">
<div class="max-h-[300px] overflow-y-auto divide-y divide-black/5 dark:divide-white/5">
<% (gems || []).each do |gem| %>
<div class="flex items-center justify-between gap-4 px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50">
<div class="flex items-center gap-3">
<div class="grid size-8 sm:size-10 place-items-center rounded-lg bg-gradient-to-br from-red-500 to-red-600 text-xs sm:text-sm font-bold text-white shrink-0">
<%= gem[:name][0..1].upcase %>
</div>
<div>
<p class="text-sm font-semibold text-neutral-900 dark:text-white"><%= gem[:name] %></p>
<p class="text-sm text-neutral-500 dark:text-neutral-300"><%= gem[:description] %></p>
</div>
</div>
<div class="text-sm text-neutral-500 dark:text-neutral-300"><%= gem[:downloads] %></div>
</div>
<% end %>
</div>
<div class="border-t border-black/10 px-4 py-3 dark:border-white/10 relative z-20">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<%= render "shared/pagy_info", pagy: pagy %>
<%= render "shared/pagy_nav", pagy: pagy, frame_id: frame_id, preserve_params: request.query_parameters %>
</div>
</div>
</turbo-frame>
# Basic pagination with numbered page buttons
class GemsController < ApplicationController
def index
# @pagy: pagination object with metadata (page, pages, count, etc.)
# @gems: the paginated subset of records for the current page
# limit: number of items per page (default: 20)
@pagy, @gems = pagy(Gem.all, limit: 10)
end
end
Pagination with jump to page
Adds a jump-to-page input for quick navigation to specific pages.
devise
Flexible authentication solution
sidekiq
Simple, efficient background processing
pagy
The best pagination ruby gem
pundit
Minimal authorization through OO design
kaminari
A Scope & Engine based paginator
ransack
Object-based searching
draper
View Models for Rails
scenic
Versioned database views for Rails
stimulus-rails
A modest JavaScript framework
turbo-rails
The speed of a SPA
Showing 1–10 of 84
<% frame_id = local_assigns.fetch(:frame_id, "pagy-jump-to-page") %>
<turbo-frame id="<%= frame_id %>" data-pagy-loading-indicator class="overflow-hidden w-full rounded-xl border border-black/10 bg-white shadow-xs dark:border-white/10 dark:bg-neutral-900">
<div class="max-h-[300px] overflow-y-auto divide-y divide-black/5 dark:divide-white/5">
<% (gems || []).each do |gem| %>
<div class="flex items-center justify-between gap-4 px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50">
<div class="flex items-center gap-3">
<div class="grid size-8 sm:size-10 place-items-center rounded-lg bg-gradient-to-br from-red-500 to-red-600 text-xs sm:text-sm font-bold text-white shrink-0">
<%= gem[:name][0..1].upcase %>
</div>
<div>
<p class="text-sm font-semibold text-neutral-900 dark:text-white"><%= gem[:name] %></p>
<p class="text-sm text-neutral-500 dark:text-neutral-300"><%= gem[:description] %></p>
</div>
</div>
<div class="text-sm text-neutral-500 dark:text-neutral-300"><%= gem[:downloads] %></div>
</div>
<% end %>
</div>
<div class="border-t border-black/10 px-4 py-3 dark:border-white/10 relative z-20">
<div class="flex flex-col items-center gap-3">
<%= render "shared/pagy_info", pagy: pagy %>
<div class="flex flex-wrap items-center justify-center gap-3">
<%= render "shared/pagy_nav",
pagy: pagy,
frame_id: frame_id,
preserve_params: request.query_parameters %>
<%= render "shared/pagy_page_form",
pagy: pagy,
frame_id: frame_id,
preserve_params: request.query_parameters %>
</div>
</div>
</div>
</turbo-frame>
# Pagination with jump to page input
class GemsController < ApplicationController
def index
# @pagy: pagination object with metadata (page, pages, count, etc.)
# @gems: the paginated subset of records for the current page
# The jump-to-page input works automatically with the standard pagy object
@pagy, @gems = pagy(Gem.all, limit: 10)
end
end
Pagination with row selection
Adds a row selection input for quick navigation to specific pages.
devise
Flexible authentication solution
sidekiq
Simple, efficient background processing
pagy
The best pagination ruby gem
pundit
Minimal authorization through OO design
kaminari
A Scope & Engine based paginator
ransack
Object-based searching
draper
View Models for Rails
scenic
Versioned database views for Rails
stimulus-rails
A modest JavaScript framework
turbo-rails
The speed of a SPA
Showing 1–10 of 84
<% frame_id = local_assigns.fetch(:frame_id, "pagy-row-selection") %>
<turbo-frame id="<%= frame_id %>" data-pagy-loading-indicator class="overflow-hidden w-full rounded-xl border border-black/10 bg-white shadow-xs dark:border-white/10 dark:bg-neutral-900">
<div class="max-h-[300px] overflow-y-auto divide-y divide-black/5 dark:divide-white/5">
<% (gems || []).each do |gem| %>
<div class="flex items-center justify-between gap-4 px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50">
<div class="flex items-center gap-3">
<div class="grid size-8 sm:size-10 place-items-center rounded-lg bg-gradient-to-br from-red-500 to-red-600 text-xs sm:text-sm font-bold text-white shrink-0">
<%= gem[:name][0..1].upcase %>
</div>
<div>
<p class="text-sm font-semibold text-neutral-900 dark:text-white"><%= gem[:name] %></p>
<p class="text-sm text-neutral-500 dark:text-neutral-300"><%= gem[:description] %></p>
</div>
</div>
<div class="text-sm text-neutral-500 dark:text-neutral-300"><%= gem[:downloads] %></div>
</div>
<% end %>
</div>
<div class="border-t border-black/10 px-4 py-3 dark:border-white/10 relative z-20">
<div class="flex flex-col gap-3 lg:flex-row items-center justify-between">
<%= render "shared/pagy_info", pagy: pagy %>
<%= render "shared/pagy_nav",
pagy: pagy,
frame_id: frame_id,
preserve_params: request.query_parameters %>
<%= render "shared/pagy_limit_form",
pagy: pagy,
frame_id: frame_id,
preserve_params: request.query_parameters,
limit_options: [10, 25, 50] %>
</div>
</div>
</turbo-frame>
# Pagination with customizable items per page
class GemsController < ApplicationController
def index
# Only allow 10, 25, or 50 items per page; default to 10 if invalid
# This prevents users from requesting arbitrary limits (e.g., 999,999)
limit = ([params[:limit].to_i] & [10, 25, 50]).first || 10
# @pagy: pagination object with metadata (page, pages, count, etc.)
# @gems: the paginated subset of records for the current page
@pagy, @gems = pagy(Gem.all, limit: limit)
end
end
Previous / Next only
Minimal navigation with just previous and next buttons.
devise
Flexible authentication solution
sidekiq
Simple, efficient background processing
pagy
The best pagination ruby gem
pundit
Minimal authorization through OO design
kaminari
A Scope & Engine based paginator
ransack
Object-based searching
draper
View Models for Rails
scenic
Versioned database views for Rails
stimulus-rails
A modest JavaScript framework
turbo-rails
The speed of a SPA
<% frame_id = local_assigns.fetch(:frame_id, "pagy-prev-next") %>
<turbo-frame id="<%= frame_id %>" data-pagy-loading-indicator class="overflow-hidden w-full rounded-xl border border-black/10 bg-white shadow-xs dark:border-white/10 dark:bg-neutral-900">
<div class="max-h-[300px] overflow-y-auto divide-y divide-black/5 dark:divide-white/5">
<% (gems || []).each do |gem| %>
<div class="flex items-center justify-between gap-4 px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50">
<div class="flex items-center gap-3">
<div class="grid size-8 sm:size-10 place-items-center rounded-lg bg-gradient-to-br from-red-500 to-red-600 text-xs sm:text-sm font-bold text-white shrink-0">
<%= gem[:name][0..1].upcase %>
</div>
<div>
<p class="text-sm font-semibold text-neutral-900 dark:text-white"><%= gem[:name] %></p>
<p class="text-sm text-neutral-500 dark:text-neutral-300"><%= gem[:description] %></p>
</div>
</div>
<div class="text-sm text-neutral-500 dark:text-neutral-300"><%= gem[:downloads] %></div>
</div>
<% end %>
</div>
<div class="border-t border-black/10 px-4 py-3 dark:border-white/10 relative z-20">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-3">
<span class="text-sm text-neutral-500 dark:text-neutral-400">
Page <span class="font-medium text-neutral-900 dark:text-white"><%= pagy.page %></span> of <span class="font-medium text-neutral-900 dark:text-white"><%= pagy.pages %></span>
</span>
<div class="border-l border-black/10 h-4 dark:border-white/10"></div>
<%= render "shared/pagy_info", pagy: pagy %>
</div>
<%= render "shared/pagy_prev_next", pagy: pagy, frame_id: frame_id, preserve_params: request.query_parameters %>
</div>
</div>
</turbo-frame>
# Simple previous/next pagination
class GemsController < ApplicationController
def index
# @pagy: pagination object with metadata (page, pages, count, etc.)
# @gems: the paginated subset of records for the current page
# Minimal setup: prev/next buttons work with standard pagy object
@pagy, @gems = pagy(Gem.all, limit: 10)
end
end
Configuration
The pagination component is powered by Pagy, a fast and lightweight pagination library. Below are the most common configuration options and usage patterns.
Basic usage
In your controller, use the pagy method to paginate your collection:
# app/controllers/users_controller.rb
def index
# Returns two values:
# @pagy: pagination metadata (page, pages, count, from, to, etc.)
# @users: the paginated subset of records for the current page
@pagy, @users = pagy(User.all, limit: 25)
end
Understanding pagy options
The pagy method accepts several useful options:
@pagy, @records = pagy(
collection, # Any collection (ActiveRecord, Array, etc.)
limit: 25, # Items per page (default: 20)
page: params[:page], # Current page (automatically read from params)
page_key: "page", # URL param name for page (useful for multiple tables)
limit_key: "limit", # URL param name for limit (useful for multiple tables)
overflow: :last_page # Redirect invalid pages to last page (prevents errors)
)
Turbo-friendly view pattern
Wrap your list and pagination in a Turbo Frame so the nav updates without full-page reloads:
<turbo-frame id="articles">
<%= render @articles %>
<%= render "shared/pagination", pagy: @pagy, turbo_frame: "articles" %>
</turbo-frame>
Composable partials
Use the shared partials individually to place the info, nav, page input, or limit select anywhere in your layout. Pass preserve_params to maintain query parameters across page changes:
<%# shared/pagy_info + shared/pagy_nav + shared/pagy_page_form + shared/pagy_limit_form %>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<%= render "shared/pagy_info", pagy: pagy %>
<div class="flex flex-wrap items-center gap-3">
<%= render "shared/pagy_nav", pagy: pagy, frame_id: "orders-frame", preserve_params: request.query_parameters %>
<%= render "shared/pagy_page_form", pagy: pagy, frame_id: "orders-frame", preserve_params: request.query_parameters %>
<%= render "shared/pagy_limit_form", pagy: pagy, frame_id: "orders-frame", limit_options: [10, 25, 50], preserve_params: request.query_parameters %>
</div>
</div>
Configuration options
Overflow handling
Add overflow: :last_page to gracefully handle out-of-bounds page requests. For example, if a user bookmarks page 10 but you only have 3 pages, they'll be redirected to page 3 instead of seeing an error:
@pagy, @records = pagy(collection, limit: 15, overflow: :last_page)
Preserving query parameters
When using search, filters, or sorting alongside pagination, pass preserve_params to maintain these parameters across page changes:
<%= render "shared/pagy_nav",
pagy: @pagy,
frame_id: "results-frame",
preserve_params: request.query_parameters %>
Partial options
Multiple tables on one page
You can have multiple paginated tables on the same page by using unique parameter names for each table. This keeps each table's pagination independent without conflicts.
Controller setup:
# app/controllers/dashboard_controller.rb
def index
# First table - users
@pagy_users, @users = pagy(
User.all,
page_key: :users_page,
limit_key: :users_limit,
limit: 10
)
# Second table - orders
@pagy_orders, @orders = pagy(
Order.all,
page_key: :orders_page,
limit_key: :orders_limit,
limit: 15
)
end
View setup:
<%# Users Table %>
<turbo-frame id="users-table">
<div class="mb-8">
<h2>Users</h2>
<%# ...table content... %>
<div class="flex items-center justify-between">
<%= render "shared/pagy_info", pagy: @pagy_users %>
<div class="flex gap-3">
<%= render "shared/pagy_nav",
pagy: @pagy_users,
frame_id: "users-table",
page_key: "users_page",
limit_key: "users_limit",
preserve_params: request.query_parameters %>
</div>
</div>
</div>
</turbo-frame>
<%# Orders Table %>
<turbo-frame id="orders-table">
<div>
<h2>Orders</h2>
<%# ...table content... %>
<div class="flex items-center justify-between">
<%= render "shared/pagy_info", pagy: @pagy_orders %>
<div class="flex gap-3">
<%= render "shared/pagy_nav",
pagy: @pagy_orders,
frame_id: "orders-table",
page_key: "orders_page",
limit_key: "orders_limit",
preserve_params: request.query_parameters %>
</div>
</div>
</div>
</turbo-frame>
The URL will include both tables' parameters: /dashboard?users_page=2&orders_page=3. Each table updates independently via Turbo, and preserve_params ensures both tables maintain each other's pagination state.
Advanced features
- Turbo Frame Support: Seamless integration with Hotwire Turbo for instant page updates
- Query Parameter Preservation: Maintain search, filter, and sort parameters across pages
- Composable Partials: Mix and match navigation, info, page jump, and limit controls
- Multiple Tables: Support for multiple independent paginated tables on one page
- Overflow Protection: Gracefully handle invalid page requests
- Lightweight & Fast: Pagy is one of the fastest pagination libraries available
Learn more
These components cover the most common pagination patterns. For advanced features like infinite scroll, countless pagination, calendar pagination, and more, check out the comprehensive Pagy documentation.