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.

Product-minded Rails notes

Once a month: straightforward notes on improving UX in Rails—what to simplify, what to measure, and UI/frontend changes that move real usage.

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

Want to read me more?