turbo-rails: The Complete Guide to Rails Helpers for Turbo

The turbo-rails gem is what makes Turbo feel native in Rails. It's not just the JavaScript library—it's a complete set of view helpers, model concerns, and controller integrations that turn Turbo into a first-class citizen of your Rails app.

If you've been writing raw <turbo-frame> tags or manually building stream responses, you're working too hard. Here's the complete guide to the Rails helpers that make Turbo actually pleasant to use.

turbo_frame_tag


This is your workhorse for Turbo Frames. It generates <turbo-frame> elements with proper IDs and handles the DOM ID generation automatically.

Basic usage:

<%= turbo_frame_tag "sidebar" do %>
  <p>Content here</p>
<% end %>
<!-- Generates: <turbo-frame id="sidebar"><p>Content here</p></turbo-frame> -->

With a model (automatic dom_id):

<%= turbo_frame_tag @post do %>
  <%= render @post %>
<% end %>
<!-- Generates: <turbo-frame id="post_123">...</turbo-frame> -->

With model and prefix:

<%= turbo_frame_tag @post, "comments" do %>
  <%= render @post.comments %>
<% end %>
<!-- Generates: <turbo-frame id="comments_post_123">...</turbo-frame> -->

Lazy loading with src:

<%= turbo_frame_tag "weather", src: weather_path, loading: "lazy" %>
<!-- Generates: <turbo-frame id="weather" src="/weather" loading="lazy"></turbo-frame> -->

Breaking out of the frame:

<%= turbo_frame_tag "modal", target: "_top" do %>
  <!-- Links in here navigate the whole page -->
<% end %>

The helper handles converting models to their dom_id automatically. Pass an array for compound IDs: turbo_frame_tag [@user, "settings"] becomes id="1_settings".

turbo_stream_from


This creates the WebSocket subscription that receives broadcasts. Put it in your view to subscribe to a channel.

<%= turbo_stream_from @post %>
<!-- Generates: <turbo-cable-stream-source signed-stream-name="..." channel="Turbo::StreamsChannel"> -->

Multiple streamables:

<%= turbo_stream_from @user, :notifications %>
<!-- Subscribes to stream: "user_123:notifications" -->

With custom channel:

<%= turbo_stream_from "chat_room", channel: ChatRoomChannel %>

The stream name is signed automatically—users can't tamper with it to subscribe to channels they shouldn't access.

turbo_stream (TagBuilder)


In your .turbo_stream.erb templates, you get access to turbo_stream which builds stream action tags. This is what you return from controller actions that respond to format.turbo_stream.

The nine actions:

<!-- app/views/messages/create.turbo_stream.erb -->

<%= turbo_stream.append "messages", @message %>
<%= turbo_stream.prepend "messages", @message %>
<%= turbo_stream.replace @message %>
<%= turbo_stream.update @message %>
<%= turbo_stream.remove @message %>
<%= turbo_stream.before @message, partial: "messages/divider" %>
<%= turbo_stream.after @message, partial: "messages/divider" %>
<%= turbo_stream.morph @message %>
<%= turbo_stream.refresh %>

With explicit target:

<%= turbo_stream.update "flash", partial: "shared/flash" %>

With block for inline content:

<%= turbo_stream.replace "counter" do %>
  <span id="counter"><%= @count %></span>
<% end %>

Multiple targets with targets:

<%= turbo_stream.remove_all ".notification" %>
<!-- Or with the targets parameter -->
<%= turbo_stream.update targets: ".price", partial: "products/price" %>

Broadcastable (Model Concern)


