CLAUDE.md

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

  1. 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 >>>>>>>>>>>>>>>>>>>>>>>>>

  1. No Inline Script Tags

NEVER use tags in ERB partials, views, or Turbo Stream responses.

  1. 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" (or patch, 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 src attribute 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)