Webpack ALL The Assets!!

With the release of Rails 6, Webpack was introduced as the default JavaScript bundler by using the Webpacker gem. We tend to think about Webpack only as a tool to handle JavaScript files, but it can be used to handle all kinds of asset files. This article shows how to create a Rails app that uses only Webpack to handle all the assets, including images, fonts, styles and videos.

Init the Rails App

rails new NoSprocketsRails --skip-sprockets

You can use -S instead of --skip-sprockets, both are aliases. Use rails new -h to see the available options.

Cleaning the New App

Interestingly, there's some Sprockets-related files and code that are still getting created so we need to remove them. The app/assets folder contains: config/manifest.js (a configuration file for Sprockets) and stylesheets/application.css contains the comments and the require statements from Sprockets.

We can delete the app/assets folder for now. We are going to use app/assets to store all our assets instead of app/javascript, but we'll do that in the next section.

Rails depends on the sprockets-rails gem, so some dependencies will appear in the Gemfile.lock file, but if we check inside config/application.rb we can see that the app is not requiring sprockets/railtie.

Another change we have to make is in the application.html.erb layout file, it's using stylesheet_link_tag but with Webpacker we want to use the stylesheet_pack_tag helper.

The stylesheet_link_tag is part of ActionView, so it still partially works even without sprockets-rails.

app/javascript to app/assets

Since we are going to handle all asset types and not only JavaScript files with Webpack, we are going to use a more descriptive name for the folder. Instead of app/javascript, we are renaming the folder to app/assets.

We also have to tell Webpacker that we changed that path. We do that by setting the new path for the source_path option in the config/webpacker.yml file.

Handling CSS

We have two options here:

Using packs/application.css

We can create a CSS (or SASS) file at app/assets/packs/application.scss (or .sass/css), Webpack will emit an application.css pack/bundle as the compiled version.

This option feels more similar to what we would do with Sprockets, but there seems to be an issue when combined with image handling.

Importing CSS Inside JS

Another option is to import the css file inside our application.js pack. In this case, Webpack will compile the imported CSS and will emit a .css file with the same name as the pack, so, if the pack is called admin.js, the imported CSS will be emitted as admin.css even if the imported file is named differently.

We will use this option for the rest of the article.

Files Structure

We are storing the CSS files at app/assets/stylesheets and we will import the needed files in our pack. The files will then look like this:

// app/assets/packs/application.js

import "../stylesheets/application.scss"; // this is added, the rest is the default

import Rails from "@rails/ujs";
// ...
// app/assets/stylesheets/application.scss

body {
  background-color: blue;
}

JavaScript Code

A common mistake when starting with Webpack is to put all the JS files inside app/assets/pack. The problem with this is that Webpack will emit one bundle for each of the files that it finds at that directory and it's something we don't want to do in general.

Files that are used by the pack that we don't want Webpack to emit, should be in a different folder. We are going to use a structure similar to Sprockets, so any extra custom JS file will be at app/assets/javascript.

// app/assets/packs/application.js

import "../stylesheets/application.scss";

import Rails from "@rails/ujs";
// ...
ActiveStorage.start();

import "../javascript/debugging"; // we import a file from outside `packs` with `..`
// app/assets/javascript/debugging.js

console.log("Hello world");

Now we should see Hello world in the browser's console using the DevTools.

Debugging Emitted Assets

In the previous section we talked about an issue when adding all the asset files inside /pack and how Webpack would compile and emit any file found there. You can check your logs to see what Webpack is emitting:

[Webpacker] Hash: e4fc711e2adb6a0437c7
Version: webpack 4.46.0
Time: 697ms
Built at: 02/07/2021 11:23:59
                                     Asset       Size       Chunks                         Chunk Names
    js/application-f9a3471a7f250c8b35d8.js    138 KiB  application  [emitted] [immutable]  application
js/application-f9a3471a7f250c8b35d8.js.map    152 KiB  application  [emitted] [dev]        application
                             manifest.json  364 bytes               [emitted]              

It is only emitting a JS file (and the source map) during development because it doesn't extract the CSS from JS files as a separated file by default. During development, the CSS is still part of the JS code and it's injected in the head of the HTML by Webpack.

