class ProductsController < ApplicationController
PER_PAGE = 24
def index
@page = [params[:page].to_i, 1].max
@sort = %w[name price recent].include?(params[:sort]) ? params[:sort] : "recent"
scope = Product.available.order(sort_column(@sort))
@total_pages = (scope.count.to_f / PER_PAGE).ceil
@products = scope.offset((@page - 1) * PER_PAGE).limit(PER_PAGE)
end
private
def sort_column(sort)
case sort
when "name" then { name: :asc }
when "price" then { price_cents: :asc }
else { created_at: :desc }
end
end
end
<h1>Catalog</h1>
<%= turbo_frame_tag "products", data: { turbo_action: "advance" } do %>
<div class="toolbar">
<%= render "sort_control", sort: @sort %>
</div>
<div class="product-grid">
<% @products.each do |product| %>
<%= render "products/card", product: product %>
<% end %>
</div>
<%= render "pager", page: @page, total_pages: @total_pages, sort: @sort %>
<% end %>
<nav class="pager" aria-label="Pagination">
<% if page > 1 %>
<%= link_to "← Prev", products_path(page: page - 1, sort: sort), class: "pager__link" %>
<% end %>
<% (1..total_pages).each do |n| %>
<% if n == page %>
<span class="pager__link is-current" aria-current="page"><%= n %></span>
<% else %>
<%= link_to n, products_path(page: n, sort: sort), class: "pager__link" %>
<% end %>
<% end %>
<% if page < total_pages %>
<%= link_to "Next →", products_path(page: page + 1, sort: sort), class: "pager__link" %>
<% end %>
<div class="pager__jump">
<%# Jumping to page 1 is incidental state — do not push a history entry. %>
<%= link_to "First page", products_path(page: 1, sort: sort),
class: "pager__reset",
data: { turbo_action: "replace" } %>
</div>
</nav>
This snippet demonstrates how Hotwire's Turbo Drive and Turbo Frames interact with the browser History API through the data-turbo-action attribute. When a Turbo Frame navigates, it normally does not touch the URL or push a history entry. Setting data-turbo-action="advance" promotes a frame navigation into a full history push, so the frame's src becomes the visible URL and a new back-button entry is created. Setting data-turbo-action="replace" instead swaps the current entry in place, changing the URL without growing the history stack.
The ProductsController in the first tab is a plain Rails index action with keyset-free offset pagination via a page param. It renders normally on full loads and renders the same view when a frame requests a page, because Turbo Frame requests carry a Turbo-Frame header that Rails handles transparently — no special branch is needed here.
The index.html.erb tab wraps the product grid and pager inside a turbo_frame_tag named products. The key detail is data: { turbo_action: "advance" } on the frame: this tells Turbo that any navigation landing in this frame should advance history and update the address bar. As a result, paginating deep into the catalog leaves the browser back button working exactly as a user expects, and the current page number is shareable and bookmarkable.
The _pager.html.erb partial shows the trade-off in miniature. The numbered page links inherit advance from the frame, so each click is a distinct history entry. The compact "filter" style links use data: { turbo_action: "replace" } at the link level, which overrides the frame default for that single navigation — appropriate for state that should not pollute history, like toggling a sort order the user will tweak repeatedly.
The practical rule is: use advance for navigations a user would want to reverse with the back button, and replace for incidental state changes. A common pitfall is enabling advance on a frame whose src diverges from the real controller route, producing URLs that 404 on refresh; keeping frame src values aligned with actual routes, as done in _pager.html.erb, avoids that.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
Share this code
Here's the card — post it anywhere.