Skip to main content
Technology & EngineeringRuby Rails244 lines

Hotwire Turbo

Hotwire and Turbo Drive, Frames, and Streams for building reactive Rails frontends without heavy JavaScript.

Quick Summary32 lines
You are an expert in Hotwire (HTML Over The Wire) and Turbo for building fast, reactive Rails applications with minimal custom JavaScript.

## Key Points

- Return `status: :unprocessable_entity` (422) from failed form submissions so Turbo replaces the frame with validation errors.
- Use `status: :see_other` (303) for redirects after non-GET requests.
- Keep Turbo Frames small and focused; one frame per editable unit.
- Use `turbo_stream` responses for actions that affect multiple parts of the page.
- Leverage broadcasting for real-time features but keep broadcast payloads minimal.
- Use `loading: :lazy` on frames to defer non-critical content.
- Set `data-turbo-action="advance"` on frames that should update the browser URL.
- **Missing status codes**: Turbo silently fails if a form error response returns 200 instead of 422.
- **Frame ID mismatch**: The frame tag ID must match between the source page and the response, or Turbo will not find the content to swap.
- **Forgetting `see_other` on redirects**: POST/PATCH/DELETE redirects must use 303 so Turbo issues a GET for the redirect target.
- **Overly large frames**: Wrapping the entire page in one frame defeats the purpose. Scope frames tightly.
- **Not handling the non-JS fallback**: Always provide `format.html` responses alongside `format.turbo_stream` for progressive enhancement.

## Quick Example

```erb
<%# Loads content asynchronously after the page renders %>
<%= turbo_frame_tag "weather_widget", src: weather_path, loading: :lazy do %>
  <p>Loading weather...</p>
<% end %>
```

```erb
<%# Target the whole page from inside a frame %>
<%= link_to "View Full Post", post_path(post), data: { turbo_frame: "_top" } %>
```
skilldb get ruby-rails-skills/Hotwire TurboFull skill: 244 lines
Paste into your CLAUDE.md or agent config

Hotwire and Turbo — Ruby on Rails

You are an expert in Hotwire (HTML Over The Wire) and Turbo for building fast, reactive Rails applications with minimal custom JavaScript.

Overview

Hotwire is the default frontend approach in modern Rails. It consists of Turbo (Drive, Frames, Streams) and Stimulus. Turbo replaces full page reloads with targeted HTML updates sent from the server, giving SPA-like responsiveness while keeping logic server-side.

Core Philosophy

Hotwire represents a fundamental bet: that most web application interactivity can be achieved by sending HTML from the server rather than JSON to a JavaScript framework. Instead of duplicating rendering logic in both the backend and frontend, you write it once in your Rails views and let Turbo handle the delivery. The result is less code, fewer abstractions, and a drastically simpler mental model for building interactive applications.

The key insight behind Turbo is that the unit of update is not a data payload — it is a piece of rendered HTML. Turbo Drive replaces full page loads, Turbo Frames scope updates to specific regions, and Turbo Streams allow the server to surgically mutate any part of the DOM. Each layer adds precision without requiring you to write JavaScript. The progressive escalation from Drive to Frames to Streams means you reach for more complexity only when simpler tools are insufficient.

This approach works because the server is the source of truth. There is no client-side state to synchronize, no optimistic update logic to reconcile, and no API versioning to manage between your own frontend and backend. When the HTML arrives, it is already correct. The tradeoff is latency on every interaction, which is why Turbo is designed to make those round-trips as fast and invisible as possible.

Anti-Patterns

  • Status Code Neglect: Returning 200 OK for failed form submissions instead of 422 Unprocessable Entity. Turbo relies on HTTP status codes to decide how to handle responses. A 200 on a validation failure causes Turbo to silently replace content without showing errors.

  • Mega-Frame Wrapping: Wrapping the entire page content in a single Turbo Frame. This defeats the purpose of frames, which are meant to scope updates to small, independent regions. If everything is one frame, you have just recreated full-page replacement with extra steps.

  • Ignoring Progressive Enhancement: Building Turbo Stream responses without a corresponding format.html fallback. When JavaScript fails, or the user opens a link in a new tab, the HTML response must stand on its own.

  • Broadcast Flooding: Using after_create_commit broadcasts to send large HTML partials to hundreds of connected clients on every database write. Keep broadcast payloads minimal and consider whether the real-time update is truly necessary for every subscriber.

  • Fighting the Server Round-Trip: Adding complex client-side JavaScript to avoid server interactions that Turbo is designed to handle. If you find yourself building significant JavaScript state management alongside Turbo, you may be better served by committing fully to either Hotwire or a JavaScript framework — not both.

Core Concepts

Turbo Drive

Turbo Drive intercepts link clicks and form submissions, replacing full page loads with fetch requests and swapping the <body> content.

<%# Turbo Drive is enabled by default. Disable for specific links: %>
<%= link_to "External", "https://example.com", data: { turbo: false } %>

<%# Disable for a form: %>
<%= form_with model: @user, data: { turbo: false } do |f| %>
  ...
<% end %>

Turbo Frames

Turbo Frames scope navigation to a specific region of the page. Only the matching frame is replaced.

<%# app/views/posts/index.html.erb %>
<%= turbo_frame_tag "posts_list" do %>
  <% @posts.each do |post| %>
    <%= render post %>
  <% end %>
  <%= link_to "Load more", posts_path(page: @next_page) %>
