If you’ve worked on a Rails app that’s been around for a few years, you’ve probably seen this pattern: a application.js file full of //= require lines, a vendor/assets/javascripts folder nobody wants to touch, a couple of <script src="https://cdn..."> tags sitting in the layout “just in case,” and a mix of .js and .es6 files that nobody can confidently say belong together. That’s Sprockets — Rails’ original asset pipeline — showing its age.
This post walks through migrating a Rails app from Sprockets to esbuild, step by step, in plain language, with real examples. We’ll cover why you’d do this, how to set it up without breaking your app on day one, how to move your JavaScript over safely, and how to test and clean up at the end.
This is a gradual migration, not a rewrite
The approach in this post deliberately avoids a “big bang” cutover. Sprockets and esbuild run side by side for most of the migration, so your app keeps working in production at every single step. You only remove Sprockets’ JavaScript handling at the very end, once everything has been verified.
1. Summary
Sprockets is Rails’ original, built-in way of bundling CSS, JavaScript, and images — it works by stitching files together using special comments like //= require. It was a great solution for the mid-2010s web, but it predates the modern JavaScript ecosystem: npm packages, ES modules, tree-shaking, and fast incremental builds.
esbuild is a modern, extremely fast JavaScript bundler written in Go. Rails officially supports it through the jsbundling-rails gem, which lets esbuild (or Webpack, or Rollup) handle your JavaScript while Rails’ asset pipeline still serves the final output — and, if you want, still handles your CSS and images.
Migrating means:
- Your JavaScript moves from
app/assets/javascriptstoapp/javascript, written as real ES modules withimport/export. - Instead of
//= require jquery, you runnpm install jqueryand writeimport $ from "jquery". - esbuild bundles everything into a single file, which Sprockets then serves to the browser like any other asset — so you keep fingerprinting, caching, and
rails assets:precompileworking exactly as before. - You get access to the entire npm ecosystem, faster rebuilds, and a clearer mental model of where your JavaScript lives and how it’s organized.
The whole thing can be done incrementally, file by file, with both pipelines running in parallel until you’re confident enough to remove the old one.
2. Why migrate? The requirements driving this
Before touching any code, it’s worth being honest about why you’re doing this — it shapes every decision later. In practice, the push to migrate usually comes from a combination of these:
- The JavaScript has become unmaintainable. A mix of
.jsand.es6files, no clear convention for where new code goes, and dependencies like jQuery and Bootstrap loaded from a CDN instead of being tracked as real dependencies anywhere in the codebase. - You want to adopt modern tools. Frameworks like Stimulus (Rails’ own pick for sprinkles of JavaScript) are distributed as npm packages and designed around ES modules — Sprockets can be made to work with them, but it fights you the whole way.
- You’re tired of CDN dependencies. CDN
<script>tags mean your app depends on a third-party server staying up, you can’t pin exact versions easily in one place, and every CDN tag is a separate HTTP request and a separate thing that can silently break. - You want real dependency management. With npm, every JavaScript dependency has a version, lives in
package.json, and gets installed with a singlenpm install— the same discipline you already expect from yourGemfile. - You want faster builds. esbuild’s headline feature is raw speed: it can rebuild a typical app’s JavaScript in milliseconds, which makes local development noticeably snappier than Sprockets’ asset recompilation.
A useful gut check before you start
If your app’s JavaScript is small, static, and never changes, migrating might not be worth the effort. This migration pays off most for apps where JavaScript is actively growing, where developers keep asking “where does this file go?”, or where you’re about to adopt a modern framework like Stimulus or Turbo and want the dependency story to be clean from day one.
3. Setup and migration steps
Step 1 — Add jsbundling-rails and generate the esbuild setup
jsbundling-rails is the official Rails gem that wires a JavaScript bundler into the asset pipeline. Add it to your Gemfile:
# Gemfilegem "jsbundling-rails"bundle install./bin/rails javascript:install:esbuildThat generator does three useful things automatically:
- Creates
app/javascript/application.jsas your new JavaScript entry point. - Adds an
esbuilddependency and abuildscript topackage.json. - Adds a
javascriptprocess toProcfile.devso esbuild runs alongside your Rails server in development.
Step 2 — Understand the new package.json build script
Open package.json and you’ll see something like this:
{ "scripts": { "build": "esbuild app/javascript/*.* --bundle --sourcemap --format=iife --outdir=app/assets/builds" }, "devDependencies": { "esbuild": "^0.25.6" }}In plain language: this command tells esbuild to start at app/javascript/application.js, follow every import statement it finds, bundle all of that code into one file, and drop the result into app/assets/builds. That output folder is the bridge between the two worlds — it’s still inside app/assets, so Sprockets can pick it up and serve it exactly like it would any other asset.
Step 3 — Audit what your current JavaScript actually depends on
This is the step people skip and regret. Before moving a single file, go through your existing codebase and build an honest inventory:
- Search your layout files for
<script src="...">tags, especially ones pointing at a CDN (cdn.jsdelivr.net,cdnjs.cloudflare.com, and similar). - Open
application.jsand list every//= requireline — each one is a dependency you’ll need to account for. - Grep your app for global usage — things like
$(...),jQuery, or a plugin name used directly in view templates or inline<script>tags, since these imply a global variable your new bundle will need to provide. - Check your
Gemfilefor gems that exist purely to wrap a JavaScript library (jquery-rails,coffee-rails, and so on) — these will eventually be removed, but not yet. - Match existing versions first. Install the same version of each library you’re already using via npm, and treat “upgrade the library” as a separate task you do after the migration, not during it. Mixing “migrate the pipeline” with “upgrade the dependency” at the same time makes it much harder to tell which change caused a bug.
# A quick way to install the npm equivalents, pinned to what you already have:npm install jquery@3.7.1 bootstrap@4.4.1 popper.js@1.16.0 flatpickrStep 4 — Build the new entry point
app/javascript/application.js (created by the generator) is where everything starts. This is where your old //= require list turns into real import statements:
// Before — app/assets/javascripts/application.js (Sprockets)//= require jquery//= require popper//= require bootstrap//= require flatpickr//= require_tree .
// After — app/javascript/application.js (esbuild)import $ from "jquery";import Popper from "popper.js";import "bootstrap";import flatpickr from "flatpickr";
// Legacy inline scripts and .js.erb views still expect these as globals,// so we attach them to `window` deliberately, on top of the proper imports:window.$ = window.jQuery = $;window.Popper = Popper;window.flatpickr = flatpickr;Notice the window.$ = $ line — that’s not an accident or a bad habit carried over. It’s a deliberate bridge: your old inline <script> tags and .js.erb AJAX responses were written assuming $ exists globally, and you’re not rewriting all of those yet. You’re just changing how $ gets defined.
Step 5 — Tell Sprockets to serve esbuild’s output (the parallel pipeline)
This is the step that makes the whole migration safe. Open app/assets/config/manifest.js and add:
//= link_tree ../buildsThis tells Sprockets: “there’s a folder of already-built files at app/assets/builds — serve them like any other precompiled asset, fingerprinting and all.” You are not asking Sprockets to understand or bundle your JavaScript anymore; you’re just asking it to hand esbuild’s finished output to the browser.
Now update your layout to load the new bundle alongside the old one, so you can compare them side by side during development:
<%# app/views/layouts/application.html.erb %>
<%# Old pipeline — still here, still working %><%= javascript_include_tag "application", "data-turbo-track": "reload" %>
<%# New pipeline — loaded in parallel for comparison %><%= javascript_include_tag "application-esbuild", type: "module" %>Expect duplicate behavior during this phase
While both pipelines are active, things like event handlers or global variables might get set up twice. That’s expected and temporary — this phase is for verifying the new pipeline works, not for running it as the only source of truth yet.
Step 6 — Update your Dockerfile and Procfile.dev
esbuild needs Node.js and npm available wherever your app runs — your local machine, CI, and your Docker image.
# DockerfileRUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash -RUN apt-get install -y nodejs
COPY package.json package-lock.json ./RUN npm install
COPY . .RUN npm run buildProcfile.dev (generated for you, but worth understanding) runs your Rails server and esbuild’s watch mode together, so every time you save a JavaScript file, esbuild rebuilds it in the background within milliseconds:
web: bin/rails serverjs: npm run build -- --watchStep 7 — Migrate your JavaScript files, in small, logical batches
Don’t move everything at once. A sensible order, from safest to riskiest:
- Plain utility files first — small, self-contained helpers with no dependencies on globals. These can usually be moved with little more than a
mvand animportstatement. - Business logic next — forms, user-facing interactions, things that call into your Rails backend via AJAX.
- Complex, AJAX-heavy forms last — anything relying on jQuery plugins, global functions, or intricate DOM manipulation. These take the most care because they’re the most likely to depend on load order and global state.
- Modern, dependency-free files (like Stimulus controllers) can often just move as-is — if a file was already written in plain, modern JavaScript with no Sprockets-specific syntax, migrating it can be a simple
mv app/assets/javascripts/controllers/foo.es6 app/javascript/controllers/foo.jswith zero code changes.
# Example: moving Stimulus-style controllersmv app/assets/javascripts/controllers/*.es6 app/javascript/controllers/# then rename the extension and fix the import path in application.jsFor code that Rails calls from a .js.erb view (a common Rails pattern for AJAX responses), keep exposing functions globally even as you modularize them:
export function refreshUserTable() { // ...}
// Rails' .js.erb views call this as a bare global function,// so we keep a bridge until those views are updated too:window.refreshUserTable = refreshUserTable;<%# app/views/users/update.js.erb %>refreshUserTable();Step 8 — Handle jQuery, Bootstrap, and other “global-dependent” libraries carefully
This is where most of the real friction shows up, because a lot of older JavaScript libraries assume they’re loaded in a specific order, as globals, rather than as clean ES modules.
Load order matters more than you’d expect. Some libraries (Bootstrap is a classic example) expect another library (Popper) to already exist on window before they’re imported — not just “imported earlier in the file,” but actually attached to window first:
import Popper from "popper.js";window.Popper = Popper; // must happen BEFORE Bootstrap is imported
import "bootstrap"; // Bootstrap reads window.Popper internallyGive yourself an explicit “jQuery is ready” helper, rather than assuming import order guarantees jQuery is available everywhere it’s needed:
import $ from "jquery";window.$ = window.jQuery = $;
export function withJquery(callback) { callback($);}import { withJquery } from "./jquery-loader";
withJquery(($) => { // anything that needs jQuery to already be attached to window goes here import("./flatpickr-setup");});Rails UJS (data-remote, data-confirm, etc.) deserves special attention. It’s tempting to replace the old jquery-rails gem with the newer @rails/ujs npm package, but if any of your existing code reads data out of Rails’ AJAX events, the event payload shape is different between the two — code written for jQuery-based UJS events can break silently under @rails/ujs. If you hit this, it’s often simpler to install the jquery-ujs npm package instead, which keeps the same event shape your existing code already expects:
npm install jquery-ujsimport "jquery-ujs";Step 9 — Clean up Sprockets once everything is verified
Only after you’ve tested thoroughly (see the next section) do you remove the training wheels:
// Remove://= link_directory ../javascripts .js
// Keep://= link_tree ../builds# Remove — esbuild/terser-equivalent minification now happens in the build step itself:# config.assets.js_compressor = :terser# Gemfile
# Remove:# gem "jquery-rails"# gem "coffee-rails"# gem "terser"# gem "babel-transpiler"
# Keep:gem "jsbundling-rails"<%# app/views/layouts/application.html.erb %>
<%# Remove all CDN tags and the old Sprockets bundle. Only this remains: %><%= javascript_include_tag "application", "data-turbo-track": "reload", type: "module" %>4. Testing and conclusion
How to test it properly: the safest approach is exactly the parallel-pipeline setup from Step 5 — load both the old and new bundles side by side (on different layouts, or behind a query parameter) and compare behavior page by page, feature by feature, in more than one browser. Specifically check:
- Every page that uses AJAX (forms, live search, inline edits) still works end to end.
- Every third-party widget (date pickers, dropdowns, modals) renders and behaves the same.
- Browser console is free of new errors in both Chrome and Firefox.
rails assets:precompilestill succeeds, and the compiled, fingerprinted output is what actually gets served — not a leftover development build.
Two real gotchas worth watching for specifically, because they’re easy to misdiagnose:
Gotcha: UJS events can look identical but aren't
If an AJAX success/error handler that worked fine under Sprockets suddenly stops firing correctly
after the switch, check whether you’re reading data out of the Rails UJS event the old way. The
event detail’s shape differs between @rails/ujs and jquery-ujs — this is a frequent, confusing
source of “it worked yesterday” bugs during this exact migration.
Gotcha: esbuild is stricter than Sprockets
Code that quietly relied on an undeclared variable (no var, let, or const) may have worked
fine under Sprockets’ looser handling, and then fail under esbuild’s stricter parsing. The fix is
simple — declare the variable properly — but the symptom (a silent ReferenceError in the
console) can be confusing if you don’t know to look for it.
Conclusion: migrating from Sprockets to esbuild isn’t a weekend task for any app with real JavaScript in it, but it’s also not the risky, all-or-nothing rewrite it might sound like. Done as a gradual, file-by-file migration — new pipeline built first, old pipeline kept running, files moved in small logical batches, everything compared side by side before cleanup — it’s a change you can ship safely, a little at a time, without ever putting a half-migrated app in front of real users. The payoff is a JavaScript setup that looks like the rest of modern web development: real npm dependencies with real version numbers, fast rebuilds, no mystery CDN tags, and a clear answer to “where does new JavaScript go?” — app/javascript, as an ES module, every time.
Reference: this post is based on the real-world migration walkthrough in the official jsbundling-rails documentation.