Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 019 / 100

Turbo Frames tutorial: a practical guide for developers

Update one section at a time.

Expona Admin7 min read

When we need to update just one section of a page without a full refresh, reaching for a server-side solution beats wrestling with JavaScript frameworks. Turbo Frames—part of Rails' Hotwire stack—lets us do exactly that: swap portions of the DOM with minimal client-side code. This Turbo Frames tutorial: a practical guide for developers walks through real scenarios we encounter in production e-commerce and SaaS platforms, showing how to wrap content, handle navigation, load frames on demand, and break out when full-page navigation is needed.

Wrapping content with turbo_frame_tag

The foundation of Turbo Frames is the turbo_frame_tag helper. It creates a named container on the page that Turbo can target and replace independently. Think of it as marking a region: "when this frame is updated, replace only this part of the DOM."

Let's say we're building a product catalog. Each product has a wishlist button. Without Turbo Frames, clicking that button triggers a full page reload or requires custom AJAX handling. With Turbo Frames, we wrap the wishlist section and let Turbo handle the update.

ruby
<!-- app/views/products/show.html.erb -->
<div class="product-detail">
  <h1><%= @product.name %></h1>
  <p><%= @product.description %></p>

  <%= turbo_frame_tag "wishlist_#{@product.id}" do %>
    <% if current_user.wishlist.include?(@product) %>
      <button>✓ Saved to Wishlist</button>
    <% else %>
      <button>+ Add to Wishlist</button>
    <% end %>
  <% end %>
</div>

When a user clicks that button and submits a form (or clicks a link), Turbo intercepts the request and looks for a matching frame ID in the server response. Only that frame's content gets replaced. The rest of the page—the product title, description, images—remains untouched. This is dramatically faster than re-rendering the entire page, and the user sees an instant, focused update.

Use descriptive frame IDs that are unique within the page. Prefix them with context (like product_id or cart_item_id) to avoid collisions when the same component appears multiple times.

Once we've wrapped a section in turbo_frame_tag, any link or form inside it becomes frame-aware. When a user submits a form or clicks a link, Turbo sends the request and patches the frame with the response—no page reload.

Let's build on our e-commerce example. A product has reviews. We want to load reviews, let users filter them (by rating, for instance), and update just the reviews list without touching the product details.

ruby
<!-- app/views/products/show.html.erb -->
<%= turbo_frame_tag "product_reviews" do %>
  <h2>Customer Reviews</h2>

  <form method="get" action="<%= product_reviews_path(@product) %>">
    <label>Filter by rating:</label>
    <select name="min_rating">
      <option value="">All</option>
      <option value="4">4+ stars</option>
      <option value="3">3+ stars</option>
    </select>
    <button type="submit">Filter</button>
  </form>

  <div class="reviews-list">
    <%= render @reviews %>
  </div>
<% end %>

The form is inside the frame. When a user changes the dropdown and clicks "Filter," the form posts to product_reviews_path. Our controller responds with the filtered reviews, wrapped in the same turbo_frame_tag :product_reviews. Turbo patches just that frame.

ruby
# app/controllers/products_controller.rb
def show
  @product = Product.find(params[:id])
  @reviews = @product.reviews
end

# app/controllers/reviews_controller.rb (or a product reviews action)
def index
  @product = Product.find(params[:product_id])
  @reviews = @product.reviews
  
  if params[:min_rating].present?
    @reviews = @reviews.where("rating >= ?", params[:min_rating])
  end

  render :index, status: :ok
end
ruby
<!-- app/views/reviews/index.html.erb -->
<%= turbo_frame_tag "product_reviews" do %>
  <h2>Customer Reviews</h2>

  <form method="get" action="<%= product_reviews_path(@product) %>">
    <!-- same form as above -->
  </form>

  <div class="reviews-list">
    <%= render @reviews %>
  </div>
<% end %>

The key: the response must contain the same frame ID. Turbo matches by ID and swaps only that element. Links work the same way—<a href="/some/path"> inside a frame triggers a frame update if the response contains that frame.

Lazy loading with the src attribute

Sometimes we don't need a frame's content immediately. On a dashboard, we might have several metric tiles. Loading all of them upfront adds latency. Instead, we lazy-load: render the frame tag with a src attribute pointing to an endpoint, and Turbo fetches and populates it after the initial page load.

This is common in SaaS dashboards and ride-hailing dispatch screens where we want fast initial render and then populate expensive queries asynchronously.

