Sprockets and Import Maps, Side by Side: A Practical Coexistence for Legacy Rails Apps
If you inherited or maintain a Rails app that predates Rails 7's JavaScript overhaul, you're probably sitting on a pile of legacy JavaScript in app/assets/javascripts/. Custom code. jQuery plugins. Maybe some CoffeeScript. Maybe even Angular. It works. It ships. Nobody wants to rewrite it, and honestly, nobody should have to. It's doing its job.
But you probably also want to add something modern. Stimulus, Turbo, Sentry via CDN, a new library that only ships as ES modules. The official Rails 7 answer for that is import maps. Which sounds great, until you realize the tutorials all assume you're starting fresh, and the upgrade guides mostly assume you're prepared to rewrite everything downstream.
Here's the practical answer for legacy Rails apps: you can run both pipelines side by side. Sprockets serves the legacy bundle. Import maps serve the modern code. If you get the load order right, they don't fight each other, and you get to modernize the parts you want to modernize without touching the parts you don't.
This isn't a hypothetical. It's how Rock Agile actually moves long-lived Rails apps forward. And it started with one specific client and one specific problem.
The specific problem this pattern solved
We inherited a Rails ERP that had been in continuous production since the Rails 2 era. It had been through every major Rails upgrade since: 2 to 3, 3 to 4, 4 to 5, 5 to 6, and eventually 6 to 7. Every one of those upgrades did the right thing for the Ruby side of the codebase. The JavaScript side accumulated. Nothing ever got seriously rewritten, because it worked, because the client didn't want to pay for a JS overhaul that solved no business problem, and because the JavaScript test coverage was thin enough that a rewrite would have been a real risk.
By the time we hit the Rails 7 window, the JavaScript pile in app/assets/javascripts/ was:
- 51 hand-written
.jsfiles in the app tree. - 13 CoffeeScript files still compiling through Sprockets.
- About 6,900 lines of application JavaScript total, spread across those files.
- Another 2,240 lines of vendored JavaScript in
vendor/assets/javascripts/, all of it depending on the Sprockets pipeline to concatenate and serve it.
The vendored libraries alone were a museum of the last decade of front-end Rails: jQuery, jQuery UI, jQuery UJS, jQuery Datepicker, jQuery Timepicker, jQuery TreeTable, DataTables, an old Angular router, Bootstrap 5, Lodash, and Toastr. Some of those are still maintained. Some are barely maintained. A few haven't been touched in years. All of them are load-bearing for at least one screen in the app.
When Rails 6 shipped Webpacker as the default JavaScript pipeline, the community consensus was: rewrite your legacy JS to modules, migrate through Webpacker, and by the way you'll need to add real JavaScript tests along the way. For a fresh app, that's the right advice. For a Rails 7 upgrade of a nine-year-old ERP where JavaScript tests are thin and the client's willingness to fund a JS rewrite is zero, that advice would have added six months of work and material regression risk to what should have been a clean version upgrade.
So we didn't move to Webpacker. We waited. When Rails 7 shipped import maps as an alternative, and later when propshaft became a viable Sprockets replacement for asset serving, a different path opened up: keep the legacy JavaScript exactly where it was, add import maps for anything new, and migrate the app's JS piece by piece as we touched each part for other reasons. No rewrite. No big-bang migration. No JavaScript-testing effort we couldn't sell.
That's the pattern this post is about. It worked so well that we've since used it on a second legacy client with a similar profile, and it's become part of how Rock Agile approaches legacy Rails work generally.
The core pattern
In your layout:
<%= javascript_importmap_tags %>
<%= javascript_include_tag 'sprockets', defer: true %>
That's most of the trick.
The import map tags emit first. They include the modern JavaScript that lives in app/javascript/, imported through config/importmap.rb. Because these are <script type="module"> tags, they run synchronously in module context. Anything they set on window is set immediately.
The Sprockets bundle comes second, with defer: true. That means the browser downloads it in parallel but waits to execute until the DOM is parsed. By the time it runs, the module-context imports have already finished setting up whatever globals the legacy code depends on.
Why the load order matters
Sprockets was built in an era when JavaScript expected globals: $, jQuery, _, toastr, whatever your app was using. Legacy code assumes those globals exist. If you tried to load the Sprockets bundle first, none of your modern module code has run yet, and the legacy bundle blows up trying to reference a jQuery that isn't there.
Loading the import map tags first lets you bridge from ES module land back to the global namespace before the Sprockets bundle runs. Your app/javascript/application.js looks something like this:
import jQueryModule from 'jquery'
window.jQuery = window.$ = jQueryModule
import _ from 'lodash'
window._ = _
import toastr from 'toastr'
window.toastr = toastr
By the time the deferred Sprockets bundle runs, window.jQuery is set. Any legacy plugin that starts with (function($){ ... })(jQuery) finds its argument and works.
The static import gotcha (Safari specifically)
There's one subtlety worth calling out, because it will silently bite you if you get it wrong.
Use static imports for anything the legacy code depends on. Not dynamic imports.
// This works
import jQueryModule from 'jquery'
window.jQuery = window.$ = jQueryModule
// This will silently fail in some browsers
import('jquery').then(mod => { window.jQuery = window.$ = mod.default })
Static imports resolve synchronously as part of the module's top-level execution. Dynamic imports return a promise, which schedules a microtask. The defer attribute on the Sprockets bundle doesn't wait for microtasks. It waits for DOM parsing to complete.
If the microtask hasn't flushed by the time defer runs, your legacy code executes before window.jQuery gets set, and the whole Sprockets bundle fails on the first $ reference.
Chromium browsers happen to flush microtasks before running deferred scripts. Safari is more aggressive about running deferred scripts as soon as the DOM is parsed, and it does not consistently wait for pending microtasks. Firefox falls somewhere in between depending on version. So the bug shows up as "everything works locally in Chrome, everything is broken in Safari, and the console error is a bare $ is not defined that gives you no clue what went wrong."
I learned this on the ERP client. The fix was one keyword change (import 'jquery' instead of import('jquery')), but figuring out that this was the issue took most of a day, because "works locally, breaks in Safari, error message useless" is a specific kind of pain. We now keep a comment in the actual application.js explaining why the imports are static, so future maintainers don't optimize the "clean" dynamic-import version back in without understanding what breaks.
Bridging globals for jQuery plugins specifically
The jQuery plugin world is where the coexistence pattern earns most of its keep. A typical jQuery plugin is a self-executing function wrapped around jQuery:
(function($) {
$.fn.myPlugin = function(options) {
// ...
}
})(jQuery)
If jQuery isn't on window when this runs, the plugin throws immediately. Since Sprockets concatenates every included JavaScript file into one bundle, a single missing global at the top of the bundle can cascade into every subsequent plugin failing.
The reliable pattern is to set every global the legacy code depends on in application.js, in the order the legacy code expects them:
// Order matters, set jQuery first so plugins can find it
import jQueryModule from 'jquery'
window.jQuery = window.$ = jQueryModule
// Then extensions that patch jQuery
import 'jquery-ui'
import 'jquery-ujs'
// Then utility globals other legacy code depends on
import _ from 'lodash'
window._ = _
import toastr from 'toastr'
window.toastr = toastr
// Then anything that expects those globals to already be set
import 'application-legacy-bootstrap'
jquery-ui and jquery-ujs are imported for their side effects, they patch window.jQuery when they run. As long as jQuery is on the window by the time they execute, they attach themselves correctly.
For plugins that ship as UMD modules but don't publish clean ES module builds, the pattern is the same: import for side effects, let them find their globals on the window.
Where Sprockets keeps living
Once the pattern is in place, Sprockets stops being an obstacle and starts being a boring pipeline that quietly serves the legacy stuff. It's still the right home for several things.
Rails gem-provided assets. Gems like administrate, recurring_select, and older Rails engines still ship their JS through Sprockets. There's no reason to fight this. Let Sprockets serve them.
Custom app JS that isn't causing pain. If you have thirty files of application-specific JavaScript that's been stable for five years, don't rewrite it because a blog post says import maps are the future. It's already working.
CoffeeScript files. If your app has legacy CoffeeScript, Sprockets is where it lives. Import maps don't compile. This alone will keep Sprockets in the mix for many older apps for years.
Vendored JS you don't control. Third-party libraries that assume the global namespace live comfortably in Sprockets. This includes the DataTables family, most jQuery UI extensions, and anything that predates the ES module era.
Legacy CSS pipelines. Sprockets is also serving your stylesheets tree. Import maps don't handle CSS. Until you move the CSS pipeline to something like propshaft or dartsass-rails, Sprockets keeps that responsibility too.
What import maps take over
Once you've got the coexistence pattern working, import maps become the home for the modern side of your app.
Anything ES module-native. Modern libraries that ship as ES modules, no build step needed.
Stimulus and Turbo. The Hotwired stack was designed for this pattern. Adding a single Stimulus controller to a legacy page is a one-file change.
CDN-hosted dependencies you don't want to bundle. Sentry, third-party analytics, anything you'd rather pull from a jspm or jsdelivr URL and let the browser cache aggressively.
New JS you're writing today. New code goes in app/javascript/. Old code stays where it is. Over time, the balance shifts.
Coexistence as a long migration path
The interesting thing about this pattern is what happens to it over time. In practice, "side by side forever" isn't usually the destination. What usually happens is that custom JavaScript slowly migrates from Sprockets to import maps, one file at a time, as engineers touch each part for other reasons. New features go to import maps by default. Old features migrate when they're already being changed for other work. After a year or two, the Sprockets bundle has stopped growing, and eventually it stops shrinking too. It stabilizes around what it can't easily leave, which is usually just the gem-provided assets and third-party libraries that don't ship as ES modules.
That's a completely reasonable end state. The goal was never to eliminate Sprockets. It was to stop it from being an obstacle to modernization. Once it's just quietly serving the assets that belong there, it's done its job. If a future gem release ships as ES modules, you migrate that too. Otherwise you leave it alone. The pattern is coexistence as a bridge, not coexistence as a permanent architecture.
The other benefit of the slow migration is that it lets you add real JavaScript tests as you go. Every time you touch a file to migrate it from the Sprockets pipeline to import maps, that's a natural moment to add the test coverage that the original file never had. Over a year of doing this, we've added meaningful JS test coverage to code that would never have been tested if it required a dedicated testing effort. The migration budget is small enough that adding tests along the way doesn't blow it up.
When this pattern is the right answer
If any of these are true, side-by-side coexistence is probably the right move.
You inherited an app with a legacy JS pile and you're trying to add new features without rewriting the old ones.
Your team's engineering time is worth more than the aesthetic wins of a full modernization.
Your legacy JS is working. It ships. It's not the source of your pain.
You're on Rails 7 and you want import maps, Stimulus, or Turbo without introducing a bundler.
You have limited or no automated tests on the JavaScript side and can't safely do a bulk migration.
Your client won't fund a JavaScript rewrite that solves no visible business problem.
When it's the wrong answer
Side-by-side has one real cost: you're maintaining two mental models of how JavaScript gets to the browser. That's fine as an interim state. It's less fine as a permanent architecture. If any of these are true, consider going further.
Your legacy JS is the source of your pain, and rewriting it would save more time than it costs.
You're already planning a bigger overhaul that would touch the JS anyway.
Your Sprockets bundle has grown so large that page-load performance is suffering, and shipping half of it as ES modules would actually help.
You have strong JavaScript test coverage that would let you refactor safely at scale.
For most inherited-app situations, though, side by side is where the sanity is. Modernize where you're getting value. Leave the boring parts boring.
The paradigm shift for consulting on legacy Rails
The reason this pattern matters isn't a technical argument, it's a business one. Full JavaScript modernizations on inherited Rails apps are expensive. Not just in engineering hours, but in risk. Every legacy file you rewrite is a chance to introduce a bug that had been dormant for years. Every dependency you replace is a decision your future self will have to defend.
The side-by-side pattern lets you spend that budget where it's actually earning something. You can add the modern piece without paying the full modernization tax. You can defer the rewrite of the legacy bundle to a moment when there's a real reason for it. That's the shape of a healthy legacy app: not "everything is modern," but "we're modernizing the parts that need it, and leaving the rest alone."
For us specifically, this pattern has been a paradigm shift in how we approach legacy Rails engagements. Before we understood the coexistence pattern, "modernize the JavaScript" was a discrete project that had to be pitched, scoped, and funded, often with a business case we couldn't fully make. Now, "move the app to Rails 8 and let JavaScript modernize incrementally" is a background hum that happens naturally as we work on features. We don't ask the client for a JavaScript modernization budget. We just do it a little at a time, and the app gets healthier as a side effect of everything else.
For clients with a decade-old Rails app they intend to keep for another decade, that's the outcome that actually matters.
If you're maintaining a legacy Rails app and want to talk through what's worth modernizing and what to leave alone, that's the kind of work Rock Agile does. Get in touch.