turbo
Expert guidance for building modern Rails UIs with Turbo (Drive, Frames, Streams). Use when implementing partial page updates, real-time broadcasts, turbo frames, turbo streams, hotwire patterns, turbo_frame_tag, turbo_stream responses, lazy loading frames, morphing, page refreshes, or any "turbo" related Rails feature. Covers Turbo Drive navigation, Turbo Frames for scoped updates, Turbo Streams for real-time HTML delivery, and Turbo 8 morphing.
How do I install this agent skill?
npx skills add https://github.com/thinkoodle/rails-skills --skill turboIs this agent skill safe to install?
- Gen Agent Trust Hubpass
The skill is a comprehensive technical guide for implementing Turbo (Hotwire) in Ruby on Rails applications. It provides safe, standard coding patterns and configuration steps for modern Rails UI development.
- Socketpass
No alerts
- Snykpass
Risk: LOW · No issues
What does this agent skill do?
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)
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/thinkoodle/rails-skills/turbo">View turbo on skillZs</a>