<h1>Products</h1>
<%= form_with url: products_path, method: :get,
data: { turbo_frame: "products_list", turbo_action: "advance" } do |f| %>
<div class="filters">
<%= f.text_field :q, value: params[:q], placeholder: "Search products" %>
<%= f.select :category,
options_for_select(Product.categories, params[:category]),
include_blank: "All categories" %>
<%= f.select :sort,
options_for_select([["Newest", "recent"], ["Price", "price"]], params[:sort]) %>
<%= f.submit "Apply" %>
</div>
<% end %>
<%= render "products/list", products: @products, pagy: @pagy %>
<%= turbo_frame_tag "products_list" do %>
<% if products.any? %>
<ul class="product-grid">
<% products.each do |product| %>
<li class="card">
<%= link_to product.name, product_path(product) %>
<span class="price"><%= number_to_currency(product.price) %></span>
</li>
<% end %>
</ul>
<nav class="pager">
<%= link_to "Next", url_for(request.query_parameters.merge(page: pagy.next)),
data: { turbo_frame: "products_list", turbo_action: "advance" } if pagy.next %>
</nav>
<% else %>
<p class="empty">No products match those filters.</p>
<% end %>
<% end %>
class ProductsController < ApplicationController
def index
scope = Product.all
scope = scope.search(params[:q]) if params[:q].present?
scope = scope.where(category: params[:category]) if params[:category].present?
scope = apply_sort(scope, params[:sort])
@pagy, @products = pagy(scope, items: 24)
end
def show
@product = Product.find(params[:id])
end
private
def apply_sort(scope, sort)
case sort
when "price"
scope.order(price: :asc)
else
scope.order(created_at: :desc)
end
end
end
This snippet shows the Rails idiom for driving a Turbo Frame from a form that lives outside that frame, so a submit updates one region of the page while leaving the rest intact. The core mechanic is the data-turbo-frame attribute: it tells Turbo which frame should absorb the response, even though the form itself is not nested inside that frame.
In the products/index view, a filter form is rendered above the results. It is built with form_with url: products_path, method: :get and marked with data: { turbo_frame: "products_list" }. Because Turbo intercepts the submission, the GET request is issued, but instead of a full-page navigation Turbo looks in the response for a <turbo-frame id="products_list"> and swaps only that element. The turbo_frame_tag "products_list" below wraps the collection partial, giving Turbo a stable target. Note that the form and the frame share the same id string — that string is the whole contract, and a typo silently falls back to a full-page load.
The ProductsController is deliberately ordinary. It filters by the incoming params and renders normally; there is no special JSON path or respond_to branch. This is the key trade-off of frames versus Stimulus-driven fetches: the server keeps rendering plain HTML and stays framework-agnostic, and the same action serves both a first full-page visit and a frame-scoped re-render. Turbo decides what to keep based purely on matching frame ids.
The products list partial renders the matching <turbo-frame> and its contents. Because Turbo replaces the frame's inner HTML by matching ids, the response can be the full layout — Turbo extracts just the frame — which is why the controller needs no branching.
A common pitfall is pagination or sort links inside the frame navigating the whole page; wrapping them in the same frame keeps them scoped. Another is losing the query string in the URL bar — adding data: { turbo_action: "advance" } promotes the frame navigation to a history entry so filters survive a refresh. This pattern is ideal for search filters, tabs, and master-detail panes where a small, server-rendered region updates independently.
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.