Rails 7: JSBundling with ESBuild, Stimulus, Turbo, Bootstrap, CircleCI Up & Running

Rails new has a new paradigm for Javascript and it is called Rails 7 JSBundling. This is in contrast to ImportMap, which is the other officially Rails 7 JS paradigm for your app. (Look here for a comparison of the options.) In this post we’ll use the JS Bundling paradigm, building with the package tool called ESBuild, to get setup quickly with Stimulus, Turbo, Bootstrap, and CircleCI.

You may be familiar with Webpacker under Rails 7, JSBundling is probably what you will transition to. JS Buidling gives you three choices for build tool: Webpack, ESBuild, or Rollup. In this post, we’ll prefer the ESBuild build tool when using JS Bundling, also known as jsbundling-rails Gem.

Remember, if you want parity with what you were familiar with in Webpacker under Rails 6, using jsbundling-rails with the new faster esbuild is the way to go for you.

This article will help you if you’ve already decided you don’t want ImportMap. To help make that decision, check out Rails 7 : Do I need ImportMap-rails

Bundling itself is not compatible with eager loading your Javascript files, so for Stimulus JS, your application loader will look different that it will for a non-bundling application running Stimulus JS.

Furthermore, bundling also requires that you run a “watcher” in the background so that as you develop SCSS or Typescript your changes are immediately recompiled by the watcher and visible in your development environment.

IMPORTANT: Do not attempt to add jsbundling-rails on top of an app created with rails new (importmap). Do not attempt to create a new Rails app with cssbundling-rails and then add jsbundling-rails after. Do not attempt to switch between jsbundling-rails and Importmap on new Rails installations.

Section 1: Meet the CSS and JS Flags

If you use either the --css or the --javascript flag, you will get both cssbundling and jsbundling. (You can use --js as a shortcut for --javascript). Remember the three options for the --javascript flag are webpack, esbuild, rollup and the recommended one if you don’t know what to choose is esbuild.

rails new HelloWorld --javascript=esbuild --database=postgresql

Your JS choices are: ESBuild, Rollup, or Webpack.

Be sure to use the new ./bin/dev instead of the old rails server or else your JS & CSS will not compile.

On the project folder you now start your Rails server and a background “watcher” with:


Confirm ESBuild Loads the Rails 7 Features

1. Confirm that Stimulus works

Uncomment this line in routes.rb

Rails.application.routes.draw do
  root "articles#index"

Generate an articles controller

rails generate controller Articles

Now create a file at app/views/articles/index.erb

<div data-controller="hello"></div>

Rails has come prepackaged with a Stimulus controller.

