Agent Skills: Rails Turbo Expert

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.

UncategorizedID: thinkoodle/rails-skills/turbo

Install this agent skill to your local

pnpm dlx add-skill https://github.com/ThinkOodle/rails-skills/tree/HEAD/skills/turbo

Skill Files

Browse the full folder contents for turbo.

Download Skill

Loading file tree…

skills/turbo/SKILL.md

Skill Metadata

Name
turbo
Description
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.

Rails Turbo Expert

Build fast, modern Rails UIs with zero (or minimal) custom JavaScript using Turbo Drive, Frames, and Streams.

Philosophy

Core Principles:

  1. HTML over the wire — The server renders HTML. Turbo delivers it. No JSON APIs for UI.
  2. Progressive enhancement — Turbo Drive works automatically. Frames and Streams layer on top.
  3. Server is the source of truth — Business logic stays in Ruby. Turbo just moves HTML around.
  4. Minimal JavaScript — If you're writing JS to update the DOM, you're probably doing it wrong. Use Turbo Streams.
  5. 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:

  1. HTTP responses — returned from form submissions (POST/PUT/PATCH/DELETE)
  2. 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 place
  • update — 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 changes
  • scroll: :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 scroll
  • references/streams.md — Flash messages, live search, nested forms, counters, toasts, template conventions
  • references/broadcasting.md — Scoped broadcasts, user-specific streams, background job patterns
  • references/morphing.md — Turbo 8 morph refresh setup, when to use morph vs streams
  • references/testing.md — Integration tests, system tests, broadcast assertions
  • references/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

  1. Missing matching frame IDs — Both pages need turbo_frame_tag with same ID
  2. Returning 200 on validation failure — Must be status: :unprocessable_entity (422)
  3. Turbo Stream on GET requests — Streams only work on POST/PUT/PATCH/DELETE
  4. Using current_user in broadcast partials — No request context in broadcasts
  5. Forgetting turbo_stream_from in the view — Broadcasts need a subscription
  6. replace when you mean updatereplace removes the target element entirely
  7. Not using dom_id — Manual IDs drift; dom_id(@post) is consistent
  8. Forgetting after_*_commit — Use commit callbacks, not after_save for broadcasts
  9. No HTML fallback in respond_to — Always include format.html for non-Turbo clients
  10. Lazy frame without placeholder content — Show loading state inside the frame tag

Anti-Patterns

  1. Building JSON APIs just for UI updates — Use Turbo Streams instead
  2. Writing JavaScript to swap DOM content — That's what Turbo does
  3. Nesting frames deeply — Keep it to 1-2 levels; complexity explodes
  4. Broadcasting every model change — Be selective; too many broadcasts = performance issues
  5. Giant turbo_stream.erb templates — Keep stream responses focused; 1-3 actions per response
  6. Using Turbo Frames for things that need Streams — If you need to update multiple unrelated areas, use Streams
  7. Skipping the HTML fallback — Your app should work without Turbo (progressive enhancement)
Rails Turbo Expert Skill | Agent Skills