Adding Ransack Search & Pagination to ResourceController
In the previous post we built a ResourceController that handles CRUD for any model. The index action returns all records with a default sort. That’s fine for small datasets, but most admin interfaces need search and pagination.
This post adds both by integrating Ransack for query building and Pagy for pagination into the base controller. Subclasses get search and pagination automatically — no extra code needed.
Install the Gems
# Gemfile
gem "ransack"
gem "pagy"
bundle install
Include the Pagy backend and frontend helpers:
app/controllers/application_controller.rb:
class ApplicationController < ActionController::Base
include Pagy::Backend
end
app/helpers/application_helper.rb:
module ApplicationHelper
include Pagy::Frontend
end
Update the Base Controller
Add Ransack and Kaminari to the index action in ResourceController:
app/controllers/resource_controller.rb:
class ResourceController < ApplicationController
before_action :set_resource, only: [:show, :edit, :update, :destroy]
def index
@q = resource_scope.ransack(params[:q])
@q.sorts = default_sort_string if @q.sorts.empty?
@pagy, @resources = pagy(@q.result(distinct: true), items: per_page)
instance_variable_set("@#{resource_name.pluralize}", @resources)
end
# show, new, create, edit, update, destroy unchanged...
private
def resource_class
raise NotImplementedError
end
def resource_params
raise NotImplementedError
end
def resource_scope
resource_class.all
end
def resource_name
resource_class.model_name.singular
end
def default_sort_string
"created_at desc"
end
def per_page
25
end
def set_resource
@resource = resource_scope.find(params[:id])
instance_variable_set("@#{resource_name}", @resource)
end
end
The changes are in index. ransack(params[:q]) builds a query object from search parameters. If no sort is applied, it falls back to default_sort_string. The result is paginated with Pagy — pagy() returns both a pagination metadata object and the paginated records.
Note that default_sort changed from a hash ({ created_at: :desc }) to a string ("created_at desc"). Ransack uses its own sort format — a column name and direction separated by a space.
The Search Form
Add a Ransack search form to your index view. Ransack provides search_form_for which generates the right parameter structure automatically:
app/views/users/index.html.erb:
<%= search_form_for @q, url: users_path do |f| %>
<div class="row g-3 mb-4">
<div class="col-md-4">
<%= f.label :name_cont, "Name", class: "form-label" %>
<%= f.search_field :name_cont, class: "form-control", placeholder: "Search by name..." %>
</div>
<div class="col-md-4">
<%= f.label :email_cont, "Email", class: "form-label" %>
<%= f.search_field :email_cont, class: "form-control", placeholder: "Search by email..." %>
</div>
<div class="col-md-2">
<%= f.label :role_eq, "Role", class: "form-label" %>
<%= f.select :role_eq, User::ROLES, { include_blank: "All" }, class: "form-select" %>
</div>
<div class="col-md-2 d-flex align-items-end">
<%= f.submit "Search", class: "btn btn-primary me-2" %>
<%= link_to "Clear", users_path, class: "btn btn-outline-secondary" %>
</div>
</div>
<% end %>
Ransack predicates like _cont (contains), _eq (equals), _gt (greater than) determine how each field is matched. The Ransack docs list all available predicates.
Pagination
Add Pagy’s pagination helper below your table:
<table class="table">
<thead>
<tr>
<th><%= sort_link(@q, :name) %></th>
<th><%= sort_link(@q, :email) %></th>
<th><%= sort_link(@q, :role) %></th>
<th><%= sort_link(@q, :created_at, "Joined") %></th>
</tr>
</thead>
<tbody>
<% @users.each do |user| %>
<tr>
<td><%= link_to user.name, user %></td>
<td><%= user.email %></td>
<td><%= user.role.titleize %></td>
<td><%= user.created_at.strftime("%b %d, %Y") %></td>
</tr>
<% end %>
</tbody>
</table>
<%== pagy_bootstrap_nav(@pagy) %>
sort_link renders a clickable column header that toggles between ascending and descending. pagy_bootstrap_nav renders Bootstrap-styled page numbers. Both preserve the current search parameters automatically.
A Reusable Search Partial
If your search forms follow a consistent layout, extract a partial:
app/views/components/_search_form.html.erb:
<%= search_form_for @q, url: url do |f| %>
<div class="row g-3 mb-4">
<%= yield f %>
<div class="col-auto d-flex align-items-end">
<%= f.submit "Search", class: "btn btn-primary me-2" %>
<%= link_to "Clear", url, class: "btn btn-outline-secondary" %>
</div>
</div>
<% end %>
Use it in any index view:
<%= render layout: "components/search_form", locals: { url: users_path } do |f| %>
<div class="col-md-4">
<%= f.search_field :name_cont, class: "form-control", placeholder: "Name..." %>
</div>
<div class="col-md-4">
<%= f.search_field :email_cont, class: "form-control", placeholder: "Email..." %>
</div>
<% end %>
Each view defines only its own search fields. The submit button, clear link, and form structure come from the partial.
Connecting to Tabulator
If you’re using the Tabulator integration for your tables, the JSON endpoint already works with Ransack. Pass the search parameters through:
class Api::UsersController < ResourceController
def index
@q = resource_scope.ransack(params[:q])
@q.sorts = default_sort_string if @q.sorts.empty?
pagy, users = pagy(@q.result(distinct: true), items: params[:size] || per_page)
render json: {
data: UserDecorator.decorate(users),
last_page: pagy.last
}
end
private
def resource_class
User
end
def resource_params
params.require(:user).permit(:name, :email, :role)
end
end
Tabulator sends its filter and sort parameters, which Ransack picks up from params[:q]. The decorator formats the data. The same pipeline — search, paginate, decorate, serialize — works for every resource.
Customizing Per Subclass
Subclasses can override default_sort_string and per_page:
class InvoicesController < ResourceController
private
def resource_class
Invoice
end
def resource_params
params.require(:invoice).permit(:number, :client_id, :amount, :status)
end
def default_sort_string
"due_on asc"
end
def per_page
50
end
end
Invoices sort by due date and show 50 per page. No changes to the base class needed.
Tips
- Whitelist searchable attributes. By default Ransack allows searching any column. Lock it down in your model with
ransackable_attributesandransackable_associations:
class User < ApplicationRecord
def self.ransackable_attributes(auth_object = nil)
%w[name email role created_at]
end
def self.ransackable_associations(auth_object = nil)
[]
end
end
- Use
distinct: true. Without it, joins on associations can return duplicate rows. The performance cost is negligible and it prevents confusing results. - Pagy handles nil pages.
pagy()defaults to page 1 whenparams[:page]is nil — no need to set a fallback. - Use
<%== %>for Pagy helpers. Pagy returns raw HTML strings, so use the double-equals ERB tag to output without escaping. - Turbo Frame search. Wrap the search form and results table in a
turbo_frame_tagto make search submit without a full page reload. The form, table, and pagination all update within the frame. - Don’t over-search. Not every column needs a search field. Expose the 2-3 fields users actually filter by. You can always add more later.