Bootstrap 4 to 5, One Controller at a Time
You’ve decided your Ruby on Rails app needs to move from Bootstrap 4 to Bootstrap 5. Bootstrap 4 is end of life and no longer receives bug fixes or security updates. Maybe you also want to get jQuery out of your front end, or maybe a component you need only exists in version 5. The catch is that Bootstrap 5 renames classes and data attributes, and that touches many of the views in the app. In a large application, changing all of them in a single pull request is hard to review, hard to test, and hard to roll back.
We recently borrowed the idea behind the Dual-Boot technique , running two versions of a dependency side by side, for something other than Rails: Bootstrap itself. Instead of upgrading everything at once, we kept both versions in the app and moved one controller at a time, for many of the same reasons that you might upgrade Rails in increments . In this article, you will learn how to run Bootstrap 4 and 5 side by side in one Rails app, how to pick the version per controller with a layout, and how to sequence the migration into small pull requests.
Two Bootstrap versions in one app
The first obstacle is that both versions are published under the same package name, so you can’t have bootstrap at 4.x and 5.x in the same package.json. npm aliases solve that. We kept the original packages as they were and installed the Bootstrap 5 versions under new names:
{
"dependencies": {
"bootstrap": "^4.5.0",
"bootstrap-select": "^1.13.18",
"bootstrap5": "npm:bootstrap@^5.3.0",
"bootstrap-select5": "npm:bootstrap-select@^1.14.0-beta3"
}
}
Anything that wraps Bootstrap needs the same treatment, which is why bootstrap-select has an alias too. The 1.14 release of bootstrap-select that supports Bootstrap 5 was still a beta when we did this, so check whether a stable release is available before you copy these versions. After npm install, both versions live in node_modules, one under bootstrap and one under bootstrap5.
The next step is to give each version its own entry point. For CSS, we have one Sass file per version, and the Bootstrap 5 one imports everything through the alias:
// application-bootstrap5.scss
@import 'app-bootstrap5/variables';
@import 'bootstrap5/scss/bootstrap';
@import 'app-bootstrap5/main';
@import 'bootstrap-select5/dist/css/bootstrap-select';
The order matters here: our variable overrides come before Bootstrap, so they can replace its defaults. The Bootstrap 4 entry file looks the same but imports the original packages, and both compile to separate files in app/assets/builds.
One caveat is that @import has been deprecated in Dart Sass since version 1.80.0, and it is planned for removal in 3.0.0, so you will see deprecation warnings. We silence them for now with the --silence-deprecation=import flag in our sass command. Moving to @use is a separate piece of work that can wait until the Bootstrap migration is done.
JavaScript follows the same pattern, and it assumes the app already bundles with esbuild (we covered that migration in Migrating From Sprockets to esbuild in Rails ). We pass both entry files to esbuild in the same build script, so each version ends up with its own bundle in app/assets/builds:
{
"scripts": {
"build": "esbuild app/javascript/application.js app/javascript/application-bs5.js --bundle --sourcemap --format=iife --outdir=app/assets/builds --minify"
}
}
The old entry point, application.js, is untouched. The new one, application-bs5.js, imports Bootstrap 5 through the alias and exposes it on window:
// application-bs5.js
import * as Popper from '@popperjs/core'
import * as bootstrap from 'bootstrap5'
window.Popper = Popper
window.bootstrap = bootstrap
Setting window.bootstrap here is not only a convenience. As we’ll see in the section on shared code, it is also how other scripts can tell which bundle they are running in. Our real entry file goes on to load the rest of the app’s scripts, but the Bootstrap part is just those few lines. At this point nothing in the app has changed, because no page loads the new bundles yet.
Choosing the version with a layout
Rails already has a natural place to decide which assets a page loads: the layout. We kept the existing application layout exactly as it was, still loading the Bootstrap 4 stylesheet and the original JavaScript bundle, and added a second layout next to it. Here is a trimmed version of the new one:
<!DOCTYPE html>
<html lang="en">
<head>
<%= stylesheet_link_tag "application-bootstrap5", media: "all" %>
<%= javascript_include_tag "application-bs5" %>
<%= csrf_meta_tags %>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body class="app-bootstrap5">
<%= yield %>
</body>
</html>
A controller opts in with a single line:
class ReportsController < ApplicationController
layout "application_bs5"
end
That’s the whole switch. Unless an action overrides it, every action in that controller renders with Bootstrap 5, and every other controller keeps rendering with Bootstrap 4. A pull request that migrates one controller should mostly affect only the pages that controller serves, as long as the shared partials it touches keep working under both versions. We named the layouts with a _bs5 suffix on purpose, and as we’ll see in the next section, that naming convention ends up being useful in code.
If a group of controllers shares a base controller, leave the base controller on the old layout and set the new one on each child controller as you migrate it. The base controller switches last.
Shared partials and helpers
Layouts take care of the page shell, but partials and helpers are shared between controllers, and a partial used by one migrated controller and one unmigrated controller has to render correctly under both versions. This is the part of the approach that needs the most care.
The first thing we added was a helper that tells a view which version it is being rendered under. Since we named the layouts with a _bs5 suffix, it can just ask the controller:
module ApplicationHelper
def bs5_layout?
controller.class._layout.to_s.end_with?("_bs5")
end
end
The underscore in _layout is a hint that it is an internal Rails method, not a public API, so it could change between Rails versions. We are comfortable with that because the helper is transitional code that gets deleted at the end, but it’s worth a quick check if you upgrade Rails in the middle of the migration. The helper also expects layouts declared as plain strings, as in our controllers. _layout holds whatever was passed to layout, and when that is a symbol (the name of a method that picks the layout) or a proc, the layout is only decided at request time, so there is no layout name to check for the _bs5 suffix. For example, with this declaration _layout returns :pick_layout, the name of the method, and the helper would report Bootstrap 4 even for users who get the Bootstrap 5 layout:
class PostsController < ApplicationController
layout :pick_layout
private
def pick_layout
current_user.beta? ? "application_bs5" : "application"
end
end
Shared partials can now branch on bs5_layout? wherever the markup differs. For example, a table header that used thead-light in Bootstrap 4 needs table-light in Bootstrap 5, and a collapse toggle needs data-bs-toggle instead of data-toggle. We also gave our dropdown helpers a bs5: keyword argument that defaults to false, because Bootstrap 5 renamed custom-select to form-select. Existing callers keep working without changes, and migrated views pass bs5: true.
Our rule was that the first pull request that renders a shared partial converts it, and the partial has to work under both versions until the migration is done. Partials that only migrated controllers render can be converted outright, with no branching. We also kept a list of every branch we added, so we could remove them later.
Remote JavaScript responses (*.js.erb) were the one place where branching on the layout doesn’t work. They get rendered by whichever controller handled the request, but the markup they inject lands in a page that could be using either version, so their output has to stay neutral. Modals were the main case. Bootstrap 4 opens them through a jQuery plugin and Bootstrap 5 uses a plain JavaScript API, so we wrapped both behind two small functions:
export function showModal (selector, options = {}) {
const el = document.querySelector(selector)
if (window.bootstrap) {
window.bootstrap.Modal.getOrCreateInstance(el, options).show()
} else {
$(el).modal(options)
}
}
The Bootstrap 5 bundle sets window.bootstrap and the Bootstrap 4 bundle doesn’t, so the function can tell which API to call. hideModal works the same way, and both are also exposed on window so remote responses can call them.
Sequencing the controllers
With the switch in place, the remaining question is the order. We started with the smallest controllers that depended the least on the rest of the application, because those are the easiest to migrate and to test. They were also where we worked out the workflow and the conventions that the rest of the app would follow. From there we moved on to more complex controllers and left the hardest ones for last. By then we had already run into most of the problems they would raise, and had solved them, so even the hardest controllers were easier than they would have been at the start.
The first pull request is the foundation. It adds the bs5_layout? helper, converts the shared partials the first controllers need (a sidebar, a history table, a search modal in our case), and migrates only a few pages that use them. Nothing in it is exciting, but it gives every later pull request something to build on. After that, each pull request migrates one controller or a small group of related ones, sets their layout, and converts any views and partials that only they render. Small pull requests like these are the same approach we recommend for most Rails upgrades, as we explain in Two Commonly Used Rails Upgrade Strategies .
Our goal for each pull request was visual parity: the migrated pages should look the same as they did under Bootstrap 4, and a person compared them side by side before the change went further. We targeted all of these pull requests at a long-lived base branch for the migration instead of main.
Removing the old version
The last step is a cleanup pull request, and it is the reason we kept the list of transitional code. Once every controller that shares a base controller declares a _bs5 layout, the base controller switches to the new layout, the old layout file is deleted, and the individual layout declarations go away because the base class now covers them. Then we go through the list: the bs5_layout? helper and every call to it, the bs5: flags, and each branch in a shared partial, keeping the Bootstrap 5 variant every time. If you are curious about the general idea of cleaning up after a dual-boot, we wrote about it in Finishing an Upgrade .
Once nothing renders with the old layouts, the Bootstrap 4 entry points, its packages, and the npm aliases can be removed too. It’s worth planning for that part early, since renaming bootstrap5 back to bootstrap touches every import.
Conclusion
In this article, we talked about migrating a Rails app from Bootstrap 4 to Bootstrap 5 without a big-bang rewrite. We installed both versions side by side with npm aliases, gave each one its own CSS and JavaScript entry points, and let the layout decide which one a controller uses. We handled shared partials with a bs5_layout? helper, and then moved through the controllers in small pull requests, starting with the smallest.
It’s important to note that this approach trades one big risk for a lot of small debt. Every bs5_layout? branch and every bs5: flag is code you will have to remove later. Keep the list of transitional code from the first pull request, and plan the cleanup before you start the migration.
Has your team been struggling with an upgrade? We can help!