JavaScript without the build step headache
For years, Rails developers have watched the JavaScript ecosystem grow increasingly complex. Build tools, transpilers, and bundlers became mandatory just to write modern code. Import maps in Rails: a Ruby on Rails guide for developers changes that story—they give us a way to use ES modules directly in the browser without webpack, esbuild, or any build step at all. If you're tired of npm install taking five minutes and your CI pipeline choking on bundle analysis, this guide will show you how import maps simplify your stack.
What import maps actually do
Import maps are a browser standard that let you map bare module specifiers—like import * as Turbo from "turbo"—to actual URLs the browser can fetch. Without import maps, that bare specifier would fail because browsers only understand relative or absolute paths. With them, you tell the browser where to find turbo, and it handles the rest.
Rails bakes import maps support directly into the framework via the importmap-rails gem. When you generate a new Rails app with the --javascript importmap flag (or choose it during setup), Rails scaffolds your JavaScript infrastructure for you. No npm, no package.json in the traditional sense, just a simple configuration file that maps package names to CDN URLs.
The key insight: modern browsers understand ES modules natively. We're not using a polyfill or reinventing the wheel. We're using what the browser already gives us and adding a routing layer on top.
Pin packages with bin/importmap
In a Rails app using import maps, the entry point is app/javascript/application.js, and your dependency declarations live in config/importmap.rb. This Ruby file is where you "pin" packages—register them so Rails knows where to fetch them from.
Here's what a typical importmap configuration looks like:
pin "application", preload: true
pin "@hotwire/turbo-rails", to: "turbo.min.js"
pin "@hotwire/stimulus", to: "stimulus.min.js"
pin "tom-select", to: "https://cdn.jsdelivr.net/npm/tom-select@2.0.0/dist/js/tom-select.complete.min.js"
pin "chart.js", to: "https://cdn.jsdelivr.net/npm/chart.js@3"The bin/importmap CLI tool is your workhorse here. When you want to add a new dependency, you don't run npm install. You run:
./bin/importmap pin lodash-esRails inspects npm's registry, finds the package, and automatically adds the appropriate pin to your configuration. It picks sensible defaults—usually the minified CDN URL from jsdelivr or unpkg. You can override this later if you need to.
This approach feels lightweight because it is. You're not maintaining a massive node_modules directory. You're just declaring which packages you want, where they live, and letting the browser fetch them. The preload: true` directive on application.js tells Rails to preload that bundle so it starts downloading before your HTML even finishes parsing.
For packages you author yourself or want to version-control, you can use local pins:
pin "my_custom_module", to: "my_custom_module.js"This points to app/javascript/my_custom_module.js. Rails serves it from your app, giving you the best of both worlds: vendored control over your own code, CDN convenience for third-party libraries.
Modern browsers load ES modules natively
The reason import maps work at all is that every modern browser—Chrome, Firefox, Safari, Edge—supports ES modules. When Rails renders your layout, it includes a script tag like this:
<script type="module">
import * as Turbo from "@hotwire/turbo-rails"
import * as Stimulus from "@hotwire/stimulus"
import Application from "./application.js"
</script>The browser sees type="module", knows these are ES modules, and reads the import map to resolve bare specifiers. No transpiler needed. No build step. The browser's native module loader handles everything.
This has real performance implications. Your JavaScript starts executing as soon as the browser downloads it—no compilation step, no waiting for webpack to finish. For applications with modest JavaScript needs, this can be noticeably faster than traditional bundled approaches.
Import map resolution is also deterministic and transparent. You can inspect config/importmap.rb and immediately see where every dependency comes from. There's no magic node_modules resolution algorithm. There's no package-lock.json to reconcile. It's straightforward enough that a junior developer can understand the entire dependency graph in minutes.
One caveat: import maps work best for libraries published as ES modules on CDNs. If you need a package that's only available as CommonJS, or one with complex peer dependencies, import maps won't handle it gracefully. That's when you reach for a bundler.
Great with Hotwire
Import maps and Hotwire are a natural pairing. Hotwire—Turbo and Stimulus—is the Rails team's modern approach to interactivity: progressively enhance server-rendered HTML instead of building a single-page app in JavaScript. Both philosophies resist unnecessary complexity.
Turbo ships as an ES module, so pinning it is trivial. Stimulus controllers, written as ES classes, integrate seamlessly with import maps. Here's a real example:
// app/javascript/controllers/search_controller.js
import { Controller } from "@hotwire/stimulus"
export default class extends Controller {
static targets = ["input", "results"]
static values = { url: String }
search() {
const query = this.inputTarget.value
fetch(`${this.urlValue}?q=${query}`)
.then(r => r.html())
.then(html => this.resultsTarget.innerHTML = html)
}
}Your controller is a plain ES module. Stimulus discovers it automatically if you follow naming conventions. You import what you need from Hotwire packages—already pinned—and write modern JavaScript without any build complexity.
Many teams running Hotwire don't need complex JavaScript at all. A few Stimulus controllers, maybe a third-party charting library, and you're done. Import maps handle that perfectly. You're not paying the cost of a build tool for code that doesn't need one.
Where Hotwire and import maps really shine is in teams that want to ship features fast without JavaScript infrastructure maintenance. New developer onboarding doesn't involve explaining webpack plugins or babel presets. You check the JavaScript folder, see the pinned packages, and start contributing.
Use jsbundling when you need a bundler
Import maps are powerful, but they're not a universal solution. If you need code splitting, tree shaking, minification beyond what CDNs provide, or npm packages with complex dependencies, you need a bundler.
Rails gives you jsbundling-rails for exactly this scenario. It integrates esbuild or webpack into your asset pipeline. You still use Rails conventions—your code lives in app/javascript, your entry point is application.js—but now a bundler processes it before serving.
The choice between import maps and jsbundling isn't binary. You can even use both: keep most of your app on import maps for simplicity, and introduce a bundler only for performance-critical sections or complex dependencies.
A practical heuristic: start with import maps. If you find yourself fighting the constraints, or if performance profiling shows you need bundling, switch to jsbundling. Most Rails applications—especially those using Hotwire—never need to make that switch.
Common mistakes
Forgetting to pin before importing. The most common error is writing import X from "package-name" without pinning it first. The browser won't find it. Always run ./bin/importmap pin package-name before using it, or manually add the pin to config/importmap.rb.
Mixing CommonJS and ES modules. Import maps work with ES modules. If a library only exports CommonJS, import maps can't use it directly. Check the npm package documentation or use a different library. Don't waste time fighting incompatible formats.
Overcomplicating vendor code. Resist the urge to vendorize everything. If a library is stable and available on a CDN, pin it. Only commit source code for packages you're actively developing or can't find elsewhere. Duplicating vendor libraries in your repo wastes space and creates maintenance burden.
Not preloading critical bundles. The preload: true directive is important. If Turbo or your application.js bundle is critical path, preload it. This hints to the browser to prioritize the download, improving perceived performance.
Key takeaways
- Import maps let you use ES modules in the browser without a build step, routing bare module specifiers to actual URLs.
- Pin packages in
config/importmap.rbusingbin/importmap pin package-nameor manual configuration. - Modern browsers resolve import maps natively—no transpiler or bundler required, which speeds up development and reduces infrastructure overhead.
- Import maps pair exceptionally well with Hotwire, eliminating the need for complex JavaScript tooling in many Rails applications.
- When you need code splitting, tree shaking, or complex dependency management, graduate to jsbundling-rails rather than forcing import maps to do something they're not designed for.
- Start simple: import maps first, bundlers only if you hit their limitations and can demonstrate the performance or maintainability gains justify the added complexity.






Comments
Loading comments…