<h1>Create your account</h1>
<%= render "error_summary", record: @user %>
<%= form_with model: @user, url: signups_path do |f| %>
<div class="field">
<%= f.label :email %>
<%= f.email_field :email, id: "user_email" %>
</div>
<div class="field">
<%= f.label :password %>
<%= f.password_field :password, id: "user_password" %>
</div>
<%= f.submit "Sign up" %>
<% end %>
<%# locals: record %>
<div id="<%= dom_id(record, :errors) %>">
<% if record.errors.any? %>
<div class="error-summary" role="alert" aria-live="assertive" tabindex="-1">
<h2 class="error-summary__title">
<%= pluralize(record.errors.count, "error") %> prevented this form from being saved
</h2>
<ul class="error-summary__list">
<% record.errors.each do |error| %>
<li>
<a href="#<%= [record.model_name.param_key, error.attribute].join("_") %>">
<%= error.full_message %>
</a>
</li>
<% end %>
</ul>
</div>
<% end %>
</div>
class SignupsController < ApplicationController
def new
@user = User.new
end
def create
@user = User.new(signup_params)
respond_to do |format|
if @user.save
format.html { redirect_to dashboard_path, notice: "Welcome aboard!" }
format.turbo_stream { redirect_to dashboard_path, notice: "Welcome aboard!" }
else
format.turbo_stream do
render turbo_stream: turbo_stream.update(
dom_id(@user, :errors),
partial: "error_summary",
locals: { record: @user }
), status: :unprocessable_entity
end
format.html { render :new, status: :unprocessable_entity }
end
end
end
private
def signup_params
params.require(:user).permit(:email, :password)
end
end
This snippet shows how a server-rendered error summary block is kept in sync with form validation using Turbo Streams instead of a full page reload. The pattern is useful when a form submits over turbo_stream (the default for Rails 7 form submissions) and the model fails validation: rather than re-rendering the whole page, the controller pushes a targeted turbo_stream.update that swaps only the contents of a stable DOM node.
In _error_summary partial the summary is wrapped in an element whose id is derived from the model via dom_id(record, :errors), giving a predictable target like signup_errors. When the record has no errors the partial renders the container empty but still present, which matters because Turbo can only update a node that already exists in the DOM. The block carries role="alert" and aria-live="assertive" so screen readers announce the messages as they appear, and each message links back to the offending field via full_messages and anchor hrefs.
In SignupsController the create action attempts to persist the user. On success it responds with a redirect that Turbo follows normally. On failure it renders a turbo_stream response whose single stream targets the same dom_id, replacing the inner HTML with the freshly rendered partial that now contains the validation errors. Because the response status is :unprocessable_entity, Turbo treats it as a form error and processes the stream rather than discarding it — returning 200 here would cause Turbo to ignore the body.
The key idea is that the error region is addressed by a stable identifier shared between the initial page render and the update, so turbo_stream.update can find and replace it precisely. This avoids flicker, preserves the rest of the page including untouched inputs, and keeps validation logic entirely on the server. The trade-off is that the summary must always be rendered as an empty shell up front; forgetting that shell is the most common pitfall, since Turbo silently drops updates aimed at a missing target. This approach scales well to multiple independent regions on one page, each with its own dom_id.
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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Semantic HTML Example</title>
Semantic HTML5 elements and accessibility best practices
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.