This is where turbo-rails really shines. Include Turbo::Broadcastable in your model (it's automatic for Active Record if you have Active Job), and you get methods to push updates over WebSockets.

Declarative broadcasts:

class Message < ApplicationRecord
  belongs_to :room
  
  broadcasts_to :room
  # Broadcasts append on create, replace on update, remove on destroy
end

With custom target:

class Comment < ApplicationRecord
  broadcasts_to :post, target: "comments"
end

Page refresh broadcasts (morphing):

class Article < ApplicationRecord
  broadcasts_refreshes
  # Broadcasts <turbo-stream action="refresh"> on any change
end

Instance-level broadcasts:

class Order < ApplicationRecord
  after_update_commit :broadcast_status_change, if: :saved_change_to_status?
  
  private
  
  def broadcast_status_change
    broadcast_replace_later_to customer, :orders,
      target: "order_#{id}_status",
      partial: "orders/status",
      locals: { order: self }
  end
end

The full method list:

  • broadcast_remove / broadcast_remove_to
  • broadcast_replace / broadcast_replace_to
  • broadcast_update / broadcast_update_to
  • broadcast_append / broadcast_append_to
  • broadcast_prepend / broadcast_prepend_to
  • broadcast_before_to / broadcast_after_to
  • broadcast_refresh / broadcast_refresh_to
  • broadcast_action / broadcast_action_to
  • broadcast_render / broadcast_render_to

Each has a _later variant that runs asynchronously via Active Job. Use the _later versions in model callbacks. Synchronous broadcasts inside a transaction will block and can cause issues.

Drive Helpers


Control Turbo Drive behavior from your views.

Exclude from cache:

<% turbo_exempts_page_from_cache %>
<!-- Adds: <meta name="turbo-cache-control" content="no-cache"> -->

Exclude from preview:

<% turbo_exempts_page_from_preview %>
<!-- Adds: <meta name="turbo-cache-control" content="no-preview"> -->

Force full page reload:

<% turbo_page_requires_reload %>
<!-- Adds: <meta name="turbo-visit-control" content="reload"> -->

Configure morphing refreshes:

<% turbo_refreshes_with method: :morph, scroll: :preserve %>
<!-- Adds both meta tags for morph refreshes -->

These helpers require <%= yield :head %> in your layout's <head> section.

Controller Integration


turbo-rails adds format.turbo_stream to your controllers:

def create
  @message = @room.messages.create!(message_params)
  
  respond_to do |format|
    format.turbo_stream  # Renders create.turbo_stream.erb
    format.html { redirect_to @room }
  end
end

Inline stream response:

def destroy
  @message.destroy
  
  respond_to do |format|
    format.turbo_stream { render turbo_stream: turbo_stream.remove(@message) }
    format.html { redirect_to @room }
  end
end

Multiple stream actions inline:

def create
  @task = @project.tasks.create!(task_params)
  
  respond_to do |format|
    format.turbo_stream do
      render turbo_stream: [
        turbo_stream.append("tasks", @task),
        turbo_stream.update("task_count", html: @project.tasks.count.to_s)
      ]
    end
  end
end

Frame Request Detection


Check if the current request is from a Turbo Frame:

class ArticlesController < ApplicationController
  layout -> { turbo_frame_request? ? "turbo_rails/frame" : "application" }
  
  def show
    @article = Article.find(params[:id])
    
    if turbo_frame_request?
      # Only render what the frame needs
      render partial: "article_content"
    end
  end
end

The turbo_rails/frame layout is a minimal layout that just renders the content—no <html> or <head> tags.

Testing Helpers


turbo-rails includes test assertions:

class MessagesControllerTest < ActionDispatch::IntegrationTest
  test "creates message with turbo stream" do
    post room_messages_path(@room), params: { message: { content: "Hello" } },
      as: :turbo_stream
    
    assert_turbo_stream action: :append, target: "messages"
  end
end

Broadcast testing:

class MessageTest < ActiveSupport::TestCase
  include Turbo::Broadcastable::TestHelper
  
  test "broadcasts on create" do
    assert_turbo_stream_broadcasts "room_1:messages" do
      Message.create!(room_id: 1, content: "Test")
    end
  end
  
  test "suppressing broadcasts" do
    assert_no_turbo_stream_broadcasts do
      Message.suppressing_turbo_broadcasts do
        Message.create!(room_id: 1, content: "Silent")
      end
    end
  end
end

System test helper:

class ChatSystemTest < ApplicationSystemTestCase
  test "receives broadcasted messages" do
    visit room_path(@room)
    connect_turbo_cable_stream_sources  # Wait for WebSocket connection
    
    Message.create!(room: @room, content: "New message")
    
    assert_text "New message"
  end
end

Common Patterns


Flash messages via Turbo Stream:

<!-- app/views/layouts/application.html.erb -->
<div id="flash"><%= render "shared/flash" %></div>

<!-- app/views/shared/_flash.html.erb -->
<% flash.each do |type, message| %>
  <div class="flash flash-<%= type %>"><%= message %></div>
<% end %>

<!-- app/views/messages/create.turbo_stream.erb -->
<%= turbo_stream.append "messages", @message %>
<%= turbo_stream.update "flash", partial: "shared/flash" %>

Empty state handling:

<!-- Show empty state when last item removed -->
<%= turbo_stream.remove @task %>
<% if @project.tasks.empty? %>
  <%= turbo_stream.update "tasks" do %>
    <p class="empty-state">No tasks yet. Create one!</p>
  <% end %>
<% end %>

Morph for smooth updates:

<!-- Replace with morphing to preserve form state -->
<%= turbo_stream.replace @form, method: :morph, partial: "form" %>

The Key Insight


turbo-rails isn't just convenience methods. It's what makes Turbo feel like a native Rails feature instead of a JavaScript library you bolted on. The helpers handle DOM ID generation, stream signing, Action Cable integration, and response formatting automatically.

Use turbo_frame_tag with models instead of manual IDs. Use broadcasts_to instead of manual callbacks. Use format.turbo_stream instead of building XML by hand. That's the Rails way.

References