← All posts

Jun 3, 2026

Add Hotwire Turbo Streams to one form in 10 minutes

A single comment form, no JavaScript, appended to the page on submit. The minimum Turbo Streams setup that actually pays off.

rails hotwire turbo how-to

I’m going to add a comment form to a post show page. Submitting it appends the new comment to the list without a full page reload, with zero custom JavaScript. Then I’ll show how the same setup gets you free server-pushed updates if you want them.

The whole thing is ten minutes the first time and two minutes every time after.

Setup

I’m assuming you have turbo-rails in your Gemfile (it’s a Rails 7+ default) and a Post has_many :comments model. If you’re starting from scratch:

# illustrative
bin/rails generate scaffold Post title:string
bin/rails generate model Comment post:references body:text
bin/rails db:migrate

Step 1: the form and the list

On the post show page, the comment list is wrapped in a Turbo Frame so it has a stable DOM ID:

<%# illustrative %>
<%# app/views/posts/show.html.erb %>
<h1><%= @post.title %></h1>

<%= turbo_frame_tag "comments" do %>
  <%= render @post.comments %>
<% end %>

<%= render "comments/form", post: @post, comment: Comment.new %>

The partial for a single comment also needs a DOM ID — Turbo uses dom_id(comment) to target it:

<%# illustrative %>
<%# app/views/comments/_comment.html.erb %>
<div id="<%= dom_id(comment) %>" class="comment">
  <p><%= comment.body %></p>
  <small><%= time_ago_in_words(comment.created_at) %> ago</small>
</div>

The form partial is the part that matters. It posts to the comments controller, no remote attributes, no Stimulus controller, no JS:

<%# illustrative %>
<%# app/views/comments/_form.html.erb %>
<%= form_with(model: [post, comment]) do |f| %>
  <%= f.text_area :body %>
  <%= f.submit "Post comment" %>
<% end %>

Turbo intercepts the submit because form_with defaults to data-turbo: true in modern Rails. Without any further work, this already gives you a no-reload page navigation — but the page doesn’t append, it replaces. To make it append, we need a Turbo Stream response from the controller.

Step 2: the controller

The whole thing is one respond_to block:

# illustrative
class CommentsController < ApplicationController
  def create
    @post = Post.find(params[:post_id])
    @comment = @post.comments.build(comment_params)

    if @comment.save
      respond_to do |format|
        format.turbo_stream do
          render turbo_stream: turbo_stream.append(
            "comments",
            partial: "comments/comment",
            locals: { comment: @comment }
          )
        end
        format.html { redirect_to @post }
      end
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def comment_params
    params.require(:comment).permit(:body)
  end
end

Three things to notice:

  1. The format.turbo_stream block returns one stream actionappend to the DOM ID "comments". There’s also prepend, replace, update, remove, and before/after. Same shape, different verbs.
  2. The format.html fallback is still there. If JavaScript is disabled or a non-Turbo client hits the endpoint, it gets a normal redirect. This is the part of Hotwire I keep coming back to: you don’t lose progressive enhancement.
  3. No partial rendering tricks. The same _comment.html.erb partial that renders the existing comments renders the new one. One source of truth.

That’s it. Refresh the page, submit the form, the new comment appears in the list. The form should also be cleared — Turbo handles that automatically because the stream response replaces the form’s frame. If your form isn’t inside a frame, you can add a second stream action:

# illustrative
render turbo_stream: [
  turbo_stream.append("comments", partial: "comments/comment", locals: { comment: @comment }),
  turbo_stream.replace("new_comment_form", partial: "comments/form", locals: { post: @post, comment: Comment.new })
]

You wrap the form partial in <turbo-frame id="new_comment_form"> and Turbo handles the swap.

Step 3 (optional but worth showing): broadcasts

The above response is request-driven — only the user who submitted sees the update. To make every connected viewer see new comments in real time, add one line to the model:

# illustrative
class Comment < ApplicationRecord
  belongs_to :post
  broadcasts_to :post, inserts_by: :append
end

And one line to the show page:

<%# illustrative %>
<%= turbo_stream_from @post %>

Now the controller’s format.turbo_stream block becomes redundant for that user (the broadcast covers them too) and you can simplify back to redirect_to @post for the HTML fallback. The model callback fires on create, builds the same partial, and pushes it down the WebSocket to every subscriber.

The cost: you’re now running ActionCable, which means an adapter (Solid Cable on Rails 8 by default, Redis otherwise) and the connection overhead of an open WebSocket per viewer. Worth it for an app where real-time is a feature; overkill for a comment form on a low-traffic blog.

What I’d skip

turbo_stream.update when you mean turbo_stream.replace. update swaps the contents of the target, leaving the wrapper element in place. replace swaps the whole element. For most “the thing changed, show the new version” cases you want replace. I’ve debugged enough “why did the DOM ID disappear” issues to know.

Also: the data-turbo-confirm attribute on the submit button is a one-line confirm dialog. Hotwire ships with it. Don’t reach for a custom JS modal for “are you sure” prompts.

What’s actually happening

The browser sends a normal POST. The server responds with Content-Type: text/vnd.turbo-stream.html and a payload that looks like:

<%# illustrative %>
<turbo-stream action="append" target="comments">
  <template>
    <div id="comment_42" class="comment">
      <p>Great post.</p>
      <small>less than a minute ago</small>
    </div>
  </template>
</turbo-stream>

The Turbo client picks up the text/vnd.turbo-stream.html content type, parses the stream actions, and applies them to the DOM. No JSON. No client-side rendering. The HTML you sent is the HTML you get.

This is the part of Hotwire I find genuinely clarifying: the wire format is the rendered HTML. You debug it by reading the response body in your browser’s network panel. There’s no client/server data contract to keep in sync because there’s no data — just markup.

Reference

Source for the stream actions and broadcast helpers: hotwired/turbo-rails at @435135b. The whole gem is small enough to read in an evening, and the source for Turbo::StreamsHelper is the canonical reference for what turbo_stream.append and friends actually generate.

For most apps, the steps in this post cover 80% of what you’ll do with Turbo Streams. The remaining 20% — frame navigation, custom stream actions, multi-target updates — builds on the same primitives. Start with one form. Ship it. Add the next one when the next one is needed.