We can change this behavior with the extract_css option for the development environment inside the config/webpacker.yml file. When set to true, we will see it emits the CSS file too (and the source map):

[Webpacker] Hash: 1fae6519a03d7182ac2b
Version: webpack 4.46.0
Time: 893ms
Built at: 02/07/2021 11:35:49
                                     Asset       Size       Chunks                         Chunk Names
              css/application-cf683911.css   80 bytes  application  [emitted] [immutable]  application
          css/application-cf683911.css.map  188 bytes  application  [emitted] [dev]        application
    js/application-fae78801cd0a245d8eac.js    126 KiB  application  [emitted] [immutable]  application
js/application-fae78801cd0a245d8eac.js.map    139 KiB  application  [emitted] [dev]        application
                             manifest.json  640 bytes               [emitted]

Also, if we compile the assets manually for production using RAILS_ENV=production rails assets:precompile, we can see it not only emits the CSS bundle but it adds compressed versions using Brotli and Gzip to optimize the size when the browser downloads the assets:

Hash: eee1e7c3239c83d7ca88
Version: webpack 4.46.0
Time: 2852ms
Built at: 02/07/2021 11:28:53
                                        Asset       Size  Chunks                         Chunk Names
                 css/application-00551f1a.css   20 bytes       0  [emitted] [immutable]  application
       js/application-91581966b20673bf924a.js   69.5 KiB       0  [emitted] [immutable]  application
    js/application-91581966b20673bf924a.js.br   15.4 KiB          [emitted]              
    js/application-91581966b20673bf924a.js.gz   17.8 KiB          [emitted]              
   js/application-91581966b20673bf924a.js.map    205 KiB       0  [emitted] [dev]        application
js/application-91581966b20673bf924a.js.map.br   43.9 KiB          [emitted]              
js/application-91581966b20673bf924a.js.map.gz     51 KiB          [emitted]              
                                manifest.json  494 bytes          [emitted]              
                             manifest.json.br  157 bytes          [emitted]              
                             manifest.json.gz  170 bytes          [emitted]              

This comes handy when trying to debug issues in production.

Webpacker, by default, is configured to behave differently in different environments so keep that in mind if you can't find your assets after a deploy.

Handling Images

To handle images with Webpack we need to also import them inside our application.js pack. We can import each image manually or we can tell Webpack to import all the images inside a directory.

// app/assets/packs/application.js

import "../stylesheets/application.scss";
// import '../images/cat.jpg' // we could do this for each image
require.context("../images", true); // or this to import all the images at once

import Rails from "@rails/ujs";
// ...

And we store our cat.jpg image at app/assets/images/cat.jpg.

Now we can see it's also emitting the image files:

Webpacker] Hash: 92afbc5eddcd2ffc50b0
Version: webpack 4.46.0
Time: 637ms
Built at: 02/07/2021 11:54:48
                                                Asset       Size       Chunks                         Chunk Names
                         css/application-cf683911.css   80 bytes  application  [emitted] [immutable]  application
                     css/application-cf683911.css.map  188 bytes  application  [emitted] [dev]        application
               js/application-8afdd2859d6b40d4b0c4.js    128 KiB  application  [emitted] [immutable]  application
           js/application-8afdd2859d6b40d4b0c4.js.map    140 KiB  application  [emitted] [dev]        application
                                        manifest.json  730 bytes               [emitted]              
media/images/cat-106e150392be77f57358d16ef3678a35.jpg    732 KiB               [emitted]

You can see all the file extensions that Webpack will process in the config/webpacker.yml file. You can add more image formats if you need, like webp for example.

Using the Image

Like Sprockets, Webpacker is configured to add a digest by default for the emitted files. To render an img tag with the Rails' helpers, we have two options:

image_tag + asset_pack_path

If we want to use the common image_tag helper, we can't reference the image name like we do with Sprockets, we need to use a Webpacker helper to get the correct path of the file:

image_tag asset_pack_path('media/images/cat.jpg')

media is the directory where non-CSS/JS assets are emitted, you can check that with the console output.

image_pack_tag

Since the previous option requires a lot of extra code, we can use a new helper provided by Webpacker:

image_pack_tag 'cat.jpg'