Open up app/javascript/controllers/hello_controller.js to examine its contents.

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
connect() {
this.element.textContent = "Hello World!"

Boot rails using



Because we created an element with data-controller="hello", Stimulus hooked our Stimulus controller into it appropriately.

Confirm that you see the “Hello world!” on the screen. For more about Stimulus, see my Stimulus Blog Post.

2. Confirm that Turbo is Working

Go back to routes.rb and add this line. This is non-standard, do not do this in a real Rails app!

Rails.application.routes.draw do

  post "/", to: "articles#index"
  root "articles#index"

Now go back to app/views/articles/index.erb

You can leave the Stimulus test in place, and add this form:

<div data-controller="hello"></div>

<%= form_with(url: "/") do |form| %>
  this is a form for you to submit
  <%= form.text_field :xyz %>
  <%= form.submit "submit" %>
<% end %>

Now, load your browser window and you will see the new form.

If Turbo Rails is NOT installed correctly, you will see this:

To a point: WRONG: This is what happens when you first boot without the “watcher” running — Stimulus & Turbo are not enabled

Rails 7 boots but Turbo does not initialize

Notice that this form submission — which does absolutely nothing— should be sent to the Rails backend as a TURBO_STREAM, not as an HTML request.

CORRECT: This is what happens when you boot using the new ./bin/dev rails command, which includes running the ESBuild watcher in the background to compile your JS.


You must see the request submitted as TURBO_STREAM or else Turbo will not be working correctly.

Evidently, please note that even if Turbo is working, this app doesn’t actually do anything. The only purpose of this exercise is to examine your Rails logs for the TURBO_STREAM requests from the frontend.

Section 2: Bundling app with With Bootstrap, ESBuild, and CircleCI

1. Hello sassc-rails Gem

Start by uncommenting the scssc-rails gem which is commented out by default in the Gemfile:

gem "sassc-rails"

2. Turn on Inline Source maps (so we can debug Rails 7 Bootstrap)

in config/environments/development.rb add this line:

 config.sass.inline_source_maps = true

3. Add a (temporary) Dummy Controller & Route

Create the articles controller with:

rails generate controller Articles

Add an empty def index to it:

class ArticlesController < ApplicationController
  def index

in config/routes.rb, uncomment this line:

root "articles#index"

Finally, create a Hello World file at app/views/articles/index.erb

Hello World!

Now, load your website in your browser and click “View Source”

Notice here that Sprockets has applied a thumbprint to this specific build. When we change anything in our CSS, Sprockets will recompile the application file and give it a new thumbprint.

7. Add Bootstrap

7.1 â€” Add to Procfile.dev:

Procfile.dev is located at the root of your repository

css: yarn build:css --watch

Do not add these to Procfile, which is used by your deployment to start the system. For deployed (non-development) environments, you have no extra “watchers” watching for JS and CSS changes in the background. That’s because both the JS and CSS are built during deployment.

7.2 Add these yarn packages:

yarn add @popperjs/core bootstrap bootstrap-icons sass

In package.json, add to the “scripts” section the bold line:

"scripts": {
  "build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=assets",
  "build:css": "sass ./app/assets/stylesheets/application.scss:./app/assets/builds/application.css --no-source-map --load-path=node_modules"

7.3 In app/assets/config/manifest.js, remove this line:

//= link_directory ../stylesheets .css

7.4 Delete the file at app/assets/stylesheets/application.css and replace it with a file app/assets/stylesheets/application.scss that contains only:

@import 'bootstrap/scss/bootstrap';
@import 'bootstrap-icons/font/bootstrap-icons'; 

7.5 Then to app/javascript/application.js (your ESBuild pack file), add:

import * as bootstrap from "bootstrap"

7.6 To config/initializers/assets.rb, add this line:

Rails.application.config.assets.paths << Rails.root.join("node_modules/bootstrap-icons/font")

7.8 — Test Bootstrap

If we look in our browser, our existing layout looks like so:

Forthwith, the shrewd eye might notice that Bootstrap is applied (you can tell because the font is not the browser’s default), but we would hardly notice since all our app does is say “Hello World”‘

Hence, let’s add a Bootstrap navbar for Bootstrap 5

    <nav class="navbar navbar-expand-lg navbar-light bg-light">
  <div class="container-fluid">
    <a class="navbar-brand" href="#">Navbar</a>
    <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
      <span class="navbar-toggler-icon"></span>
    <div class="collapse navbar-collapse" id="navbarSupportedContent">
      <ul class="navbar-nav me-auto mb-2 mb-lg-0">
        <li class="nav-item">
          <a class="nav-link active" aria-current="page" href="#">Home</a>
        <li class="nav-item">
          <a class="nav-link" href="#">Link</a>
        <li class="nav-item dropdown">
          <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
          <ul class="dropdown-menu" aria-labelledby="navbarDropdown">
            <li><a class="dropdown-item" href="#">Action</a></li>
            <li><a class="dropdown-item" href="#">Another action</a></li>
            <li><hr class="dropdown-divider"></li>
            <li><a class="dropdown-item" href="#">Something else here</a></li>
        <li class="nav-item">
          <a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Disabled</a>
      <form class="d-flex">
        <input class="form-control me-2" type="search" placeholder="Search" aria-label="Search">
        <button class="btn btn-outline-success" type="submit">Search</button>

Ok now let’s take a look:

Boot your server with ./bin/dev

The second way to confirm that Javascript is working is to collapse your browser window. This special navbar display collapses into a Hamburger menu, and its slide-down effect requires that the popper-js and jQuery (javascript libraries) be correctly loaded through JS-bundling (ESbuild, in this case).

Notice that when you hit the Hamburger menu, the other options slide down.

Even though jQuery is available to Bootstrap here via importing it through the dependency management the JSBundling provides, jQuery is not globally available as was customary with old-style frontend development.

This is because we are now using the ESModules paradigm of modern Javascript, where we import the jQuery only where we need it.

Hence, if you open your console browser and type jQuery, you will see:

This is the expected result as jQuery is not globally available on your page.

8/ Adding Support for Typescript in JSBundling

• Native support for Typescript compilation errors is not included with JSBundling. To add it, use a tool called tsc-watch. First, let’s make sure have tsc (Typescript) installed.

which tsc

If you get “tsc not found,” install it with yarn add global typescript

Add TSC watch using

yarn add --dev tsc-watch

You will want to create a tsconfig.json file like so:

tsc --init

This creates a large config file that has many options commented out. Stripping out the commented options, the default tsconfig.json file looks like this:

"compilerOptions": {
"target": "es5",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true

I recommend for new projects that you add one additional option: noImplicitAny to compilerOptions

"noImplicitAny": true,

Now add these two scripts to your package.json file:

"failure:js": "rm ./app/assets/builds/application.js && rm ./app/assets/builds/application.js.map", 
"compile:typescript": "tsc-watch --noClear -p tsconfig.json --onSuccess \"yarn build\" --onFailure \"yarn failure:js\""

Remember, the scripts section already has scripts for build and build:css, so be sure to add a comma after the last line and add these two new lines (added text is shown here in orange.)

"scripts": {
  "build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=assets",
  "build:css": "sass ./app/assets/stylesheets/application.scss:./app/assets/builds/application.css --no-source-map --load-path=node_modules",
  "failure:js": "rm ./app/assets/builds/application.js && rm ./app/assets/builds/application.js.map", 
  "compile:typescript": "tsc-watch --noClear -p tsconfig.json --onSuccess \"yarn build\" --onFailure \"yarn failure:js\""

Here, we are calling tsc-watch with these options:

  • noClear, which prevents tsc-watch from clearing the console window.
  • -p tsconfig.json, points to the TypeScript config file that will set the Typescript compilation settings.
  • --onSuccess \"yarn build:js\", controls what will happen if the TypeScript compilation succeeds. Here, we then call build:js, since we now know the code is type safe.
  • --onFailure \"yarn failure:js\" is what will happen if TypeScript compilation fails. In this setup, we remove the application.js and application.js.map files using "rm ./app/assets/builds/application.js && rm ./app/assets/builds/application.js.map". This removes the existing esbuild files from the build directory so that the development browser page will show an immediate error. If we didn’t do this, we might fail to notice the compilation errors and our dev environment would then load the most recent successful compilation. (That leads for a confusing dev experience leaving you scratching your head as to why your changes don’t show up on your website.)

Now go back to your Procfile.dev. Notice that the js line now says yarn build --watch

web: bin/rails server -p 3000
js: yarn build --watch
css: yarn build:css --watch

Replace the line js: yarn build --watch with js: yarn compile:typescript --watch, so your new Procfile.dev looks like:

web: bin/rails server -p 3000
js: yarn compile:typescript --watch
css: yarn build:css --watch

Keep in mind that this means we are first going to do Typescript type checking (compilation), and then we will do yarn build if and only if Typescript compiling succeeds. We do this using the --on-success flag inside of the compile:typescript line in Procfile.dev.

9/ CircleCI

On your .circleci/config file, there is a section for build > test > steps. Confirm that both the Yarn install and Yarn build are here. Your build should come after yarn install, like so:

- node/install-packages:
    pkg-manager: yarn
    cache-key: "yarn.lock"
- run:
    name: yarn build
    command: yarn build
- run:
    name: yarn build:css
    command: yarn build:css

Here is a sample Circle CI config for using the following Circle config.

  ruby: circleci/ruby@1.0
  node: circleci/node@2
  browser-tools: circleci/browser-tools@1.2.3