35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Rails Turbo Frames: Build Dynamic UIs Without Writing JavaScript

Rails Turbo Frames: Build Dynamic UIs Without Writing JavaScript

roger
ruby-on-rails
How to use Turbo Frames in Rails 8 to create fast, dynamic interfaces with lazy loading, inline editing, and frame navigation — no custom JavaScript required.

Turbo Frames let you decompose a page into independent sections that load and update without full page reloads. You get the responsiveness of a single-page app using server-rendered HTML. No React. No webpack headaches. Just Rails views and a handful of HTML attributes.

Here’s what a Turbo Frame looks like in practice:

<%%= turbo_frame_tag "user_profile" do %>
  <h2><%%= @user.name %></h2>
  <p><%%= @user.bio %></p>
  <%%= link_to "Edit", edit_user_path(@user) %>
<%% end %>

When a user clicks “Edit,” Rails replaces only the content inside that frame — the rest of the page stays put. The browser doesn’t reload. The server renders a full HTML response, but Turbo extracts just the matching frame.

How Frame Matching Works

Turbo Frames use an id attribute to match content between the current page and the server response. When you click a link or submit a form inside a frame, Turbo:

  1. Sends a standard HTTP request to the server
  2. Receives a full HTML response
  3. Finds the <turbo-frame> with the matching id in the response
  4. Swaps the content of the current frame with the response frame

Your controller doesn’t need to know about Turbo. It renders a normal view. The magic happens client-side through HTML attribute matching.

# app/controllers/users_controller.rb
def edit
  @user = User.find(params[:id])
  # Nothing special here — same controller action as always
end
<%# app/views/users/edit.html.erb %>
<%%= turbo_frame_tag "user_profile" do %>
  <%%= form_with model: @user do |f| %>
    <%%= f.text_field :name %>
    <%%= f.text_area :bio %>
    <%%= f.submit "Save" %>
  <%% end %>
<%% end %>

After form submission, Rails processes the update and renders the show or edit view again. Turbo swaps the frame content. The URL in the browser doesn’t change by default — that’s intentional for inline edits.

Lazy Loading Frames

Frames can load their content after the initial page render. This is useful for expensive queries, third-party data, or sections below the fold.

<%%= turbo_frame_tag "recent_activity", src: user_activity_path(@user), loading: :lazy do %>
  <p>Loading activity...</p>
<%% end %>

The loading: :lazy attribute tells Turbo to fetch the frame content only when it enters the viewport. Without it, Turbo fetches immediately after page load. The placeholder content (“Loading activity…”) displays until the response arrives.

I’ve used lazy frames on a dashboard that pulled analytics from three different services. Initial page load dropped from 2.8 seconds to 600ms. Each analytics panel loaded independently as the user scrolled down.

Controlling Load Timing

<%# Loads immediately after page render (default with src) %>
<%%= turbo_frame_tag "notifications", src: notifications_path %>

<%# Loads when visible in viewport %>
<%%= turbo_frame_tag "notifications", src: notifications_path, loading: :lazy %>

The server endpoint for lazy frames should return a response containing the matching turbo_frame_tag. If the response doesn’t include a matching frame, Turbo raises a frame-missing error in the console.

Breaking Out of Frames

By default, links inside a Turbo Frame navigate within that frame. Sometimes you need a link to break out and navigate the full page. Use data-turbo-frame="_top":

<%%= turbo_frame_tag "search_results" do %>
  <%% @results.each do |result| %>
    <%# This link navigates the whole page, not just the frame %>
    <%%= link_to result.title, result_path(result), data: { turbo_frame: "_top" } %>
  <%% end %>
<%% end %>

You can also set a target on the frame itself to change the default behavior for all links inside it:

<%%= turbo_frame_tag "search_results", target: "_top" do %>
  <%# All links here navigate the full page by default %>
<%% end %>

This is the pattern I reach for with search interfaces. The search form and results live in a frame for instant filtering, but clicking a result loads the full detail page.

Targeting a Different Frame

Links and forms can target a frame other than the one they’re inside. This opens up patterns like tabbed interfaces and master-detail layouts.

<%# Sidebar with navigation links targeting the main content frame %>
<nav>
  <%%= link_to "Profile", user_profile_path, data: { turbo_frame: "main_content" } %>
  <%%= link_to "Settings", user_settings_path, data: { turbo_frame: "main_content" } %>
  <%%= link_to "Billing", user_billing_path, data: { turbo_frame: "main_content" } %>
</nav>

<%# Main content area that gets swapped %>
<%%= turbo_frame_tag "main_content" do %>
  <%%= render "profile" %>
<%% end %>

Each link sends a request to a different endpoint, and Turbo swaps the response into the main_content frame. The sidebar stays untouched. You get tabbed navigation with zero JavaScript and full browser history support if you add Turbo Drive.

Inline Editing Pattern

This is probably the most common Turbo Frame use case. Show a read-only view, click edit, swap in a form, submit, swap back to read-only.

<%# app/views/comments/_comment.html.erb %>
<%%= turbo_frame_tag dom_id(comment) do %>
  <div class="comment">
    <p><%%= comment.body %></p>
    <span class="text-sm text-gray-500"><%%= comment.author.name %></span>
    <%%= link_to "Edit", edit_comment_path(comment) %>
  </div>
