After another confusing migration of a Rails app’s asset stack into I finally found a stack that I’m happy with for Rails 7.x.

Before…

I had some Rails 6 and Rails 5 apps. One of them was using webpacker, sprockets 3, bootstrap-sass 3, react, typescript, jquery-ui and jquery-ujs. Two others were using sprockets 3 and bootstrap-sass 3.

The Concept

  • Use JS-based tools like node-sass and esbuild to compile CSS and JS files and output the compiled files to app/assets/builds
  • @import any CSS files required by your JS libraries (like date pickers or charts) in your CSS files instead of JS files
  • Make a list of those built files in app/config/manifest.js so sprockets-4 will let you reference them with javascript_include_tag etc…

  • Remove webpack

The Solution

1. cssbundling-rails

This gives you a nice bin/build-css command. It’s a bash script that compiles all your CSS/SCSS using node-sass, which you will install in your package.json.

You can configure multiple CSS entrypoints in here if you need. Mine looks something like this:

./node_modules/sass/sass.js \
  ./app/assets/stylesheets/application.sass.scss:./app/assets/builds/application.css \
  ./app/assets/stylesheets/admin/application.sass.scss:./app/assets/builds/admin/application.css --no-source-map \
  --load-path=node_modules \
  --load-path=vendor/assets/stylesheets \
  $@

The outputs will all be sent to app/assets/builds.

Some of the JS libraries we use come with CSS dependencies. Previously I was import-ing them in JS files (which always felt weird in a Rails app). Now I can import them into my SCSS files like this:

@import "../../../../node_modules/react-date-range/dist/styles";
@import "../../../../node_modules/@melloware/coloris/dist/coloris";
@import "../../../../vendor/assets/stylesheets/medium-editor";

Note: be sure to remove the .css extension when importing CSS from node_modules. That’s the only way the will import properly.

2. jsbundling-rails with esbuild

This will auto-create an esbuild.config.js where you can set your JS entrypoints. Mine looks something like this:

require("esbuild")
  .build({
    entryPoints: [
      "app/javascript/dashboard.js",
      "app/javascript/frontend.js",
      "app/javascript/onboarding.js",
    ],
    bundle: true,
    sourcemap: true,
    watch: process.argv.includes("--watch"),
    outdir: "app/assets/builds",
  })
  .catch(() => process.exit(1));

Like with CSS your outputs will all be sent to app/assets/builds.

3. sprockets 4

I’d avoided sprocket upgrades for years. Anytime I tried it just got messy and their warnings in the upgrade guide definitely didn’t inspire confidence.

In the end sprockets-4 was simple to get running. I created the sprockets app/config/manifest.js file like this:

//= link_tree ../builds
//= link_tree ../images
//= link_tree ../fonts

The first line references all those files we built in the previous steps. The next two let me use my image_url and font_url helpers as usual.

The sprockets manifest replaces any Rails.application.config.assets.precompile you had before. Get rid of that entirely.

4. Global jQuery

Old Bootstrap, jQuery UI and some old plugins require a global jQuery object. At first I put this at the top of each of my entrypoints:

import jquery from "jquery";
window.$ = window.jQuery = jquery;

…but that was not enough. I needed to move that to a separate file and then import that file. So like this:

// in app/javascript/jquery.js
import jquery from "jquery";
window.$ = window.jQuery = jquery;
// in your other JS files
import "./jquery";
import {} from "jquery-ui";
import {} from "jquery-ujs";
import {} from "bootstrap-sass";

5. Bonus Tips

Ensure your app/assets/builds exists by adding a .keep file or similar. Sprockets won’t work in production without it.

esbuild doesn’t minify by default. Here’s how you can conditionally minify your JS in production.

When importing your local JS files, make sure you import them without extensions so Sprockets can do its thing. So instead of

import "my/forms.js";

remove the extension…

import "my/forms";