ruby
<!-- app/views/dashboard/index.html.erb -->
<div class="metrics-grid">
  <%= turbo_frame_tag "metrics_revenue", 
                       src: metrics_revenue_path,
                       loading: :lazy do %>
    <div class="skeleton">Loading revenue data...</div>
  <% end %>

  <%= turbo_frame_tag "metrics_users",
                       src: metrics_users_path,
                       loading: :lazy do %>
    <div class="skeleton">Loading user data...</div>
  <% end %>

  <%= turbo_frame_tag "metrics_churn",
                       src: metrics_churn_path,
                       loading: :lazy do %>
    <div class="skeleton">Loading churn data...</div>
  <% end %>
</div>

The page renders instantly with skeleton loaders. Then, as each frame becomes visible (or after a delay), Turbo fetches the content from the src URL and patches it in.

ruby
# app/controllers/metrics_controller.rb
def revenue
  # Expensive query
  @data = Revenue.sum_by_month(12.months.ago..Date.today)
  render :revenue, status: :ok
end
ruby
<!-- app/views/metrics/revenue.html.erb -->
<%= turbo_frame_tag "metrics_revenue" do %>
  <h3>Revenue (12 months)</h3>
  <div class="chart">
    <%= render "chart", data: @data %>
  </div>
<% end %>

Set loading: :lazy to defer the fetch until the frame enters the viewport. Omit it (or use loading: :eager) to fetch immediately after the page loads. This pattern is powerful for dashboards, admin panels, and anywhere initial render speed matters.

Breaking out with target="_top"

Turbo Frames target themselves by default. But sometimes a link or form inside a frame should navigate the whole page, not just the frame. For full-page navigation, we use data-turbo-target="_top".

Imagine a product search results frame. A user filters products, and each result is a link. Clicking a result should take them to the product detail page—a full navigation, not a frame update. We'd add the target attribute:

ruby
<%= turbo_frame_tag "search_results" do %>
  <h2>Search Results</h2>

  <ul>
    <% @products.each do |product| %>
      <li>
        <%= link_to product.name, product_path(product), 
                    data: { turbo_target: "_top" } %>
      </li>
    <% end %>
  </ul>
<% end %>

Without data-turbo-target="_top", clicking the product name would try to load the product detail page into the search results frame—almost certainly an error or wrong behavior. With it, the link navigates the full page.

Similarly, a "Check out" button in a shopping cart frame should go to the checkout page—a full navigation:

ruby
<%= turbo_frame_tag "cart_summary" do %>
  <h3>Cart</h3>
  <p>Items: <%= @cart.items.count %></p>
  <p>Total: <%= number_to_currency(@cart.total) %></p>

  <%= button_to "Proceed to Checkout", checkout_path,
                 data: { turbo_target: "_top" } %>
<% end %>

Use data-turbo-target="_top" judiciously. It's not a default because most interactions within a frame should stay within it. But for cross-section navigation, it's essential.

Common mistakes

  • Mismatched frame IDs. Your view wraps content in turbo_frame_tag :foo, but your response returns turbo_frame_tag :bar. Turbo won't find a match and won't update anything. Always ensure the ID in the initial frame and the response frame are identical.
  • Forgetting the frame in the response. A common trap: you render a partial or action without wrapping it in the same frame tag. The response has no frame, so Turbo has nothing to patch. Every response that targets a frame must include that frame with the same ID.
  • Not handling non-frame requests. If your action is also called via a full-page navigation or API, returning a partial wrapped in a frame won't work. Use Rails' turbo_frame_request? helper or check the request format to return different responses.
  • Nested frames without careful targeting. Frames can nest, but the default behavior is for inner links to target the innermost frame. If you need to target an outer frame from within an inner one, you must explicitly set data-turbo-target.
  • Using src without a matching route. If you specify src: some_path, that path must respond with a view containing the same frame tag. Test the URL directly in your browser to confirm it returns the expected HTML.

Key takeaways

  • Wrap sections with turbo_frame_tag to mark them as independently updateable.
  • Links and forms inside a frame automatically target that frame—no extra configuration needed.
  • Use the src attribute with loading: :lazy to defer frame loading for faster initial page render.
  • Add data-turbo-target="_top" to links or buttons that should navigate the full page, breaking out of the frame.
  • Always ensure response views include the matching frame tag with the same ID.
  • Test frame interactions in your browser's network tab to confirm only the frame is fetched, not the entire page.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles