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_tobroadcast_replace/broadcast_replace_tobroadcast_update/broadcast_update_tobroadcast_append/broadcast_append_tobroadcast_prepend/broadcast_prepend_tobroadcast_before_to/broadcast_after_tobroadcast_refresh/broadcast_refresh_tobroadcast_action/broadcast_action_tobroadcast_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.