<% end %>

<%# app/views/posts/_post.html.erb %>
<%= turbo_frame_tag dom_id(post) do %>
  <h2><%= link_to post.title, post_path(post) %></h2>
  <p><%= post.summary %></p>
  <%= link_to "Edit", edit_post_path(post) %>
<% end %>

Lazy-Loaded Frames

<%# Loads content asynchronously after the page renders %>
<%= turbo_frame_tag "weather_widget", src: weather_path, loading: :lazy do %>
  <p>Loading weather...</p>
<% end %>

Breaking Out of Frames

<%# Target the whole page from inside a frame %>
<%= link_to "View Full Post", post_path(post), data: { turbo_frame: "_top" } %>

Turbo Streams

Turbo Streams allow the server to send targeted DOM mutations (append, prepend, replace, update, remove, before, after).

# app/controllers/comments_controller.rb
class CommentsController < ApplicationController
  def create
    @comment = @post.comments.build(comment_params)

    if @comment.save
      respond_to do |format|
        format.turbo_stream
        format.html { redirect_to @post }
      end
    else
      render :new, status: :unprocessable_entity
    end
  end
end
<%# app/views/comments/create.turbo_stream.erb %>
<%= turbo_stream.append "comments" do %>
  <%= render @comment %>
<% end %>

<%= turbo_stream.update "comment_count" do %>
  <%= @post.comments.count %> comments
<% end %>

<%= turbo_stream.replace "new_comment_form" do %>
  <%= render "comments/form", comment: Comment.new %>
<% end %>

Broadcasting with Turbo Streams (WebSockets)

# app/models/message.rb
class Message < ApplicationRecord
  belongs_to :room

  after_create_commit -> { broadcast_append_to room, target: "messages" }
  after_update_commit -> { broadcast_replace_to room }
  after_destroy_commit -> { broadcast_remove_to room }
end
<%# app/views/rooms/show.html.erb %>
<%= turbo_stream_from @room %>

<div id="messages">
  <%= render @room.messages %>
</div>

Implementation Patterns

Flash Messages with Turbo

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  # Turbo requires status codes for re-rendering
  # 303 for redirects after non-GET requests
  def redirect_with_flash(path, notice: nil, alert: nil)
    redirect_to path, status: :see_other, notice: notice, alert: alert
  end
end
# Important: Turbo expects specific HTTP status codes
def update
  if @post.update(post_params)
    redirect_to @post, notice: "Updated!", status: :see_other
  else
    render :edit, status: :unprocessable_entity
  end
end

Inline Editing Pattern

<%# app/views/tasks/_task.html.erb %>
<%= turbo_frame_tag dom_id(task) do %>
  <div class="task">
    <span><%= task.title %></span>
    <%= link_to "Edit", edit_task_path(task) %>
  </div>
<% end %>

<%# app/views/tasks/edit.html.erb %>
<%= turbo_frame_tag dom_id(@task) do %>
  <%= form_with model: @task do |f| %>
    <%= f.text_field :title %>
    <%= f.submit "Save" %>
    <%= link_to "Cancel", task_path(@task) %>
  <% end %>
<% end %>

Search with Turbo Frames

<%# app/views/products/index.html.erb %>
<%= form_with url: products_path, method: :get,
    data: { turbo_frame: "products_results", turbo_action: "advance" } do |f| %>
  <%= f.search_field :q, value: params[:q], placeholder: "Search products..." %>
  <%= f.submit "Search" %>
<% end %>

<%= turbo_frame_tag "products_results" do %>
  <%= render @products %>
  <%= paginate @products %>
<% end %>

Multi-Stream Responses

<%# app/views/orders/create.turbo_stream.erb %>
<%= turbo_stream.prepend "orders_list" do %>
  <%= render @order %>
<% end %>

<%= turbo_stream.update "orders_count", plain: Order.count.to_s %>

<%= turbo_stream.append "notifications" do %>
  <div class="flash flash-success" data-controller="auto-dismiss">
    Order created successfully!
  </div>
<% end %>

Best Practices

  • Return status: :unprocessable_entity (422) from failed form submissions so Turbo replaces the frame with validation errors.
  • Use status: :see_other (303) for redirects after non-GET requests.
  • Keep Turbo Frames small and focused; one frame per editable unit.
  • Use turbo_stream responses for actions that affect multiple parts of the page.
  • Leverage broadcasting for real-time features but keep broadcast payloads minimal.
  • Use loading: :lazy on frames to defer non-critical content.
  • Set data-turbo-action="advance" on frames that should update the browser URL.

Common Pitfalls

  • Missing status codes: Turbo silently fails if a form error response returns 200 instead of 422.
  • Frame ID mismatch: The frame tag ID must match between the source page and the response, or Turbo will not find the content to swap.
  • Forgetting see_other on redirects: POST/PATCH/DELETE redirects must use 303 so Turbo issues a GET for the redirect target.
  • Overly large frames: Wrapping the entire page in one frame defeats the purpose. Scope frames tightly.
  • Not handling the non-JS fallback: Always provide format.html responses alongside format.turbo_stream for progressive enhancement.
  • Broadcasting too much: Sending large HTML partials over WebSockets to many clients can strain the server. Keep broadcasts lean.

Install this skill directly: skilldb add ruby-rails-skills

Get CLI access →