Migrating from Import Maps to esbuild in Rails

esbuild, an extremely fast bundler for the web.
Introduction
Rails comes with import maps enabled by default, so you can use Stimulus and other JS libraries without bundling.
It works well, and I even launched some sites with just import maps, but it comes with some limitations:
- Your JS is not compiled or minified / obfuscated. In other words, your JS source code is in the open. There is simply no way to do this without a bundler.
- Importing CSS in your JS? Not going to work.
- Import maps is optimized for HTTP/2, but without it your browser is gonna request for multiple JS files individually on page load.
In this blog post, let’s walk through migrating from import maps to esbuild
for modern JS bundling. Why esbuild
? Because it seems like the simplest and fastest JavaScript bundler that just works.
jsbundling-rails gem
First of all, you need the jsbundling-rails
gem.
Go ahead and get the gem:
bundle add jsbundling-rails
or manually add it to your Gemfile
:
gem "jsbundling-rails", "~> 1.3"
While you’re at it, let’s remove the importmap-rails
gem:
# gem "importmap-rails"
and run bundle
.
Then, run the install script:
rails javascript:install:esbuild
This install script adds a package.json
with a build
script:
{
"name": "app",
"private": true,
"devDependencies": {
"esbuild": "^0.25.4"
},
"scripts": {
"build": "esbuild app/javascript/*.* --bundle --sourcemap --format=esm --outdir=app/assets/builds --public-path=/assets"
}
}
and also adds a watcher process to your Procfile.dev
:
js: yarn build --watch
and also generates a yarn.lock
file. Sorry, Rails doesn’t quite like npm
, don’t try to use npm
for less tears.
And then it tries to run the build script:

Error message.
Let’s fix the first issue:
yarn add @hotwired/turbo-rails
And the second one:
// app/javascript/application.js
- import "controllers";
+ import "./controllers";
More issues
If we run yarn run build
now, we run into more issues:

More errors.
If we take a step back, esbuild
requires that we replace these named paths with relative paths. And ensure that NPM modules are actually installed with yarn add
.
Note that eager-loading Stimulus controllers will no longer work with esbuild
, because @hotwired/stimulus-loading
is not actually a published NPM module.
We can make all these errors go away with the command:
rails stimulus:manifest:update
Which updates the app/javascript/controllers/index.js
file for us:

Auto-generate Stimulus controller loading.
So in the future be sure to use rails generate stimulus controllerName
to add a Stimulus controller, or if you didn’t then re-run rails stimulus:manifest:update
manually.
The remaining errors should simply be missing NPM modules which you can install with yarn add
.
Update JS tag in application.html.erb
Finally, we have to update the JS tag in application.html.erb
:

Update the tag to load the bundled JS.
Note the use of type=module
here because of this issue. I can provide no further explanation other than that this fix works.
One important caveat: If you are importing CSS in your JS esbuild
will also generate an application.css
file in the build folder, which conflicts with your existing application.css
file. The way to get around this is to simply rename your application.js
file to application-esbuild.js
or something, and then also update the tag reference above. This is not great but I don’t see any way around this right now, in fact this renaming is documented here.
Also, if you are coming from Sprockets you can remove that manifest.js
file completely and replace Sprockets with Propshaft. And you will need to handle loading CSS in CSS (replacing require
with @import url(...)
) because this is no longer supported by Propshaft.
Minifying JS for production
If you run rails assets:precompile
right now, you will see that the bundle is not minified.
esbuild
also doesn’t really have a default config file, because its config file is not declarative based like other bundlers, but rather it is like a script.
So the simplest way to get minification to work without getting into the config mess is to add --minify
to the build
task in package.json
, because rails assets:precompile
runs this build
script.
And then add a non-minified build build-dev
in package.json
. And modify Procfile.dev
so that we use build-dev
in our local development.
Basically this:
// package.json
- "build": "esbuild app/javascript/*.* --bundle --sourcemap --format=esm --outdir=app/assets/builds --public-path=/assets",
+ "build": "esbuild app/javascript/*.* --bundle --sourcemap --minify --format=esm --outdir=app/assets/builds --public-path=/assets",
+ "build-dev": "esbuild app/javascript/*.* --bundle --sourcemap --format=esm --outdir=app/assets/builds --public-path=/assets"
// Procfile.dev
- js: yarn build --watch
+ js: yarn build-dev --watch
And of course, in your CI/CD pipeline, you will somehow have to add a Node runtime because you now need to run yarn
.
Summary
In this blog post, I have demonstrated migrating from the default import maps in Rails to bundling JavaScript using esbuild
instead.
If you’re also looking for CSS bundling in Rails, then you will need cssbundling-rails. Good luck.