← All posts

Adding Ransack Search & Pagination to ResourceController

January 15, 2026 rails

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_attributes and ransackable_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 when params[: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_tag to 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.
Let’s work together! Tell Me About Your Project