Migrating from Import Maps to esbuild in Rails

Page content
esbuild, an extremely fast bundler for the web.

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.

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.

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.

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.

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.