Rails Turbo Expert
Build fast, modern Rails UIs with zero (or minimal) custom JavaScript using Turbo Drive, Frames, and Streams.
Philosophy
Core Principles:
- HTML over the wire — The server renders HTML. Turbo delivers it. No JSON APIs for UI.
- Progressive enhancement — Turbo Drive works automatically. Frames and Streams layer on top.
- Server is the source of truth — Business logic stays in Ruby. Turbo just moves HTML around.
- Minimal JavaScript — If you're writing JS to update the DOM, you're probably doing it wrong. Use Turbo Streams.
- Composable primitives — Drive, Frames, and Streams each solve one problem. Combine them.
The Mental Model:
Turbo Drive → Speeds up ALL navigation (automatic, zero config)
Turbo Frames → Scopes updates to a REGION of the page (explicit, per-element)
Turbo Streams → Delivers TARGETED mutations to ANY element (server-pushed or response)
Decision Tree:
Need faster page loads? → Turbo Drive (already on)
Need to update PART of a page on click? → Turbo Frame
Need to update MULTIPLE parts at once? → Turbo Stream
Need real-time updates from other users? → Turbo Stream + ActionCable broadcast
Need smooth page refresh without flicker? → Turbo Morph (Rails 8+)
When To Use This Skill
- Adding turbo_frame_tag to scope navigation/updates
- Returning turbo_stream responses from controller actions
- Setting up real-time broadcasts with ActionCable
- Implementing lazy-loaded content
- Building inline editing, tabbed interfaces, modals
- Configuring Turbo Drive behavior (disabling for specific links/forms)
- Using Turbo 8 morphing and page refreshes
Instructions
Step 1: Understand What's Already There
Turbo Drive is on by default in every Rails 7+ app. You don't install it. You don't configure it. Every link click and form submission already goes through Turbo Drive.
Check the app has turbo-rails:
# Should already be in Gemfile
grep "turbo-rails" Gemfile
# JavaScript import should exist
rg "import.*turbo" app/javascript/
If missing (unlikely in Rails 7+):
bundle add turbo-rails
bin/rails turbo:install
Step 2: Turbo Drive — Know When to Opt Out
Turbo Drive intercepts every <a> click and <form> submit, fetches via AJAX, and swaps the <body>. This is automatic. You only intervene to disable it.
<%# Disable for a specific link or form %>
<%= link_to "External Site", "https://example.com", data: { turbo: false } %>
<%= form_with model: @upload, data: { turbo: false } do |f| %>
<%# Disable for a whole section %>
<div data-turbo="false">...</div>
Common opt-out scenarios: file uploads (without Active Storage direct upload), external links, OAuth/SSO redirects, file downloads, payment forms (Stripe).
Non-GET link methods — prefer button_to over link_to with turbo_method:
<%# Acceptable %>
<%= link_to "Delete", post_path(@post), data: { turbo_method: :delete, turbo_confirm: "Sure?" } %>
<%# Better — more accessible and semantic %>
<%= button_to "Delete", post_path(@post), method: :delete,
form: { data: { turbo_confirm: "Are you sure?" } } %>
Step 3: Turbo Frames — Scoped Page Updates
Turbo Frames are the workhorse. They scope navigation to a region of the page. When a link or form inside a frame is clicked, only that frame updates.
⚠️ THE #1 AGENT MISTAKE: Forgetting to wrap BOTH sides in matching turbo_frame_tag.
The frame on the current page AND the response page must have a <turbo-frame> with the same ID. If they don't match, you get a Content Missing error.
Basic Frame Pattern:
<%# index.html.erb — the page with the frame %>
<%= turbo_frame_tag "post_#{@post.id}" do %>
<h2><%= @post.title %></h2>
<%= link_to "Edit", edit_post_path(@post) %>
<% end %>
<%# edit.html.erb — the response MUST have matching frame %>
<%= turbo_frame_tag "post_#{@post.id}" do %>
<%= render "form", post: @post %>
<% end %>
Use dom_id for consistent IDs:
<%= turbo_frame_tag dom_id(@post) do %>
<%# dom_id(@post) produces "post_123" %>
<% end %>
Lazy-loaded frames (load content after page render):
<%= turbo_frame_tag "comments", src: post_comments_path(@post), loading: :lazy do %>
<p>Loading comments...</p>
<% end %>
The src URL is fetched automatically. The loading: :lazy defers until the frame is visible (Intersection Observer). Without :lazy, it loads immediately after page load.
⚠️ AGENT GOTCHA: The lazy-loaded endpoint must return a page with a matching turbo_frame_tag. The controller doesn't need to know it's a frame — it renders normally, and Turbo extracts the matching frame.
Breaking out of a frame (target the whole page):
<%= turbo_frame_tag dom_id(@post) do %>
<%# This link stays in the frame %>
<%= link_to "Edit", edit_post_path(@post) %>
<%# This link breaks out and navigates the full page %>
<%= link_to "Show Full Page", post_path(@post), data: { turbo_frame: "_top" } %>
<% end %>
Target a DIFFERENT frame:
<%= turbo_frame_tag "sidebar" do %>
<%= link_to "Load Details", post_path(@post), data: { turbo_frame: "main_content" } %>
<% end %>
<%# This frame will be updated instead %>
<%= turbo_frame_tag "main_content" do %>
<p>Select a post from the sidebar</p>
<% end %>
Frame with custom target attribute (all links inside target another frame):
<%= turbo_frame_tag "nav", target: "main_content" do %>
<%# ALL links here update "main_content" frame instead %>
<%= link_to "Posts", posts_path %>
<%= link_to "Users", users_path %>
<% end %>
Step 4: Turbo Streams — Targeted DOM Mutations
Turbo Streams are surgical. They say "take this HTML and append/replace/remove it at this DOM target." They work in two contexts:
- HTTP responses — returned from form submissions (POST/PUT/PATCH/DELETE)
- WebSocket broadcasts — pushed to all connected users via ActionCable
The 7 stream actions:
| Action | What it does |
|--------|-------------|
| append | Add HTML to END of target's children |
| prepend | Add HTML to START of target's children |
| replace | Replace the ENTIRE target element |
| update | Replace the INNER HTML of target |
| remove | Remove the target element |
| before | Insert HTML BEFORE the target |
| after | Insert HTML AFTER the target |
⚠️ CRITICAL DISTINCTION: replace vs update
replace— removes the target element itself and puts new HTML in its placeupdate— keeps the target element, replaces its children
<%# replace: <div id="post_1"> is GONE, replaced entirely %>
<%= turbo_stream.replace dom_id(@post), partial: "posts/post", locals: { post: @post } %>
<%# update: <div id="post_1"> stays, its contents are swapped %>
<%= turbo_stream.update dom_id(@post), partial: "posts/post", locals: { post: @post } %>
Responding with Turbo Streams from a controller:
# app/controllers/posts_controller.rb
def create
@post = Post.new(post_params)
respond_to do |format|
if @post.save
format.turbo_stream # Renders create.turbo_stream.erb
format.html { redirect_to @post }
else
format.html { render :new, status: :unprocessable_entity }
end
end
end
<%# app/views/posts/create.turbo_stream.erb %>
<%= turbo_stream.prepend "posts", partial: "posts/post", locals: { post: @post } %>
<%= turbo_stream.update "post_count", html: "#{Post.count} posts" %>
<%= turbo_stream.update "new_post_form" do %>
<%= render "form", post: Post.new %>
<% end %>
⚠️ AGENT GOTCHA: Turbo Stream responses ONLY work for non-GET requests. GET requests use Turbo Drive or Frames. If you try to return a turbo_stream format from a GET, it won't work. Use a frame instead.
Inline stream rendering (skip the template):
def destroy
@post.destroy
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.remove(dom_id(@post)) }
format.html { redirect_to posts_path }
end
end
Multiple stream actions inline:
format.turbo_stream do
render turbo_stream: [
turbo_stream.remove(dom_id(@post)),
turbo_stream.update("post_count", html: "#{Post.count} posts")
]
end
Step 5: Turbo Stream Broadcasts (Real-Time)
Broadcasts push Turbo Stream actions to all users subscribed to a channel via ActionCable. This is how you get "real-time" without writing any JavaScript.
Setup the subscription in the view:
<%# This creates an ActionCable subscription %>
<%= turbo_stream_from "posts" %>
<%# Scoped to a specific record %>
<%= turbo_stream_from @project %>
<%# Multiple stream names %>
<%= turbo_stream_from @project, "messages" %>
Broadcast from the model:
class Post < ApplicationRecord
# Broadcast AFTER commit (not after save — important for transactions)
after_create_commit { broadcast_append_to "posts" }
after_update_commit { broadcast_replace_to "posts" }
after_destroy_commit { broadcast_remove_to "posts" }
# Shorthand for all three:
broadcasts_to ->(post) { "posts" }
# Or if broadcasting to a parent:
broadcasts_to :project
end
Broadcast with a custom partial:
after_create_commit do
broadcast_append_to "posts",
target: "posts_list",
partial: "posts/post_card",
locals: { post: self }
end
Broadcast from anywhere (controller, job, service):
Turbo::StreamsChannel.broadcast_append_to("posts", target: "posts_list",
partial: "posts/post", locals: { post: @post })
Turbo::StreamsChannel.broadcast_remove_to("posts", target: dom_id(@post))
⚠️ AGENT GOTCHA: Broadcasts render partials WITHOUT a request context. This means no current_user, no request, no session. Design your partials to work without these, or pass needed data as locals.
Step 6: Turbo 8 Morphing (Rails 8+)
Morphing is a page-refresh strategy that updates the DOM by diffing instead of replacing. It preserves form state, scroll position, and CSS transitions.
Enable morphing for a page:
<%# In the <head> of your layout or page %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
What this does:
method: :morph— Diffs the new HTML against current DOM, applies minimal changesscroll: :preserve— Keeps scroll position after refresh
Triggering a morph refresh from the server:
# In a broadcast:
after_update_commit do
broadcast_refresh_to "posts"
end
# Shorthand:
broadcasts_refreshes
When to use morph vs streams:
- Morph — When you want to re-render the whole page but keep state (forms, scroll)
- Streams — When you want surgical, targeted updates to specific elements
Mark elements to preserve across morphs:
<div id="player" data-turbo-permanent>
<%# This element survives morph refreshes intact %>
</div>
Step 7: Forms in Turbo
Forms are where Turbo trips up agents the most. Key rules:
Rule 1: Failed validations MUST return 422 Unprocessable Entity.
def create
@post = Post.new(post_params)
if @post.save
redirect_to @post
else
render :new, status: :unprocessable_entity # ← CRITICAL
end
end
Without :unprocessable_entity, Turbo won't render the error response. It'll follow the redirect status instead.
Rule 2: Forms inside frames stay in frames.
<%= turbo_frame_tag "new_post" do %>
<%= form_with model: Post.new do |f| %>
<%= f.text_field :title %>
<%= f.submit "Create" %>
<% end %>
<% end %>
The form submits via Turbo. The response must contain a matching turbo_frame_tag "new_post". OR the controller responds with turbo_stream format to skip the frame requirement.
Rule 3: Redirect after successful form submission.
# Turbo handles redirects correctly — it navigates the page
if @post.save
redirect_to @post, notice: "Created!" # Works fine with Turbo
end
Rule 4: form_with uses Turbo by default in Rails 7+. No local: false needed. To disable:
<%= form_with model: @post, data: { turbo: false } do |f| %>
Step 8: Common Patterns
See the references/ directory for detailed implementations:
references/frames.md— Inline editing, tab navigation, lazy loading, modals, infinite scrollreferences/streams.md— Flash messages, live search, nested forms, counters, toasts, template conventionsreferences/broadcasting.md— Scoped broadcasts, user-specific streams, background job patternsreferences/morphing.md— Turbo 8 morph refresh setup, when to use morph vs streamsreferences/testing.md— Integration tests, system tests, broadcast assertionsreferences/edge-cases.md— Stimulus integration, gotchas (file uploads, DELETE redirects, CSP, caching)
Quick Reference
Helper Methods
<%# Frames %>
<%= turbo_frame_tag "id" %>
<%= turbo_frame_tag dom_id(@record) %>
<%= turbo_frame_tag "id", src: path, loading: :lazy %>
<%= turbo_frame_tag "id", target: "_top" %>
<%# Stream subscription %>
<%= turbo_stream_from "channel_name" %>
<%= turbo_stream_from @record %>
<%# Stream actions (in .turbo_stream.erb templates) %>
<%= turbo_stream.append "target_id", partial: "partial" %>
<%= turbo_stream.prepend "target_id", partial: "partial" %>
<%= turbo_stream.replace dom_id(@record), partial: "partial" %>
<%= turbo_stream.update "target_id", html: "content" %>
<%= turbo_stream.remove dom_id(@record) %>
<%= turbo_stream.before dom_id(@record), partial: "partial" %>
<%= turbo_stream.after dom_id(@record), partial: "partial" %>
<%# Morphing (Rails 8+) %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
Data Attributes
data-turbo="false" <%# Disable Turbo for this element %>
data-turbo-method="delete" <%# HTTP method for link %>
data-turbo-confirm="Sure?" <%# Confirmation dialog %>
data-turbo-frame="_top" <%# Break out of frame %>
data-turbo-frame="frame_id" <%# Target specific frame %>
data-turbo-permanent <%# Preserve across morphs %>
data-turbo-temporary <%# Remove on morph refresh %>
data-turbo-action="advance" <%# Push to browser history %>
data-turbo-action="replace" <%# Replace browser history entry %>
Model Broadcast Methods
# Individual callbacks
after_create_commit { broadcast_append_to "stream" }
after_update_commit { broadcast_replace_to "stream" }
after_destroy_commit { broadcast_remove_to "stream" }
# All-in-one
broadcasts_to ->(record) { "stream_name" }
broadcasts_to :parent_association
# Morphing (Rails 8+)
broadcasts_refreshes
after_update_commit { broadcast_refresh_to "stream" }
# Manual broadcast from anywhere
Turbo::StreamsChannel.broadcast_append_to("stream", target: "id", partial: "path")
Turbo::StreamsChannel.broadcast_remove_to("stream", target: "id")
Turbo::StreamsChannel.broadcast_refresh_to("stream")
Controller Response Pattern
respond_to do |format|
if @record.save
format.turbo_stream # renders action.turbo_stream.erb
format.html { redirect_to @record }
else
format.html { render :new, status: :unprocessable_entity }
end
end
Common Agent Mistakes
- Missing matching frame IDs — Both pages need
turbo_frame_tagwith same ID - Returning 200 on validation failure — Must be
status: :unprocessable_entity(422) - Turbo Stream on GET requests — Streams only work on POST/PUT/PATCH/DELETE
- Using
current_userin broadcast partials — No request context in broadcasts - Forgetting
turbo_stream_fromin the view — Broadcasts need a subscription replacewhen you meanupdate—replaceremoves the target element entirely- Not using
dom_id— Manual IDs drift;dom_id(@post)is consistent - Forgetting
after_*_commit— Use commit callbacks, notafter_savefor broadcasts - No HTML fallback in
respond_to— Always includeformat.htmlfor non-Turbo clients - Lazy frame without placeholder content — Show loading state inside the frame tag
Anti-Patterns
- Building JSON APIs just for UI updates — Use Turbo Streams instead
- Writing JavaScript to swap DOM content — That's what Turbo does
- Nesting frames deeply — Keep it to 1-2 levels; complexity explodes
- Broadcasting every model change — Be selective; too many broadcasts = performance issues
- Giant turbo_stream.erb templates — Keep stream responses focused; 1-3 actions per response
- Using Turbo Frames for things that need Streams — If you need to update multiple unrelated areas, use Streams
- Skipping the HTML fallback — Your app should work without Turbo (progressive enhancement)