DEV Community

Pavel Myslik
Pavel Myslik

Posted on

Turbo Frames don’t update the URL… unless you do this

Want to try it yourself? A full working demo is here: turbo-advance-vs-replace. Clone it, run bin/setup and 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 advance when each state is something users may want to revisit: pagination, search results, or filters

  • Use replace when 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 %>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

What happens

Click “Next” a few times and here's how browser history looks:

[/articles] → [?page=2] → [?page=3] → [?page=4]  ← user is here
Enter fullscreen mode Exit fullscreen mode
  • 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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

What happens

Click between tabs a few times and here's how browser history looks:

[/articles] → [/articles/42?tab=metadata]  ← user is here
Enter fullscreen mode Exit fullscreen mode
  • 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)