As you may have heard by now, Rails 7 comes out of the box with importmap-rails and the mighty Webpacker is no longer the default for new Rails applications.
For those who aren't ready to switch to import maps and don’t want to use Webpacker now that it is no longer a Rails default, jsbundling-rails was created. This gem adds the option to use webpack, rollup, or esbuild to bundle JavaScript while using the asset pipeline to deliver the bundled files.
Of the three JavaScript bundling options, the Rails community seems to be most interested in using esbuild, which aims to bring about a “new era of build tool performance” and offers extremely fast build times and enough features for most users’ needs.
Using esbuild with Rails, via jsbundling-rails is very simple, especially in a new Rails 7 application; however, the default esbuild configuration is missing a few quality of life features. Most important among these missing features is live reloading. Out of the box, each time you change a file, you need to refresh the page to see your changes.
Once you’ve gotten used to live reloading (or its fancier cousin, Hot Module Replacement), losing it is tough.
Today, esbuild doesn’t support HMR, but with some effort it is possible to configure esbuild to support live reloading via automatic page refreshing, and that’s what we’re going to do today.
We’ll start from a fresh Rails 7 install and then modify esbuild to support live reloading when JavaScript, CSS, and HTML files change.
Before we get started, please note that this very much an experiment that hasn’t been battle-tested. I’m hoping that this is a nice jumping off point for discussion and improvements. YMMV.
With that disclaimer out of the way, let’s get started!
Application setup
We’ll start by creating a new Rails 7 application.
If you aren’t already using Rails 7 for new Rails applications locally, this article can help you get your local environment ready.
Once your rails new
command is ready for Rails 7, from your terminal:
rails new live_esbuild -j esbuild
cd live_esbuild
rails db:create
rails g controller Home index
Here we created a new Rails application set to use jsbundling-rails
with esbuild and then generated a controller we’ll use to verify that the esbuild configuration works.
Booting up
In addition to installing esbuild for us, jsbundling-rails
creates a few files that simplify starting the server and building assets for development. It also changes how you’ll boot up your Rails app locally.
Rather than using rails s
, you’ll use bin/dev
. bin/dev
uses foreman to run multiple start up scripts, via Procfile.dev
. We’ll make a change to the Procfile.dev
later, but for now just know that when you’re ready to boot up your app, use bin/dev
to make sure your assets are built properly.
Configure esbuild for live reloading
To enable live reloading, we’ll start by creating an esbuild config file. From your terminal:
touch esbuild-dev.config.js
To make things a bit more consumable, we’ll first enable live reloading for JavaScript files only, leaving CSS and HTML changes to wait for manual page refreshes.
We’ll add reloading for views and CSS next, but we’ll start simpler.
To enable live reloading on JavaScript changes, update esbuild-dev.config.js
like this:
#!/usr/bin/env node
const path = require('path')
const http = require('http')
const watch = process.argv.includes('--watch')
const clients = []
const watchOptions = {
onRebuild: (error, result) => {
if (error) {
console.error('Build failed:', error)
} else {
console.log('Build succeeded')
clients.forEach((res) => res.write('data: update\n\n'))
clients.length = 0
}
}
}
require("esbuild").build({
entryPoints: ["application.js"],
bundle: true,
outdir: path.join(process.cwd(), "app/assets/builds"),
absWorkingDir: path.join(process.cwd(), "app/javascript"),
watch: watch && watchOptions,
banner: {
js: ' (() => new EventSource("http://localhost:8082").onmessage = () => location.reload())();',
},
}).catch(() => process.exit(1));
http.createServer((req, res) => {
return clients.push(
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Access-Control-Allow-Origin": "*",
Connection: "keep-alive",
}),
);
}).listen(8082);
There’s a lot going on here, let’s walk through it a section at a time:
const path = require('path')
const http = require('http')
const watch = process.argv.includes('--watch')
let clients = []
First we require packages and define a few variables, easy so far, right?
Next, watchOptions
:
const watchOptions = {
onRebuild: (error, result) => {
if (error) {
console.error('Build failed:', error)
} else {
console.log('Build succeeded')
clients.forEach((res) => res.write('data: update\n\n'))
clients.length = 0
}
}
}
watchOptions
will be passed to esbuild to define what happens each time an esbuild rebuild is triggered.
When there’s an error, we output the error, otherwise, we output a success message and then use res.write
to send data out to each client.
Finally, clients.length = 0
empties the clients
array to prepare it for the next rebuild.
require("esbuild").build({
entryPoints: ["application.js"],
bundle: true,
outdir: path.join(process.cwd(), "app/assets/builds"),
absWorkingDir: path.join(process.cwd(), "app/javascript"),
watch: watch && watchOptions,
banner: {
js: ' (() => new EventSource("http://localhost:8082").onmessage = () => location.reload())();',
},
}).catch(() => process.exit(1));
This section defines the esbuild build
command, passing in the options we need to make our (JavaScript only) live reload work.
The important options are the watch option, which takes the watch
and watchOptions
variables we defined earlier and banner
.
esbuild’s banner option allows us to prepend arbitrary code to the JavaScript file built by esbuild. In this case, we insert an EventSource that fires location.reload()
each time a message is received from localhost:8082
.
Inserting the EventSource
banner and sending a new request from 8082
each time rebuild
runs is what enables live reloading for JavaScript files to work. Without the EventSource and the local request sent on each rebuild, we would need to refresh the page manually to see changes in our JavaScript files.
http.createServer((req, res) => {
return clients.push(
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Access-Control-Allow-Origin": "*",
Connection: "keep-alive",
}),
);
}).listen(8082);
This section at the end of the file simply starts up a local web server using node’s http
module.
With the esbuild file updated, we need to update package.json
to use the new config file:
"scripts": {
"build": "esbuild app/javascript/*.* --bundle --outdir=app/assets/builds",
"start": "node esbuild-dev.config.js"
}
Here we updated the scripts
section of package.json
to add a new start
script that uses our new config file. We’ve left build
as-is since build
will be used on production deployments where our live reloading isn’t needed.
Next, update Procfile.dev
to use the start
script:
web: bin/rails server -p 3000
js: yarn start --watch
Finally, let’s make sure our JavaScript reloading works. Update app/views/home/index.html.erb
to connect the default hello
Stimulus controller:
<h1 data-controller="hello">Home#index</h1>
<p>Find me in app/views/home/index.html.erb</p>
Now boot up the app with bin/dev
and head to http://localhost:3000/home/index.
Then open up app/javascript/hello_controller.js
and make a change to the connect
method, maybe something like this:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.textContent = "Hello Peter. What's happening?"
}
}
If all has gone well, you should see the new Hello Peter header on the page, replacing the Hello World header.
If all you want is JavaScript live reloading, feel free to stop here. If you want live reloading for your HTML and CSS files, that’s where we’re heading next.
HTML and CSS live reloading
esbuild helpfully watches our JavaScript files and rebuilds every time they change. It doesn’t know anything about non-JS files, and so we’ll need to branch out a bit to get full live reloading in place.
Our basic approach will be to scrap esbuild’s watch mechanism and replace it with our own file system monitoring that triggers rebuilds and pushes updates over the local server when needed.
To start, we’re going to use chokidar to watch our file system for changes, so that we can reload when we update a view or a CSS file, not just JavaScript files.
Install chokidar from your terminal with:
yarn add chokidar -D
With chokidar installed, we’ll update esbuild-dev.config.js
again, like this:
#!/usr/bin/env node
const path = require('path')
const chokidar = require('chokidar')
const http = require('http')
const clients = []
http.createServer((req, res) => {
return clients.push(
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Access-Control-Allow-Origin": "*",
Connection: "keep-alive",
}),
);
}).listen(8082);
async function builder() {
let result = await require("esbuild").build({
entryPoints: ["application.js"],
bundle: true,
outdir: path.join(process.cwd(), "app/assets/builds"),
absWorkingDir: path.join(process.cwd(), "app/javascript"),
incremental: true,
banner: {
js: ' (() => new EventSource("http://localhost:8082").onmessage = () => location.reload())();',
},
})
chokidar.watch(["./app/javascript/**/*.js", "./app/views/**/*.html.erb", "./app/assets/stylesheets/*.css"]).on('all', (event, path) => {
if (path.includes("javascript")) {
result.rebuild()
}
clients.forEach((res) => res.write('data: update\n\n'))
clients.length = 0
});
}
builder()
Again, lots going on here. Let’s step through the important bits.
const chokidar = require('chokidar')
First, we require chokidar
, which we need to setup file system watching. Starting easy again.
Next, we setup the build
task:
async function builder() {
let result = await require("esbuild").build({
// snip unchanged options
incremental: true,
})
chokidar.watch(["./app/javascript/**/*.js", "./app/views/**/*.html.erb", "./app/assets/stylesheets/*.css"]).on('all', (event, path) => {
if (path.includes("javascript")) {
result.rebuild()
}
clients.forEach((res) => res.write('data: update\n\n'))
clients.length = 0
});
}
Here we’ve moved the build
setup into an async function that assigns result
to build
.
We also added the incremental
flag to the builder, which makes repeated builds (which we’ll be doing) more efficient.
The watch
option was removed since we no longer want esbuild to watch for changes on rebuild on its own.
Next, we setup chokidar
to watch files in the javascript, views, and stylesheets directories. When a change is detected, we check the path to see if the file was a javascript file. If it was, we manually trigger a rebuild
of our JavaScript.
Finally, we send a request out from our local server, notifying the browser that it should reload the current page.
With these changes in place, stop the server if it is running and then bin/dev
again. Open up or refresh http://localhost:3000/home/index, make changes to index.html.erb
and application.css
and see that those changes trigger page reloads and that updating hello_controller.js
still triggers a reload.
Wrapping up
Today we created an esbuild config file that enables live reloading (but not HMR) for our jsbundling-rails powered Rails application. As I mentioned at the beginning of this article, this is very much an experiment and this configuration has not been tested on an application of any meaningful size. You can find the finished code for this example application on Github.
I’m certain that there are better routes out there to the same end result, and I’d love to hear from others on pitfalls to watch out for and ways to improve my approach.
While researching this problem, I leaned heavily on previous examples of esbuild configs. In particular, the examples found at these two links were very helpful in getting live reload to a functional state:
- This example esbuild config, from an issue on the jsbundling-rails Github repo
- This discussion on the esbuild Github repo
If you, like me, are a Rails developer that needs to learn more about bundling and bundlers, a great starting point is this deep dive into the world of bundlers. If you're intested in full HMR without any speed loss, and you're willing to break out of the standard Rails offerings, you might enjoy vite-ruby.
Finally, if you're using esbuild with Rails and Stimulus, you'll probably find the esbuild-rails plugin from Chris Oliver useful.
That’s all for today. As always - thanks for reading!
Top comments (4)
Interesting dive into the topic, and certainly a good learning exercise.
Once you have a dependency on node, using jsbundling-rails is not as compelling.
If you enjoy HMR and auto-reload, I'd recommend using Vite Rails, and optionally add:
vite-plugin-stimulus-hmr
: HMR for Stimulus controllersvite-plugin-full-reload
: automatically refresh when changing a viewHey Máximo, thanks for your reply!
I'm a big fan of Vite and use it regularly with Rails in personal projects — thanks for all of your work there!
This article is mostly for folks who are trying to make the switch from Webpacker and want to stick close to the default Rails menu. For them, switching to esbuild via jsbundling-rails is likely to be a common path and since the default esbuild install you get with jsbundling-rails doesn't come with a config at all, it feels valuable to help folks start to put some of the pieces together.
Having a little bit of insight into how things work outside of Webpacker-land is valuable, and hopefully it will open the doors for folks with an interest to start to learn more about the broader set of options they have in this space, including Vite Rails.
Cool, and thanks! Really like the article, my comment was about the trade-offs jsbundling makes.
Anything that helps people to understand how things work is great 😃
One more similar solution: github.com/railsjazz/rails_live_re...