Turbo Frames Decision Guide: When to Update Page Fragments Server-Side
A practical decision guide for using Hotwire Turbo Frames to replace page fragments with server-rendered HTML, including constraints, trade-offs, and verification steps.
13 Jan 2026, 16:43 UTC

The Decision: Replace a Fragment Without Writing JavaScript
You need to refresh a list of comments after a user submits a form, but you do not want to build a JSON API or maintain a custom JavaScript controller. Hotwire Turbo Frames let you mark a region of the page as replaceable. When the browser navigates inside that region, Turbo fetches the new URL and swaps in only the server-rendered HTML that matches the frame. The rest of the page stays untouched.
The useful takeaway: choose Turbo Frames when your update is naturally scoped to one visual region and your server can return an HTML fragment containing the same <turbo-frame> ID. If you need to update multiple unrelated regions at once, or if your server only speaks JSON, frames are the wrong tool.
Constraints and Supported Options
A Turbo Frame is defined by an id attribute. The element either loads immediately from a src URL or captures navigation and form submissions from links and forms inside it. The critical constraint is that the server response must be an HTML fragment that includes a matching <turbo-frame id="..."> element. Returning a full page layout, a JSON payload, or a fragment without the matching ID will break replacement.
Before adopting frames, check which Turbo version your application uses. Frame targeting and response handling changed between Turbo 7 and Turbo 8, so patterns that work in one major version may behave differently in another.
| Approach | Best For | Server Contract | Trade-off |
|---|---|---|---|
| Turbo Frames | Isolated regions (comments, tabs, inline editors) | HTML fragment with matching <turbo-frame id> |
Simple, no custom JS; limited to one frame per navigation |
| Turbo Streams | Broadcast updates to many elements | <turbo-stream> actions over WebSocket or HTTP |
Powerful multi-element updates; requires channel/subscription setup |
| Full page reload | Infrequent navigation, simple apps | Standard HTML response | No JavaScript; loses scroll position and client state |
| Custom fetch + JS | Complex client-side logic, non-HTML APIs | JSON or any format you define | Full control; more code to write, test, and maintain |
Trade-offs to Weigh
Redirects can escape the frame. If a form inside a frame triggers a controller redirect to a URL that does not render the same frame ID, Turbo may perform a full-page navigation instead of a fragment swap. Prefer rendering the frame template directly from the submission action, or use a Turbo Stream response when you need to update multiple targets.
Nested frames add complexity. A frame inside another frame must still satisfy the matching-ID rule for its own request. Debugging becomes harder because the browser may make several requests, and only the innermost matching frame is replaced.
JavaScript execution is not guaranteed. When Turbo swaps frame content, scripts inside the new HTML may not run depending on the version and morphing behavior. If your fragment depends on a third-party widget initialization, verify that it re-initializes after replacement or move that logic outside the frame.
Lazy loading is built in. Setting a src attribute on a <turbo-frame> defers the request until after the initial page render. This is useful for below-the-fold content, but it means the server must handle a second request and the frame will show fallback content until the response arrives.
Concrete Implementation: A Lazy-Loaded Comment Frame
The following Rails example shows a frame that loads comments after the post page renders. The decision here is to keep the controller response as plain HTML rather than introducing a JSON endpoint.
1. Frame markup in the parent view
<!-- app/views/posts/show.html.erb -->
<h1><%= @post.title %></h1>
<div><%= @post.body %></div>
<turbo-frame id="comments" src="<%= post_comments_path(@post) %>">
<p>Loading comments…</p>
</turbo-frame>
2. Controller returns a matching fragment
# app/controllers/comments_controller.rb
class CommentsController < ApplicationController
def index
@comments = Comment.where(post_id: params[:post_id])
# Renders app/views/comments/index.html.erb
end
end
3. Response template wraps content in the same frame ID
<!-- app/views/comments/index.html.erb -->
<turbo-frame id="comments">
<% @comments.each do |comment| %>
<div class="comment"><%= comment.body %></div>
<% end %>
</turbo-frame>
4. Optional: form submission inside the frame
<turbo-frame id="new_comment">
<%= form_with model: [@post, Comment.new] do |f| %>
<%= f.text_area :body %>
<%= f.submit "Post comment" %>
<% end %>
</turbo-frame>
In the create action, render the frame template again rather than redirecting to a path outside the frame. A redirect to a page that lacks the matching frame ID can trigger a full-page load.
How to Verify the Behavior
- Check the installed version. Run
grep '"turbo"' package.jsonorgrep 'turbo-rails' Gemfile.lockin your project root. No special permissions are required. Compare the version against the Turbo release notes for that major version before relying on frame targeting rules. - Inspect the network request. Open your browser's developer tools, reload the page, and look for a request to the frame's
srcURL. The response body must contain<turbo-frame id="comments">(or your chosen ID). If the response is a full HTML layout or JSON, the frame will not replace correctly. - Confirm the swap is scoped. Submit the form or trigger the frame navigation. Only the content inside the frame should change. If the entire page reloads, the response likely did not include the matching frame ID, or a redirect pointed outside the frame.
- Test script execution. If your fragment includes JavaScript, verify that the expected behavior still occurs after the swap. If it does not, plan to initialize that code outside the frame or use a Turbo Stream response instead.
Limitations and Human Review
Turbo Frames reduce client-side code, but they are not a universal replacement for richer front-end architectures. Because frame handling changed between Turbo 7 and Turbo 8, and because morphing rules affect script execution, treat version-specific behavior as something to confirm in your own application. The verification steps above are the practical check: if the network response contains the matching frame and only the frame content swaps, the pattern is working as intended for your installed version.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.