Turbo Frames: Update Just Part of Your Page
Turbo Drive updates the whole page. But what if you only want to update part of it? A sidebar. A modal. A comment thread. A paginated list.
That's what Turbo Frames are for. They let you carve your page into independent regions that navigate without affecting anything else.
What Turbo Frames Do
A Turbo Frame is a section of your page that can navigate independently. Wrap content in a
<turbo-frame> tag, and links inside it only update that frame.<turbo-frame id="messages"> <h2>Messages</h2> <a href="/messages/1">Message 1</a> <a href="/messages/2">Message 2</a> </turbo-frame>
Click a link inside that frame:
1. Turbo fetches
/messages/12. Turbo finds the matching
<turbo-frame id="messages"> in the response3. Turbo swaps just that frame's content
4. Everything outside the frame stays untouched
Think of it like an iframe, but better. No cross-origin restrictions. Shares your CSS and JavaScript. Part of the same DOM.
A Simple Example
Here's an index page with a message list inside a frame:
<header>My App</header>
<turbo-frame id="messages">
<h2>Your Messages</h2>
<ul>
<li><a href="/messages/1">Hey, how are you?</a></li>
<li><a href="/messages/2">Meeting at 3pm</a></li>
</ul>
</turbo-frame>
<footer>© 2025</footer>
And the detail page at
/messages/1:<header>My App</header> <turbo-frame id="messages"> <h2>Message from Alice</h2> <p>Hey, how are you? Want to grab coffee?</p> <a href="/messages">← Back</a> </turbo-frame> <footer>© 2025</footer>
Click the first message. Turbo fetches the detail page, extracts the matching frame, swaps it in. Header and footer never change. Your server still renders complete HTML pages—Turbo just extracts the part it needs.
This means your routes work with or without JavaScript. Direct links work. Search engines can crawl everything. Progressive enhancement done right.
The ID Matching Rule
The frame's
id in the response must match the frame that initiated the request. If they don't match, Turbo shows an error.<!-- Page A --> <turbo-frame id="comments"> <a href="/comments/new">Add Comment</a> </turbo-frame> <!-- /comments/new must have --> <turbo-frame id="comments"> <form action="/comments" method="post">...</form> </turbo-frame>
This is intentional. It prevents accidentally replacing the wrong part of your page.
Lazy Loading
Frames can load content on demand. Add a
src attribute and Turbo fetches it automatically:<turbo-frame id="comments" src="/posts/1/comments"> <p>Loading comments...</p> </turbo-frame>
By default that loads immediately (eager loading). Add
loading="lazy" to defer until the frame scrolls into view:<turbo-frame id="comments" src="/posts/1/comments" loading="lazy"> <p>Loading comments...</p> </turbo-frame>
Use cases: comments below articles, analytics dashboards, related products below the fold. Anything expensive that users might not scroll to.
Breaking Out of Frames
Sometimes a link inside a frame should navigate the whole page. Use
data-turbo-frame="_top":<turbo-frame id="modal">
<form action="/profile" method="post">
<input name="name" value="Alice">
<button>Save</button>
</form>
<!-- This navigates the whole page -->
<a href="/dashboard" data-turbo-frame="_top">Cancel</a>
</turbo-frame>
Targeting options:
•
data-turbo-frame="_top" — Navigate the whole page•
data-turbo-frame="_self" — Navigate this frame (default)•
data-turbo-frame="other-id" — Navigate a different frameCross-Frame Navigation
You can target a different frame entirely. Classic sidebar + main content pattern:
<turbo-frame id="sidebar">
<nav>
<a href="/dashboard" data-turbo-frame="main">Dashboard</a>
<a href="/analytics" data-turbo-frame="main">Analytics</a>
<a href="/settings" data-turbo-frame="main">Settings</a>
</nav>
</turbo-frame>
<turbo-frame id="main">
<!-- Content loads here -->
</turbo-frame>
Clicking sidebar links updates the
main frame, not the sidebar.Updating the URL
By default, frame navigation does NOT update the browser URL. The URL stays the same while the frame content changes.
If you want the URL to update (for bookmarking, sharing, back button), add
data-turbo-action="advance":<turbo-frame id="products"> <a href="/products?page=2" data-turbo-action="advance">Next Page</a> </turbo-frame>
Now clicking "Next Page" updates the URL to
/products?page=2 and adds a history entry. Back button works.Forms in Frames
Forms inside frames work the same way as links. Submit the form, the response updates just that frame.
Inline editing example:
<turbo-frame id="user-name">
<p>
<strong>Name:</strong> Alice Smith
<a href="/profile/edit">Edit</a>
</p>
</turbo-frame>
Click "Edit", Turbo fetches the form:
<turbo-frame id="user-name">
<form action="/profile" method="post">
<input name="name" value="Alice Smith">
<button>Save</button>
<a href="/profile">Cancel</a>
</form>
</turbo-frame>
Submit the form, server responds with updated frame:
<turbo-frame id="user-name">
<p>
<strong>Name:</strong> Alice Johnson
<a href="/profile/edit">Edit</a>
</p>
</turbo-frame>
Inline editing with zero JavaScript. If validation fails, just render the form again with errors—Turbo swaps it in.
Real-World Patterns
Pagination:
<turbo-frame id="products">
<div class="product">Product 1</div>
<div class="product">Product 2</div>
<nav>
<a href="/products?page=1" data-turbo-action="advance">1</a>
<a href="/products?page=2" data-turbo-action="advance">2</a>
</nav>
</turbo-frame>
Page numbers update only the product list. Filters, header, footer stay put.
Modals:
<!-- Trigger --> <a href="/posts/new" data-turbo-frame="modal">New Post</a> <!-- Empty frame that receives modal content --> <turbo-frame id="modal"></turbo-frame>
Server renders the form inside a matching frame. Form appears in the modal. Submit creates the post.
Tabs:
<div class="tabs"> <a href="/settings/profile" data-turbo-frame="tab-content">Profile</a> <a href="/settings/security" data-turbo-frame="tab-content">Security</a> </div> <turbo-frame id="tab-content"> <!-- Tab content loads here --> </turbo-frame>
Search:
<form action="/search" method="get" data-turbo-frame="results"> <input name="q" type="search" placeholder="Search..."> <button>Search</button> </form> <turbo-frame id="results"> <!-- Results appear here --> </turbo-frame>
Permanent Elements
Some things shouldn't reset when a frame updates. Audio players, for example. Mark them with
data-turbo-permanent:<div id="audio-player" data-turbo-permanent> <audio src="/stream.mp3" autoplay></audio> </div>
Element must have an
id and exist on both pages.Disabling Frames
Add
disabled to make links and forms inside the frame use normal navigation:<turbo-frame id="messages" disabled> <!-- Normal navigation, not scoped to frame --> </turbo-frame>
Common Mistakes
"Response has no matching turbo-frame": Your server's response doesn't include a frame with the expected ID. Make sure the response HTML has
<turbo-frame id="..."> with the matching ID.Link navigates whole page instead of frame: Either the link has
data-turbo-frame="_top", or the link is outside the frame. Move it inside, or remove the _top target.Form submission navigates whole page: Server is redirecting to a URL that doesn't have the frame. Either render the updated frame directly (no redirect), or redirect to a page that includes the frame.
When to Use Frames vs Drive
Use Turbo Drive when the entire page should change. Standard page-to-page navigation.
Use Turbo Frames when only part of the page should update. Modals, tabs, pagination, sidebars, inline editing.
Most apps use both. Drive handles primary navigation. Frames handle specific interactive regions.
The Bottom Line
Turbo Frames decompose your page into independent navigation scopes. Wrap content in
<turbo-frame id="...">, and links inside it only update that region.Key points:
1. IDs must match between request and response
2. Use
src for lazy loading3. Use
data-turbo-frame to target different frames4. Use
data-turbo-action="advance" if you want URL updatesComplex, interactive UIs with simple HTML. No JSON APIs. No client-side state management.
But what about real-time updates? What if the server needs to push changes to the page, not just respond to user actions? That's where Turbo Streams come in. Next post.