This way, the usage is more similar to what we are used to, Webpack will take care of the path and the digest.

Handling Fonts

Webpack can already handle all the common font formats we may need (otf, ttf, woff, woff2, svg, etc). We will put all the font files at app/assets/fonts.

For fonts, we don't need to import them in our JS file, we can simply reference the files in a SCSS file and Webpack will compile them.

// app/assets/stylesheets/fonts.scss

@font-face {
  font-family: "custom-font";
  src: url("../fonts/custom-font.eot");
  src: url("../fonts/custom-font.eot?#iefix") format("embedded-opentype"), url("../fonts/custom-font.ttf")
      format("truetype"), url("../fonts/custom-font.woff") format("woff"), url("../fonts/custom-font.svg#custom-font")
      format("svg");
  font-weight: normal;
  font-style: normal;
}

Then we need to import the fonts CSS in our application.scss file:

// app/assets/stylesheets/application.scss
@import "./fonts.scss";

body {
  background-color: blue;
}

Now we can check the logs to be sure fonts were emitted:

[Webpacker] Hash: 00125bacb545dee275b9
Version: webpack 4.46.0
Time: 653ms
Built at: 02/07/2021 12:50:33
                                                        Asset       Size       Chunks                         Chunk Names
                                 css/application-9035a31a.css  718 bytes  application  [emitted] [immutable]  application
                             css/application-9035a31a.css.map  753 bytes  application  [emitted] [dev]        application
                       js/application-cae07e31f98e2d40328d.js    128 KiB  application  [emitted] [immutable]  application
                   js/application-cae07e31f98e2d40328d.js.map    141 KiB  application  [emitted] [dev]        application
                                                manifest.json   1.22 KiB               [emitted]              
 media/fonts/custom-font-3053c3c3bde7275a0023f883df3c257e.eot   19.8 KiB               [emitted]              
 media/fonts/custom-font-62822c524e9c9ff8e89893ed331a2527.svg   61.4 KiB               [emitted]              
media/fonts/custom-font-cfe66074982da095bcf581f8e37b7c85.woff   22.7 KiB               [emitted]              
 media/fonts/custom-font-e8c2d03a186a219dd552978db4371ccc.ttf   40.3 KiB               [emitted]              
        media/images/cat-106e150392be77f57358d16ef3678a35.jpg    732 KiB               [emitted]

Handling Videos

Handling videos is similar to handling images, though Webpacker does not provide a shorter video_pack_tag to replace video_tag like it does for images.

In order to handle videos, we need to:

  • add the videos at app/assets/videos (for example, let's call it video.mp4)
  • add the extensions we want in the config/webpacker.yml file
  • import a directory that contains the videos in the app/assets/packs/application.js file (like we did for images)
  • use the video_tag combined with asset_pack_path to get the correct file name
// app/assets/packs/application.js

require.context("../videos", true);
video_tag asset_pack_path('media/videos/video.mp4')

Live Reloading

Now that all our assets are handled by Webpack, the live reloading feature is more powerful: it will also reload our page when it detects CSS or images changes for example.

To enable live reloading, we need to start the webpack-dev-server along with the Rails server. We have to run ./bin/webpack-dev-server in one terminal and rails s in another. After reloading the page, if you change any asset, your page will be reloaded automatically.

Conclusion

With these changes, we can use Webpack to handle all the assets and, at the same time, keep a familiar file structure with assets organized by type inside app/assets with the only difference of using packs to tell Webpack which files to emit instead of an initializer to configure Sprockets.

We can now use Node modules that provide different assets easily without having to adapt them to Sprockets' conventions (most packages will give you instructions to use them with Webpack).

It allows us to use a JavaScript library like ReactJS or Vue.js and import assets inside the components when needed and also use them with Rails helpers.

We also have access to Webpack plugins to add more processing during compilation. Some examples:

Finally, the live reload feature can speed up your development if you need to do many asset changes.

Should you migrate your app to handle all the assets using Webpack? As always, it depends on your requirements and how familiar and comfortable your team is with Webpack. You can read more in the previous blog posts to see a comparison with Sprockets and some tips to help you do the transition.

You can check a sample Rails application applying this guide here.