class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
respond_to do |format|
if @comment.save
format.turbo_stream
format.html { redirect_to @post, notice: "Comment added." }
else
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
view_context.dom_id(@post, :new_comment),
partial: "comments/form",
locals: { post: @post, comment: @comment }
), status: :unprocessable_entity
end
format.html do
@comments = @post.comments.order(created_at: :desc)
render "posts/show", status: :unprocessable_entity
end
end
end
end
private
def set_post
@post = Post.find(params[:post_id])
end
def comment_params
params.require(:comment).permit(:body, :author)
end
end
<%= turbo_stream.prepend "comments" do %>
<%= render partial: "comments/comment", locals: { comment: @comment } %>
<% end %>
<%= turbo_stream.replace dom_id(@post, :new_comment) do %>
<%= render partial: "comments/form",
locals: { post: @post, comment: @post.comments.build } %>
<% end %>
<h1><%= @post.title %></h1>
<%= render partial: "comments/form",
locals: { post: @post, comment: @post.comments.build } %>
<section class="comments">
<%= tag.div id: "comments" do %>
<%= render @comments %>
<% end %>
</section>
<%= form_with model: [post, comment],
id: dom_id(post, :new_comment) do |f| %>
<% if comment.errors.any? %>
<div class="errors">
<%= comment.errors.full_messages.to_sentence %>
</div>
<% end %>
<%= f.text_field :author, placeholder: "Your name" %>
<%= f.text_area :body, placeholder: "Add a comment\u2026" %>
<%= f.submit "Post comment" %>
<% end %>
This snippet shows the canonical Hotwire pattern for creating a record and prepending it to a list over a Turbo Stream, while still working for clients that don't accept text/vnd.turbo-stream.html. The point is to write the controller once and respond to two formats from the same action, so behavior degrades gracefully instead of forking into two codepaths.
In CommentsController, the create action builds a comment scoped to a parent @post and branches on respond_to. On the happy path it hands off to format.turbo_stream, which renders the create.turbo_stream.erb template by convention. Critically, the same if @comment.save guard drives the format.html branch, which issues a plain redirect_to. When validation fails, the HTML branch re-renders :new with :unprocessable_entity so the browser shows errors normally. This dual response is what makes the feature robust: a request without JavaScript, or with Turbo disabled, still gets a working server-rendered flow.
The create.turbo_stream.erb template is where the surgical DOM update happens. turbo_stream.prepend targets the DOM id comments and renders the comments/comment partial, so the newest comment slides in at the top of the list without a full reload. A second turbo_stream.replace swaps the new_comment form back to a fresh, empty one — this is a common gotcha, since Turbo leaves the submitted form in place otherwise, retaining stale input. Emitting multiple stream actions from one template is fully supported and keeps related UI changes atomic.
The index.html.erb view wires everything together. The tag.div id: "comments" establishes the target the stream prepends into, and render @comments reuses the exact same partial the stream renders, so a page load and a live append produce identical markup. dom_id(@post, :new_comment) on the form_with gives the form a stable id that turbo_stream.replace can find.
The trade-off is a little template duplication in exchange for one authoritative partial and no client-side rendering code. Reaching for this pattern makes sense whenever a create should feel instant but must remain accessible and crawlable without relying on client JavaScript.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
Share this code
Here's the card — post it anywhere.