Understanding importmap-rails
If you’ve worked with modern JavaScript, you’re familiar with ES modules and import statements. Rails apps can use esbuild (or vite or bun) for this, but the default option (Rails way) is importmap-rails. It lets you write import { Controller } from "@hotwired/stimulus" without any build step at all.
Ever thought about how this works?
Import maps, just a web standard
Import maps are a web standard that tells browsers how to resolve bare module specifiers. A bare module specifier looks like import React from "react", which isn’t valid ESM on its own. The browser needs an absolute path (/assets/react.js), relative path (./react.js), or HTTP URL (https://cdn.example.com/react.js).
Import maps provide the translation:
<script type="importmap">
{
"imports": {
"application": "/assets/application-abc123.js",
"@hotwired/stimulus": "/assets/stimulus.min-def456.js",
"controllers/application": "/assets/controllers/application-ghi789.js"
}
}
</script>
When your JavaScript says import { Controller } from "@hotwired/stimulus", the browser looks up "@hotwired/stimulus" in this map and loads /assets/stimulus.min-def456.js.
The importmap-rails gem generates this script tag for you. It appears in your layout via <%= javascript_importmap_tags %>, which reads your config/importmap.rb configuration and outputs the importmap along with modulepreload links (which tell the browser to start downloading your JavaScript files immediately, rather than waiting to discover each import one at a time) and your application entry point.
Configuring with pin
In config/importmap.rb, you define what goes in that map:
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@rails/request.js", to: "@rails--request.js.js"
Each pin creates a mapping (entry) in the importmap. The first argument is the bare module specifier you’ll write in your import statement. The to: attribute specifies which file should be loaded from your asset pipeline (typically from app/javascript or vendor/javascript).
To add a package from npm, run:
./bin/importmap pin package-name
This downloads the package file into vendor/javascript and adds the pin to your config/importmap.rb. The default is to use JSPM.org as the CDN, but you can specify others:
./bin/importmap pin react --from unpkg
./bin/importmap pin react --from jsdelivr
The downloaded files are checked into your source control and served through your application’s asset pipeline.
Mapping directories with pin_all_from
Instead of pinning files individually, you can map entire directories:
pin_all_from "app/javascript/controllers", under: "controllers"
pin_all_from "app/javascript/turbo_stream_actions", under: "turbo_stream_actions"
The under: attribute creates a namespace prefix. Every file in the directory becomes “importable” with that prefix.
So app/javascript/controllers/reposition_controller.js becomes:
import RepositionController from "controllers/reposition_controller"
And app/javascript/turbo_stream_actions/set_data_attribute.js becomes:
import set_data_attribute from "turbo_stream_actions/set_data_attribute"
Product-minded Rails notes
Monthly roundup on what we're building, open source work and recent articles.
Example: custom Turbo Stream actions
Let’s now look at how all these pieces connect. Say you want to register a custom Turbo Stream action (as I wrote about here and here).
First, in config/importmap.rb, you map the directory:
pin_all_from "app/javascript/turbo_stream_actions", under: "turbo_stream_actions"
When Rails generates the importmap (via <%= javascript_importmap_tags %>), it scans that directory and creates entries for each file:
{
"imports": {
"turbo_stream_actions": "/assets/turbo_stream_actions/index-abc.js",
"turbo_stream_actions/set_data_attribute": "/assets/turbo_stream_actions/set_data_attribute-xyz.js"
}
}
Now you can create your custom action at app/javascript/turbo_stream_actions/set_data_attribute.js:
export default function() {
// your custom logic
}
And register it in app/javascript/turbo_stream_actions/index.js:
import set_data_attribute from "turbo_stream_actions/set_data_attribute"
Turbo.StreamActions.set_data_attribute = set_data_attribute
The browser sees "turbo_stream_actions/set_data_attribute", looks it up in the importmap, finds /assets/turbo_stream_actions/set_data_attribute-xyz.js and loads it.
Finally, in your main app/javascript/application.js:
import "turbo_stream_actions"
Your custom action is now registered. The import path ("turbo_stream_actions/set_data_attribute") matches the under: namespace from your pin_all_from configuration.
How Stimulus controllers use this
The same pattern is used for Stimulus. In config/importmap.rb:
pin_all_from "app/javascript/controllers", under: "controllers"
The under: value here could be anything: "my_controllers" or "stimulus_controllers". But "controllers" is the convention. It becomes the import prefix for everything in that directory.
In app/javascript/controllers/index.js:
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)
The application import comes from app/javascript/controllers/application.js:
import { Application } from "@hotwired/stimulus"
const application = Application.start()
application.debug = false
window.Stimulus = application
export { application }
The eagerLoadControllersFrom function scans the importmap for entries starting with "controllers/" and automatically imports and registers them. Add a new controller file and it’s instantly available.
Because of pin_all_from with under: "controllers", the file app/javascript/controllers/application.js is “importable” as "controllers/application". The pattern is the same: directory structure maps to import paths through the under: namespace.
Want to read me more?
-
Update page title counter with custom turbo streams in Rails
Learn how to dynamically update your page title with a counter using custom Turbo Stream actions in Rails. A step-by-step guide to creating reusable Turbo Stream actions. -
Update favicon with badge using custom turbo streams in Rails
Extend your Rails app with a favicon badge counter using custom Turbo Stream actions. Learn how to create visual notifications for pinned tabs. -
Make Your Rails App Future Proof: Move From React to Hotwire
Revitalize your Rails app by moving from React to ViewComponent and Hotwire for a leaner codebase and improved team morale. Enjoy simplified development with almost no JavaScript and gain free access to Rails Designer. Contact us to transform your Rails app today!
Over to you…
What did you like about this article? Learned something knew? Found something is missing or even broken? 🫣 Let me (and others) know!
Comments are powered by Chirp Form
{{comment}}