This skill should be used when the user asks about views, templates, ERB, partials, helpers, Turbo, Stimulus, Hotwire, forms, asset pipeline, Propshaft, or frontend architecture. Trigger phrases include "views", "templates", "ERB", "partials", "helpers", "Turbo", "Stimulus", "Hotwire", "Turbo Frames", "Turbo Streams", "forms", "form_with", "assets", "Propshaft", "importmaps".
Inherits all available tools
Additional assets for this skill
This skill inherits all available tools. When active, it can use any tool Claude has access to.
examples/stimulus-controller.jsexamples/turbo-frame.erbreferences/forms-guide.mdreferences/hotwire-patterns.mdExpertise in ERB templates, Turbo, Stimulus, Hotwire, partials, helpers, and the modern Rails frontend stack.
HTML belongs on the server. Turbo and Stimulus enhance server-rendered HTML with just enough JavaScript. You don't need a JavaScript framework for most applications.
Rails 8 ships with Hotwire by default:
<%# app/views/layouts/application.html.erb %>
<!DOCTYPE html>
<html>
<head>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
</head>
<body>
<%= render "shared/header" %>
<main class="container">
<%= render "shared/flash" %>
<%= yield %>
</main>
<%= render "shared/footer" %>
</body>
</html>
<%# In layout %>
<title><%= content_for(:title) || "My App" %></title>
<%# In view %>
<% content_for :title, "Articles" %>
<%# app/views/articles/_article.html.erb %>
<article id="<%= dom_id(article) %>">
<h2><%= link_to article.title, article %></h2>
<p><%= truncate(article.body, length: 200) %></p>
<footer>By <%= article.user.name %></footer>
</article>
<%# Rendering %>
<%= render @articles %> <%# Automatically uses _article partial %>
<%= render partial: "article", collection: @articles %>
<%= render "article", article: @article %>
<%# Explicit locals %>
<%= render "form", article: @article, submit_text: "Create" %>
<%# In partial: use local_assigns to check for optional locals %>
<%= submit_tag local_assigns.fetch(:submit_text, "Save") %>
<%= render partial: "article", collection: @articles, spacer_template: "article_divider" %>
<%# Wraps content for independent updates %>
<%= turbo_frame_tag "article_#{@article.id}" do %>
<h1><%= @article.title %></h1>
<%= link_to "Edit", edit_article_path(@article) %>
<% end %>
<%# Edit page: frame content replaces the original %>
<%= turbo_frame_tag "article_#{@article.id}" do %>
<%= render "form", article: @article %>
<% end %>
<%= turbo_frame_tag "comments", src: article_comments_path(@article), loading: :lazy do %>
<p>Loading comments...</p>
<% end %>
<%# Link targets the whole page, not the frame %>
<%= link_to "View All", articles_path, data: { turbo_frame: "_top" } %>
# app/controllers/comments_controller.rb
def create
@comment = @article.comments.build(comment_params)
respond_to do |format|
if @comment.save
format.turbo_stream
format.html { redirect_to @article }
else
format.html { render :new, status: :unprocessable_entity }
end
end
end
<%# app/views/comments/create.turbo_stream.erb %>
<%= turbo_stream.prepend "comments", @comment %>
<%= turbo_stream.update "comment_form", partial: "comments/form", locals: { comment: Comment.new } %>
append - Add to end of containerprepend - Add to beginningreplace - Replace entire elementupdate - Replace inner HTML onlyremove - Remove elementbefore / after - Insert adjacentclass Comment < ApplicationRecord
after_create_commit -> { broadcast_prepend_to article, :comments }
after_destroy_commit -> { broadcast_remove_to article, :comments }
end
<%# Subscribe in view %>
<%= turbo_stream_from @article, :comments %>
<div id="comments">
<%= render @article.comments %>
</div>
// app/javascript/controllers/clipboard_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["source"]
static values = { successMessage: { type: String, default: "Copied!" } }
copy() {
navigator.clipboard.writeText(this.sourceTarget.value)
this.dispatch("copied", { detail: { content: this.sourceTarget.value } })
}
}
<div data-controller="clipboard" data-clipboard-success-message-value="Link copied!">
<input type="text" value="https://example.com" data-clipboard-target="source" readonly>
<button data-action="clipboard#copy">Copy</button>
</div>
<%# Toggle visibility %>
<div data-controller="toggle">
<button data-action="toggle#toggle">Show/Hide</button>
<div data-toggle-target="content" class="hidden">
Hidden content
</div>
</div>
<%# Form validation %>
<%= form_with model: @user, data: { controller: "form-validation" } do |f| %>
<%= f.email_field :email, data: { form_validation_target: "field", action: "blur->form-validation#validate" } %>
<% end %>
<%# Debounced search %>
<input type="search"
data-controller="search"
data-action="input->search#submit"
data-search-debounce-value="300">
<%= form_with model: @article, local: true do |f| %>
<% if @article.errors.any? %>
<div id="errors">
<% @article.errors.full_messages.each do |msg| %>
<p><%= msg %></p>
<% end %>
</div>
<% end %>
<div>
<%= f.label :title %>
<%= f.text_field :title %>
</div>
<div>
<%= f.label :body %>
<%= f.rich_text_area :body %> <%# Action Text %>
</div>
<div>
<%= f.label :category_id %>
<%= f.collection_select :category_id, Category.all, :id, :name, prompt: "Select..." %>
</div>
<div>
<%= f.label :tags %>
<%= f.collection_check_boxes :tag_ids, Tag.all, :id, :name %>
</div>
<%= f.submit %>
<% end %>
<%= form_with model: @article do |f| %>
<%# ... article fields ... %>
<h3>Comments</h3>
<%= f.fields_for :comments do |cf| %>
<div>
<%= cf.text_area :content %>
<%= cf.check_box :_destroy %>
<%= cf.label :_destroy, "Delete" %>
</div>
<% end %>
<%= link_to "Add Comment", "#", data: { action: "nested-form#add" } %>
<% end %>
# app/helpers/application_helper.rb
module ApplicationHelper
def page_title(title = nil)
base = "MyApp"
title.present? ? "#{title} | #{base}" : base
end
def avatar_for(user, size: 40)
if user.avatar.attached?
image_tag user.avatar.variant(resize_to_fill: [size, size]), class: "avatar"
else
content_tag :div, user.initials, class: "avatar-placeholder"
end
end
def time_ago_in_words_with_tooltip(time)
tag.time time_ago_in_words(time) + " ago",
datetime: time.iso8601,
title: time.to_fs(:long)
end
end
# app/helpers/articles_helper.rb
module ArticlesHelper
def article_status_badge(article)
status = article.published? ? "published" : "draft"
tag.span status.titleize, class: "badge badge-#{status}"
end
end
# config/importmap.rb
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
Rails 8 uses Propshaft for asset serving:
# Assets are served from app/assets/
# app/assets/stylesheets/application.css
# app/assets/images/logo.png
<%= stylesheet_link_tag "application" %>
<%= image_tag "logo.png" %>
bin/importmap pin bootstrap
Or download to vendor/javascript/.
<%# BAD %>
<% if current_user && current_user.admin? && @article.draft? && @article.user == current_user %>
<%= link_to "Publish", publish_path(@article) %>
<% end %>
# In model or helper
def can_publish?(article)
admin? && article.draft? && article.user == self
end
<%# GOOD %>
<% if current_user&.can_publish?(@article) %>
<%= link_to "Publish", publish_path(@article) %>
<% end %>
Don't reach for React/Vue/etc for standard CRUD apps. Hotwire handles 90% of interactivity needs with a fraction of the complexity.
references/hotwire-patterns.md - Advanced Turbo/Stimulusreferences/forms-guide.md - Complex form patternsexamples/turbo-frame.erb - Frame examplesexamples/stimulus-controller.js - Controller patterns