<!DOCTYPE html>
<html>
<head>
<title><%= content_for(:title) || "App" %></title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
</head>
<body>
<div data-controller="hello">
<input data-hello-target="name" type="text">
<button data-action="hello#greet">Greet</button>
<span data-hello-target="output"></span>
</div>
<%= yield %>
</body>
</html>
pin "application", preload: true
pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
# Auto-pins every file under app/javascript/controllers as controllers/*
pin_all_from "app/javascript/controllers", under: "controllers"
import { Application } from "@hotwired/stimulus"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
const application = Application.start()
// Expose for the browser console during development
application.debug = false
window.Stimulus = application
eagerLoadControllersFrom("controllers", application)
export { application }
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["name", "output"]
connect() {
this.element.dataset.connected = "true"
}
greet() {
const name = this.nameTarget.value.trim()
this.outputTarget.textContent = name.length
? `Hello, ${name}!`
: "Please enter a name."
}
}
This snippet shows how Rails 7 loads Stimulus controllers without a bundler, using importmap-rails to serve ES modules directly to the browser. The core idea is that instead of packaging JavaScript with webpack or esbuild, the app declares a set of named import paths in a Ruby DSL, and the browser resolves those names at runtime via an <script type="importmap"> tag rendered into the layout. This trades build-step complexity for a simpler mental model at the cost of no tree-shaking or transpilation, which is why it suits the small, dependency-light JavaScript typical of Hotwire apps.
config/importmap.rb is the source of truth for module resolution. Each pin maps a bare specifier such as application or @hotwired/stimulus to a fingerprinted asset URL served by Propshaft or Sprockets. The pin_all_from call is the key convenience for Stimulus: it walks app/javascript/controllers, pins every file under the controllers/ prefix, and lets new controllers appear without touching config. Pinning @hotwired/stimulus and @hotwired/stimulus-loading from the vendored copies (vendor/javascript) keeps everything self-hosted rather than reaching out to a CDN.
app/javascript/controllers/index.js bootstraps the framework. It creates the Application, exposes it as window.Stimulus for debugging, and then calls eagerLoadControllersFrom("controllers", application). That helper reads the import map, finds every path under the controllers prefix, imports each module, and registers it by deriving an identifier from the filename — so hello_controller.js becomes the hello identifier that data-controller="hello" targets. Eager loading is chosen here for predictability; lazyLoadControllersFrom is the alternative when controller count grows large.
hello_controller.js is a conventional controller: it declares a name target and an output target via the static targets array, and greet() reads the input value to update the DOM. Because the identifier is inferred from the filename, no manual registration is needed.
Finally application.html.erb shows the two tags that make it work: javascript_importmap_tags emits the import map plus the module preloads and the entrypoint, while data-turbo-track keeps assets fresh across Turbo navigations. A common pitfall is forgetting that renamed files change their identifier, silently breaking data-controller attributes.
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.