CLAUDE.md - Rails + Hotwire + TailwindCSS Guidelines
Reusable conventions for AI assistants working on Rails applications with Hotwire and TailwindCSS.
Stack Overview
- Rails 8 with Hotwire (Turbo + Stimulus)
- TailwindCSS for styling
Base Gems
Every new project should include the following gems:
Development & Test
group :development, :test do
gem "dotenv-rails"
gem "standard" # replaces rubocop
gem "erb_lint"
gem "rspec-rails"
gem "factory_bot_rails"
end
group :development do
gem "letter_opener"
end
group :test do
gem "shoulda-matchers"
end
Configuration Notes
- letteropener: Set config.actionmailer.deliverymethod = :letteropener in config/environments/development.rb
- dotenv-rails: Create .env at the project root. Add .env to .gitignore
- standard: Use bundle exec standardrb for linting. Add .standard.yml for project-specific config
- erblint: Use bundle exec erblint --lint-all to lint ERB templates
- rspec-rails: Run rails generate rspec:install after adding. Use bundle exec rspec to run tests
- factorybotrails: Factories go in spec/factories/
- shoulda-matchers: Configure in spec/rails_helper.rb:
Shoulda::Matchers.configure do |config|
config.integrate do |with|
with.test_framework :rspec
with.library :rails
end
end
Stimulus Controller Best Practices
- Always use targets instead of document.getElementById
- --- COMPLETE SECTION ---
Prohibited Practices
- No Hardcoded HTML in JavaScript
NEVER embed HTML strings in JavaScript files. All markup lives in ERB templates.
// BAD
container.innerHTML = <div class="my-class">...</div>
// GOOD - Use stimulus controller targets
<<<<<<<<<<<<<<<<<<<<<<<<<< FIX >>>>>>>>>>>>>>>>>>>>>>>>>
const clone = this.templateTarget.content.cloneNode(true)
clone.querySelector([data-label]).textContent = value
container.appendChild(clone)
<<<<<<<<<<<<<<<<<<<<<<<<<< FIX >>>>>>>>>>>>>>>>>>>>>>>>>
- No Inline Script Tags
NEVER use tags in ERB partials, views, or Turbo Stream responses.
- No fetch() to Internal APIs
NEVER use fetch() in Stimulus controllers to call your own API endpoints.
// BAD
async saveData() {
await fetch('/api/v1/resource', { method: 'POST', body: data })
}
// GOOD - Use Turbo form submission
// Form submits via Turbo, server responds with turbo_stream
Use instead:
- Form submissions with Turbo
- Turbo Frames for partial page updates
- Turbo Streams for server-pushed updates
Turbo Best Practices
- Use turbostream.replace, turbostream.update, turbo_stream.append, etc.
- Update data via hidden elements that Stimulus controllers can read
- Let forms handle their own state via turbo:submit-end events
- Prefer Turbo Frames for scoped updates over full-page navigation
Rails Conventions
- Controllers: Keep thin, delegate to models/services
- Models: Fat models with business logic
- Concerns: Use for shared behavior
- Views: ERB templates with TailwindCSS classes
Code Style & Testing
Ruby
- StandardRB for linting: bundle exec standardrb
- ERB linting: bundle exec erb_lint --lint-all
- RSpec for testing: bundle exec rspec
- FactoryBot for test data
- Shoulda Matchers for concise model/controller specs
JavaScript
- Stimulus controllers follow naming convention: *_controller.js
- Use targets and values over direct DOM queries
- Keep controllers focused and small
Hotwire Best Practices
Turbo Drive
- Turbo Drive is on by default — every link click and form submission is handled via fetch. Do not add custom JavaScript to handle navigation.
- Use
data-turbo="false"to opt out on specific links or forms (e.g., file downloads, external redirects). - Use
data-turbo-method="delete"(orpatch,put) on links that need non-GET methods instead of wrapping them in forms.
Turbo Frames
- Wrap page sections in
<turbo-frame id="...">to scope navigation. Clicking a link inside a frame only replaces that frame's content. - Use
data-turbo-frame="_top"on links that should break out of the frame and navigate the full page. - Use
srcattribute for lazy-loaded frames:
<turbo-frame id="comments" src="<%= post_comments_path(@post) %>" loading="lazy">
<p>Loading comments...</p>
</turbo-frame>
- Keep frame IDs consistent between the page and the response. If the response doesn't contain a matching frame, Turbo will raise an error.
- Prefer frames over custom Stimulus controllers for in-page updates (e.g., inline editing, tab content, paginated lists).
Turbo Streams
- Use Turbo Streams for updates that affect multiple parts of the page or parts outside the current frame.
- Respond with turbo_stream format from controllers:
respond_to do |format|
format.turbo_stream
format.html { redirect_to resource_path }
end
- Always provide an html fallback for non-Turbo requests (progressive enhancement).
- Available actions: append, prepend, replace, update, remove, before, after.
- Use partials for stream targets to keep markup DRY:
# app/views/comments/create.turbo_stream.erb
<%= turbo_stream.append "comments", partial: "comments/comment", locals: { comment: @comment } %>
<%= turbo_stream.update "comment_count", html: @post.comments.count.to_s %>
Turbo Stream Broadcasts (Real-Time)
- Use model callbacks for real-time updates over WebSockets:
class Comment < ApplicationRecord
broadcasts_to :post
end
- Subscribe in the view:
<%= turbo_stream_from @post %>
- Only use broadcasts for multi-user real-time features (chat, notifications, live dashboards). For single-user interactions, regular Turbo Stream responses are sufficient.
Stimulus + Turbo Integration
- Use turbo:submit-end to react after a form submits (e.g., close a modal, reset a form):
// form_controller.js
closeOnSuccess(event) {
if (event.detail.success) {
this.dialogTarget.close()
this.element.reset()
}
}
<form data-controller="form" data-action="turbo:submit-end->form#closeOnSuccess">
- Use turbo:before-fetch-request to add headers or modify requests when needed.
- Use turbo:frame-load to run code after a frame loads new content.
- Do NOT use Stimulus to do what Turbo already handles. If you're writing fetch() in a controller, you're likely reinventing Turbo.
Naming Conventions
- Turbo Frame IDs: use resourcename or resourcenameid (e.g., comments, comment42)
- DOM IDs for stream targets: use dom_id(@record) helper for consistency:
<div id="<%= dom_id(@comment) %>">
- Stimulus controllers: one responsibility per controller, named after the behavior (e.g., formcontroller.js, clipboardcontroller.js, toggle_controller.js)