<%% end %>
<%# app/views/comments/edit.html.erb %>
<%%= turbo_frame_tag dom_id(@comment) do %>
  <%%= form_with model: @comment do |f| %>
    <%%= f.text_area :body, rows: 3 %>
    <%%= f.submit "Update" %>
    <%%= link_to "Cancel", comment_path(@comment) %>
  <%% end %>
<%% end %>
# app/controllers/comments_controller.rb
def update
  @comment = Comment.find(params[:id])
  if @comment.update(comment_params)
    redirect_to @comment
  else
    render :edit, status: :unprocessable_entity
  end
end

The status: :unprocessable_entity (422) on validation failure is critical in Rails 8. Turbo expects non-redirect responses for failed submissions to use 422, not 200. If you return 200 on a failed form, Turbo treats it as a success and the user won’t see validation errors. This tripped me up for hours the first time — Rails 7+ changed this convention specifically for Turbo compatibility.

Turbo Frames vs Turbo Streams

Frames replace a single rectangular section of the page. Streams can update multiple parts of the page simultaneously. Use frames when:

  • You’re updating one self-contained section
  • The interaction follows a request-response pattern (click link, get new content)
  • You want the simplicity of standard controller actions

Use Turbo Streams when:

  • A single action needs to update multiple unrelated parts of the page
  • You need append, prepend, or remove operations (not just replace)
  • You’re pushing real-time updates via WebSocket

In production, I typically start with frames and graduate to streams only when the UI requires multi-region updates. Frames cover 80% of dynamic UI needs with half the complexity.

Performance Considerations

Turbo Frames make additional HTTP requests. Each frame with a src attribute is a separate round trip. On a page with five lazy frames, that’s five requests after initial load.

Mitigate this with:

Russian doll caching — Cache frame responses aggressively. Since frames render partial views, Rails fragment caching works perfectly:

# app/controllers/dashboards_controller.rb
def activity
  @activities = current_user.activities.recent
  expires_in 5.minutes, public: false
end

Conditional loading — Only add src to frames that the user will actually see:

<%% if current_user.admin? %>
  <%%= turbo_frame_tag "admin_panel", src: admin_panel_path, loading: :lazy %>
<%% end %>

Response size — Frame responses should be lean. You can use a layout that strips navigation and footer:

class FrameResponder < ApplicationController
  layout false  # No layout wrapper for frame responses
end

Or use layout "turbo_frame" with a minimal layout that includes only the essentials.

Common Mistakes

Forgetting the matching frame ID. If your response doesn’t contain a turbo-frame with the same ID, Turbo logs a “Response has no matching <turbo-frame id="…">” error and the frame goes empty. Check your view wraps the content in the correct turbo_frame_tag.

Using 200 for form validation errors. As mentioned above, return status: :unprocessable_entity for failed form submissions. This is a Rails 7+ convention that Turbo depends on.

Nesting frames without thought. Frames can nest, but a link inside an inner frame targets that inner frame by default. If you have deeply nested frames, interactions can target the wrong frame. Use explicit data-turbo-frame attributes to be precise.

Not handling the frame-missing event. When Turbo can’t find a matching frame, it dispatches a turbo:frame-missing event. In development, this surfaces as a console error. In production, the user sees a frozen frame. Add a global handler:

document.addEventListener("turbo:frame-missing", (event) => {
  event.detail.visit(event.detail.response)
})

This falls back to a full page navigation when frame matching fails — a much better experience than a dead UI element.

Frequently Asked Questions

When Should You Use Turbo Frames?

Turbo Frames work best for interactions that update a single page section: inline editing, tabbed content, search filters with instant results, and lazy-loaded panels. If your page has clearly bounded regions that change independently, frames are the right tool.

Do Turbo Frames work with Rails API-only mode?

No. Turbo Frames require server-rendered HTML responses. API-only Rails apps that return JSON can’t use Turbo Frames. You need full Rails views with ERB (or Haml/Slim) templates. If you’re running a separate frontend, Turbo isn’t the right fit.

Can you use Turbo Frames with Devise or other authentication gems?

Yes, but watch for redirect behavior. Devise redirects unauthenticated requests to the sign-in page. If that redirect happens inside a frame, only the frame shows the login form — not the full page. Set data-turbo-frame="_top" on your Devise-protected links, or handle the turbo:frame-missing event to break out of frames on authentication redirects.

How do Turbo Frames affect SEO?

Content inside lazy-loaded frames isn’t present in the initial HTML response. Googlebot processes JavaScript but may not reliably trigger lazy frame loading. For SEO-critical content, render it in the initial page load rather than in a lazy frame. Use lazy frames for authenticated or interactive-only content that doesn’t need indexing.

What’s the browser support for Turbo Frames?

Turbo uses standard web APIs (fetch, MutationObserver, custom elements). It works in all modern browsers: Chrome, Firefox, Safari, and Edge. No IE11 support, but that hasn’t mattered since roughly 2022. The turbo-rails gem (v2.0+ for Rails 8) bundles everything you need.

#rails #hotwire #turbo #turbo-frames #frontend
r

About the Author

Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.

Get in Touch

Share this article

Need Expert Rails Development?

Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise

Schedule a Free Consultation