I’m writing this post because every solution I found involved JavaScript— but I refused to go down that path...
So, in a project I'm working on, we have a listing of contacts that allows filtering and paging. Among the filter options, we include a list of checkboxes for the client’s categories. Until now, we were making a GET request to apply the filters, and everything was working fine—until a client with more than 100 categories started using the page.
The Problem: URL Length Limits
When using GET requests, filter parameters are sent via Query Params, which append data directly to the URL. While this approach works well for a small number of filters, it becomes problematic as the number of selected options grows. Some browsers and web servers impose strict limits on the maximum URL length they can handle, and exceeding these limits can lead to errors.
For example, Google Chrome limits URLs to a maximum length of 2MB. On the server side, web servers like NGINX limits URLs to 8 KB (8,192 characters) via the large_client_header_buffers
directive. If a request exceeds this size, NGINX returns a 414 (URI Too Long) error.
In our case, since each selected category was appended to the URL as a separate query parameter:
?filter[category_ids][]=1&filter[category_ids][]=2&filter[category_ids][]=3... # and so on
the URL quickly grew beyond acceptable limits, leading to a 414 (URI Too Long) error.
Changing the Request to POST
To prevent excessively long URLs, the first step was to change the request from GET to POST, since POST allows us to send data in the request body, keeping the URL short.
In the routes file, we needed to add a POST route pointing to the controller's index
action:
resources :contacts, only: %i[index new create edit update destroy] do
post :filter, on: :collection, action: :index # renamed the route to avoid conflicts
end
Next, we updated the form:
form_with(url: filter_contacts_path, method: :post, ...) do
# ...
end
So far, so good.
New Issue: Pagination
When trying to use filters along with pagination, we encountered the following error:
ActionController::RoutingError (No route matches [GET] "/contacts/filter"):
Pagy makes a GET request, but now we needed the request to be POST.
Additionally, I noticed that when no filters were applied, the generated link pointed to contacts_path
(which needs to be GET). However, when filters were active, the URL was filter_contacts_path
(which needs to be POST).
To simplify this, I set filter_contacts_path
as the default, ensuring the request was always POST. In the controller, I added request_path
:
def index
pagy, posts = pagy(Contact.some_scope, limit: 10, request_path: filter_contacts_path)
end
This fixed the RoutingError, but introduced a new issue:
New Issue: Filters Are Lost on Pagination
The filter was only applied to the first page. When navigating to the second page, the filters disappeared because the parameters were not being sent in the request body. This happens because pagination generates links, not forms.
Solution: Using button_to
The adopted strategy was to use button_to
, as it generates hidden inputs with the received parameters, ensuring the filters are maintained while navigating through pages.
Now, following the Pagy example, the necessary changes would be applied:
<nav class="pagy nav" aria-label="Pages">
<%# Previous page link %>
<% if pagy.prev %>
- <%= link_to(pagy_url_for(pagy, pagy.prev), { aria_label: "Previous" }) do %>
+ <%= button_to(
+ pagy_url_for(pagy, pagy.prev),
+ { params: request.params.except(:page), aria_label: "Previous" }
+ ) do %>
<
<% end %>
<% end %>
<%# Page links %>
<% pagy.series.each do |item| %>
<% if item.is_a?(Integer) %>
- <%= link_to(pagy_url_for(pagy, item)) do %>
+ <%= button_to(pagy_url_for(pagy, item), params: request.params.except(:page)) do %>
<%= item %>
<% end %>
<% elsif item.is_a?(String) %>
- <a role="link" aria-disabled="true" aria-current="page" class="current"><%= item %></a>
+ <%= button_to(nil, { params: request.params.except(:page), aria_current: "page", disabled: true, class: "current" }) do %>
<%= item %>
<% end %>
<% elsif item == :gap %>
- <a role="link" aria-disabled="true" class="gap">…</a>
+ <%= button_to(nil, { params: request.params.except(:page), disabled: true, class: "gap" }) do %>
…
<% end %>
<% end %>
<% end %>
<%# Next page link %>
<% if pagy.next %>
- <%= link_to(pagy_url_for(pagy, pagy.next)), { aria_label: 'Next' }) do %>
+ <%= button_to(
+ pagy_url_for(pagy, pagy.next),
+ { params: request.params.except(:page), aria_label: "next" }
+ ) do %>
>
<% end %>
<% end %>
</nav>
Additionally, it was necessary to remove the page
parameter from the request, as it was being sent in two ways:
- As a hidden field (indicating the current page)
- In the URL (indicating the next page)
If we didn’t remove it, Rails would end up using the current page instead of the next one.
And that’s it! 🎉 Now the page works normally, without gigantic URLs and without adding JavaScript code.
Top comments (1)
Nice, this is the rails-way to solve this problem.