Want to try it yourself? A full working demo is here: turbo-advance-vs-replace. Clone it, run
bin/setupand follow DEMO.md for more tips.
Turbo Frames are one of the best things about Hotwire. Wrap a part of your page in <turbo-frame>, and navigations inside it become fast partial updates instead of full page reloads. No JavaScript, no API endpoints, just HTML.
But by default frame navigation:
- doesn’t touch browser history
- doesn’t change the URL
- doesn’t create or replace entries
It just fetches HTML and swaps the frame content in place.
Most of the time, that’s perfect. But sometimes you want the URL to reflect state. For sharing, bookmarking, or just making the browser’s Back and Forward buttons behave.
That’s where data-turbo-action comes in.
Let’s look at two real-world cases.
Two options you actually care about
When you promote a frame navigation to a real page visit, you choose:
-
advance— adds a new entry to browser history (Back goes step by step) -
replace— overwrites the current history entry (Back skips it)
Quick rule of thumb:
Use
advancewhen each state is something users may want to revisit: pagination, search results, or filtersUse
replacewhen the state matters for the URL (shareability), but not for history: tabs on a detail page
The choice comes down to one question:
What should the Back button do?
Let's see both in action.
Example 1: pagination with advance
Pagination is the obvious advance case.
Each page is a real destination:
- you can share
?page=3 - refresh keeps your position
- back button goes page by page
The view
<%# app/views/articles/index.html.erb %>
<hgroup>
<h1>Articles</h1>
</hgroup>
<%= turbo_frame_tag "articles", data: { turbo_action: :advance } do %>
<ul>
<% @articles.each do |article| %>
<li>
<%= link_to article.name, article, data: { turbo_frame: "_top" } %>
</li>
<% end %>
</ul>
<%== @pagy.series_nav %>
<% end %>
Note: article links use turbo_frame: "_top" to escape the frame and trigger a full Drive navigation instead of a frame swap.
The controller
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
def index
@pagy, @articles = pagy(Article.all, limit: 10)
end
end
What happens
Click “Next” a few times and here's how browser history looks:
[/articles] → [?page=2] → [?page=3] → [?page=4] ← user is here
- URL updates on every click
- back button goes page by page
- refresh keeps the correct page
Important detail: promoting frame navigation to visits means the pushed URLs become real entry points. Your controller must handle them directly — refresh or a cache miss on Back will trigger a full render at that URL.
Pagy handles this nicely, but it’s easy to forget with custom filters.
Example 2: tabs with replace
Now imagine tabs on an article detail page: Overview and Metadata.
You want:
- URL to reflect the active tab
- users to share a specific tab
But you don't want:
- back button replaying every tab click
That's a perfect replace.
The view
<article>
<h1><%= @article.name %></h1>
</article>
<%= turbo_frame_tag "tab_content", data: { turbo_action: :replace } do %>
<nav class="tabs" style="justify-content: flex-start; gap: 1rem;">
<% if @active_tab == "overview" %>
<strong>Overview</strong>
<% else %>
<%= link_to "Overview", article_path(@article, tab: "overview") %>
<% end %>
<% if @active_tab == "metadata" %>
<strong>Metadata</strong>
<% else %>
<%= link_to "Metadata", article_path(@article, tab: "metadata") %>
<% end %>
</nav>
<%= render "articles/tabs/#{@active_tab}", article: @article %>
<% end %>
<nav>
<%= link_to "← All Articles", articles_path %>
</nav>
The controller
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
TABS = %w[overview metadata].freeze
def show
@article = Article.find(params[:id])
@active_tab = TABS.include?(params[:tab]) ? params[:tab] : "overview"
end
end
What happens
Click between tabs a few times and here's how browser history looks:
[/articles] → [/articles/42?tab=metadata] ← user is here
- only one history entry for the article
- URL still reflects the active tab
- Back returns to
/articles
If we'd used advance here, users would have to click Back through every tab. That gets painful fast.
The takeaway
data-turbo-action lets Turbo Frames control browser history.
Without it, frames ignore history completely. With it, you decide how navigation behaves.
Just remember:
What should the Back button do?
- Go through states ->
advance - Skip intermediate states ->
replace
And don’t forget: your controller must handle those URLs on a full page load.
Run into a weird data-turbo-action edge case? Or a case where advance vs replace wasn’t obvious? Drop it in the comments.
Top comments (0)