importmap-rails: dropping the JavaScript build step from a Rails application
Use ESM with importmap to manage modern JavaScript in Rails without transpiling or bundling.
At a glance
- What is it?
- importmap-rails lets a Rails application use ES modules with a native import map, so there is no bundler, no transpiler and no npm step, only the asset pipeline Rails already ships. The trade is that you ship many small files instead of one bundle, and the pinning and integrity work moves into your hands.
- Who is it for?
- Adopt importmap-rails if you want a Rails application to ship modern ES modules without taking on a JavaScript toolchain, and you are willing to pin every dependency and think about cache invalidation at file granularity. Do not adopt it if your front end needs transpilation for older browsers or a build step for its own sake, because that is the capability you are giving up.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 14 days ago.
- What is it written in?
- Mainly Ruby, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
What the import map actually does in the browser
The mechanism is worth understanding precisely, because everything else in the library follows from it.
Import maps let you import JavaScript modules using logical names that map to versioned or digested files, directly from the browser. At their core, import maps are essentially a string substitution for what are referred to as bare module specifiers. A bare module specifier looks like this: import React from "react".
That is not compatible with the ES Module loader spec, because the ESM spec has no idea what react is. To be ESM compatible, you must provide one of three types of specifiers: an absolute path, import React from "/Users/DHH/projects/basecamp/node_modules/react"; a relative path, import React from "./node_modules/react"; or an HTTP path, import React from "https://ga.jspm.io/npm:[email protected]/index.js".
An import map is the table that says which of the three a given bare name resolves to. The configuration in config/importmap.rb is that table:
# config/importmap.rb
pin "react", to: "https://ga.jspm.io/npm:[email protected]/index.js"which means every time you see import React from "react", change it to import React from the jspm URL. The browser does the substitution before resolving the module, and everything downstream, the application code, the framework libraries, the vendor files, works as ordinary ES modules with no build step in between.
The important property is that the substitution is the browser's job, not the compiler's. Nothing about the module graph is computed at build time, so there is no build step to run, no bundle to invalidate, and no transpiler to configure. The browser already has a module loader, and the import map is a configuration format for it.
Import maps are supported natively in all major, modern browsers, and for legacy browsers without native support the README points to a shim, es-module-shims, which is the same project family that ships the es-module-lexer.
The caching argument, which is the actual reason to choose it
The README makes a performance argument that is easy to skip and worth understanding, because it is the counter-argument to the usual objection about shipping many files.
With this approach you ship many small JavaScript files instead of one big JavaScript file. Thanks to HTTP/2 that no longer carries a material performance penalty during the initial transport, and in fact offers substantial benefits over the long run due to better caching dynamics. Whereas before any change to any JavaScript file included in your big bundle would invalidate the cache for the whole bundle, now only the cache for that single file is invalidated.
That is the whole case. Bundling is a caching optimisation, and it is only an optimisation while the bundle is stable. In an application with an active front end, the bundle changes often, and every change invalidates it for every user. With per-file caching, a change to one controller invalidates one file, and the rest of the application stays in the browser cache.
The flip side is visible in the same sentence, and it is a real cost. The initial transport carries more requests, and the browser makes more of them. HTTP/2's multiplexing is what makes this acceptable, and the argument does not extend to HTTP/1.1, where a hundred small files is a hundred round trips.
There is also a second-order effect that shows up in day-to-day development. With a bundler, a change to a source file triggers a rebuild, and the rebuild time is the feedback loop. Without a bundler, there is no rebuild, because there is no build, so the feedback loop is the browser's own module fetch. The README does not put it that way, but it is the practical difference people notice first, and it is the reason DHH wrote the piece it links to about modern web apps without JavaScript bundling or transpiling.
Installing it, and the two commands
Importmap for Rails is automatically included in Rails 7+ for new applications, and the manual route for existing applications is two commands:
./bin/bundle add importmap-rails
./bin/rails importmap:installThe install task generates the configuration file and the entrypoint, and after that the application has a config/importmap.rb and an app/javascript/application.js.
There is one version constraint worth noting. In order to use JavaScript from Rails frameworks like Action Cable, Action Text and Active Storage, you must be running Rails 7.0+, because that was the first version that shipped with ESM compatible builds of these libraries. If you are on an older Rails and need those, you can pin the libraries manually by relying on the compiled versions included in Rails:
pin "@rails/actioncable", to: "actioncable.esm.js"
pin "@rails/activestorage", to: "activestorage.esm.js"
pin "@rails/actiontext", to: "actiontext.esm.js"
pin "trix"The wiring on the server side is three lines. The import map is set up through Rails.application.importmap with the configuration in config/importmap.rb. The map is inlined in the head of your application layout using the javascript_importmap_tags helper, which sets up the JSON configuration inside a script tag of type importmap. Then the application entrypoint is imported with a script tag of type module, import "application". That logical entrypoint, application, is mapped in the importmap script tag to the file app/javascript/application.js.
One operational detail catches people out. This file is automatically reloaded in development upon changes, but you must restart the server if you remove pins and need them gone from the rendered importmap or list of preloads. Removing a pin is not a hot-reload operation, which is a reasonable design given that the rendered map is what the browser has already fetched.
The last piece of the design is worth calling out because it is the one that saves you later. It makes sense to use logical names that match the package names used by npm, such that if you later want to start transpiling or bundling your code, you won't have to change any module imports. The import map is deliberately shaped like a bundler alias, so that the migration in either direction is a configuration change rather than a rewrite.
Pinning local modules, and the Propshaft difference
If you want to import local JavaScript module files from app/javascript/src or other sub-folders of app/javascript such as channels, you must pin these to be able to import them. The way to do that without listing every file is pin_all_from, which picks all files in a specific folder:
# config/importmap.rb
pin_all_from 'app/javascript/src', under: 'src', to: 'src'
# With automatic integrity calculation for enhanced security
enable_integrity!
pin_all_from 'app/javascript/controllers', under: 'controllers', integrity: trueThe :to parameter is only required if you want to change the destination logical import name, and if you drop the :to option you must place the :under option directly after the first parameter. That positional requirement is a small usability wart, and it is documented, so it costs you one read of the docs rather than an afternoon.
Then in your application code the import looks like this:
// app/javascript/application.js
import { ExampleFunction } from 'src/example_function'which imports the function from app/javascript/src/example_function.js.
The Propshaft note explains why this step is required at all, and it is a useful piece of history. Sprockets used to serve assets, albeit without filename digests, it couldn't find from the app/javascripts folder with logical relative paths, meaning pinning local files wasn't needed. Propshaft doesn't have this fallback, so when you use Propshaft you have to pin your local modules.
That is a real behavioural difference between the two asset pipelines, and it catches teams who move an application from Sprockets to Propshaft and find that their local imports suddenly 404. The import map is the mechanism that makes the logical path resolvable, and with Propshaft there is no implicit fallback behind it.
The integrity support is the other thing to look at here. The enable_integrity! call enables integrity calculation globally, and integrity: true automatically calculates integrity hashes for all files in the directory, providing security benefits without manual hash management. Since the import map is what determines which file a bare specifier resolves to, an integrity hash on each pinned file is what stops a substituted file from being served silently, which is a meaningful protection when the resolution is happening in the browser rather than in a build step you control.
Getting npm packages in without npm
The bin/importmap command is the piece that makes the whole approach practical, because the hard part of going without a bundler is not the mapping, it is getting the files.
Importmap for Rails downloads and vendors your npm package dependencies via JavaScript CDNs that provide pre-compiled distribution versions. You can use the ./bin/importmap command to pin, unpin or update npm packages in your import map. By default this command uses an API from JSPM.org to resolve your package dependencies efficiently, and then adds the pins to your config/importmap.rb file.
./bin/importmap pin react
Pinning "react" to vendor/javascript/react.js via download from https://ga.jspm.io/npm:[email protected]/index.jsSo the package is downloaded into your repository under vendor/javascript/, and the pin in the config file records the version:
pin "react" # @19.1.0Other CDNs like unpkg.com and jsdelivr.com can be specified with --from:
./bin/importmap pin react --from unpkg
Pinning "react" to vendor/javascript/react.js via download from https://unpkg.com/[email protected]/index.jsThe version comment in the pin is a small but important detail, because it makes the config file reviewable. A diff in config/importmap.rb that shows react moving from one version comment to another is a legible change to a reviewer, and the same comment is what lets bin/importmap update know what it is updating.
The vendoring is what removes the CDN from the request path. The files are downloaded once at development time and committed to the repository, so the browser fetches them from your own asset pipeline, with your own digests and your own cache headers, rather than from a third party at runtime. That is a meaningful difference from the example in the earlier section, which pins react directly to a jspm URL, and it explains why the command exists even though the config format allows a URL pin.
The requirement this imposes is that the npm package must exist as a pre-compiled ESM build on the CDN. That is true for most modern packages and false for a meaningful minority, and the ones that are false are the ones where you would end up transpiling, which is the thing you decided not to do.
Where importmap-rails is the wrong choice
There are three limits, and the first is the one that decides the whole project.
The first is that the ecosystem is not fully ESM. The bin/importmap command resolves packages through a CDN that provides pre-compiled distribution versions, and if the package you need does not ship an ESM build, you are stuck. A CommonJS-only package needs a transpiler or a hand-written wrapper, and this project deliberately does not give you one. The same applies to syntax: without a transpiler, you cannot use a language feature the browser in your support matrix does not implement, and you cannot use TypeScript. A team whose front end is TypeScript, or whose design system is published as source rather than as a build, is in the wrong place.
The second is that pinning is manual work, and the volume of it depends on the size of the front end. Every local module under app/javascript needs a pin or a pin_all_from, every npm dependency needs a download and a vendor directory committed, and the configuration file becomes a file you maintain. There is no lockfile in the npm sense, no transitive dependency resolution you do not write yourself, and the version comments in the pins are a convention rather than an enforced lock. That is the price of removing the toolchain, and for a small application it is a good trade, while for a large one with hundreds of dependencies it is a real ongoing cost.
The third is the HTTP/2 dependency in the caching argument. The README justifies many small files by reference to HTTP/2, and the reasoning is sound for a modern deployment, but a Rails application served over HTTP/1.1, or through a proxy that does not multiplex, loses the transport advantage while keeping the per-file cache benefit. The result is still defensible, but it is not the case the README makes.
And there is a fourth point, which is the shim path. Native import map support is in all major modern browsers, and the README suggests a shim for legacy browsers without native support. A shim that emulates import maps in older browsers is a compatibility layer you now depend on, and it is a dependency outside the Rails ecosystem that your team has to evaluate on its own terms.
importmap-rails against the bundler it replaces
The honest comparison is with a JavaScript bundler in a Rails application, and the difference in approach is about where the work happens.
A bundler runs on your machine or in your CI. It reads your dependency graph, resolves versions, applies transforms, and emits an artefact. The artefact is self-contained and the browser does not need to understand anything beyond ES modules. The cost is a build step, a node_modules tree, a lockfile, a CI job that installs and builds, and a debugging story where the line numbers in the browser do not correspond to the lines in your source.
importmap-rails moves the resolution to the browser and removes the build. The browser receives a table of logical names to URLs and does the resolution itself. What you gain is a zero-build development loop, no node_modules, no lockfile to keep current, and no transpiler configuration.
What you give up is a specific set of things. TypeScript is gone, unless you run a separate type check. JSX is gone, which removes React's default setup unless the library is published pre-compiled. Code splitting is gone, since there is no build to split on, and the browser fetches what the import map points at. Tree shaking is gone, because there is no tree to shake; the browser gets the whole module. A CSS pipeline, if you relied on one, is a separate question.
Those are real losses, and a good chunk of the JavaScript ecosystem assumes a bundler exists. A Rails application with a modest amount of vanilla JavaScript and no framework is an excellent fit for this approach. A Rails application with a React front end built from TypeScript source is not.
There is a third option worth naming, and it is the one most teams land on. Use a bundler for the front end and importmap-rails for the small amount of framework glue, which is to say Action Cable, Action Text, Turbo and Stimulus. The Rails libraries ship ESM builds, the import map handles them with a handful of pins, and the heavier front end keeps its own pipeline. The README's own advice, using logical names that match npm package names so you can switch later, is designed for exactly that path.
Editorial conclusion
Adopt importmap-rails if you want a Rails application to ship modern ES modules without taking on a JavaScript toolchain, and you are willing to pin every dependency and think about cache invalidation at file granularity. Do not adopt it if your front end needs transpilation for older browsers or a build step for its own sake, because that is the capability you are giving up. Verify first that the npm packages you need exist as pre-compiled ESM builds on a CDN the bin/importmap command can reach, that the framework libraries you use have ESM-compatible versions, which the README says means Rails 7.0 or newer for Action Cable, Action Text and Active Storage, and that the integrity hashes you enable cover every file you pin.
Frequently asked questions
How do I install importmap-rails in an existing Rails application?
Run `./bin/bundle add importmap-rails` and then `./bin/rails importmap:install`, which generates the configuration file and a sample setup. It is automatically included in Rails 7+ for new applications.
What does an import map actually do in the browser?
It is a string substitution for bare module specifiers. The configuration in config/importmap.rb maps a name like "react" to one of the three ESM-compatible forms, an absolute path, a relative path or an HTTP path, and the browser performs the substitution before resolving the module.
How do I add an npm package without a package manager?
Use the ./bin/importmap command, which resolves the package through the JSPM.org API, downloads it into vendor/javascript/ and adds a pin to config/importmap.rb. The CDN can be chosen with --from, with unpkg and jsdelivr named in the README.
Which Rails version do I need to use the ESM builds of Action Cable and Active Storage?
The README states you must be running Rails 7.0 or newer, because that was the first version that shipped with ESM compatible builds of those libraries. On older versions you can pin them manually to actioncable.esm.js, activestorage.esm.js and actiontext.esm.js.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/rails-importmap-rails)Was this useful?