Managing JS and CSS Assets in Rails 7
Tags: ruby, rails, javascript, css
Published: 2022-01-08 21:04:32

---
title: Managing JS and CSS Assets in Rails 7
published: true
description: Exploring asset management in Rails 7 on a high level.
tags: ruby, rails, javascript, css
canonical_url: https://www.csalmeida.com/log/managing-js-and-css-assets-in-rails-7
cover_image: https://dev-to-uploads.s3.amazonaws.com/uploads/articles/g5sr1gg0590vmnizzf3r.jpg
---

> *Image by [Pawel Czerwinski
](https://unsplash.com/@pawel_czerwinski)*

On Ruby on Rails 7 the asset management processes have changed from using Webpacker to use the asset pipeline with Import Maps by default as a way to streamline uses of JavaScript based tools and package managers such as Webpack (or other bundlers) Yarn or npm.

This article aims to explore Import Maps and custom bundling setups on a high level including a quick look over Webpacker so that it can be compared with other approaches, a short example of using Import Maps and a more convoluted example of custom bundling using esbuild with TypeScript and PostCSS.

Hopefully this article can be used as a starting point for someone who uses JavaScript tools to bundle assets but has little knowledge of how this currently works in the context of a Rails app.

## Webpacker

This asset management approach was introduced in Rails 6 and is essentially an implementation of Webpack specifically configured to be used with Rails. This is a quick overview of Webpacker so that we can draw a comparison with more recent approaches to asset bundling.

With Webpacker a `config/webpacker.yml` is used as an interface to define the app’s Webpack configuration and a `config/webpack` folder was used store files to specify handling of assets in different environments (development, production) or to adapt it to use certain JavaScript libraries which might require additional configuration.

It would also include a `package.json` which has become common to use in any application that makes use of Node modules.

To install dependencies, `yarn install` needs to be run but when `rails server` is ran it would spin up the Rails application and run the Webpack watch task so that the assets are bundled correctly.

One disadvantage could be that the bundling tool is locked to Webpack behind an abstraction configuration layer as it was the default asset management approach picked for version 6.0.0 of Rails. 

What I mean by abstraction layer here is that there wouldn’t be a need to configure Webpack and it would just work out of the box but configuration aspects are hidden behind the scenes and changing them required to change a `webpacker.yml` and not the Webpack config directly. Rails had logic in place to glue all of this together behind the scenes. 

Stripping it out or ignoring it in favour of a custom implementation is possible but it’s an extra step and can be more time consuming.

## Import Maps

[Import Maps](https://wicg.github.io/import-maps/) is the pattern being shipped with a default Rails 7 application. It makes use of a feature where JavaScript modules that would typically be installed with a package manager, such as Yarn or npm, and in most cases transpiled and bundled into a `.js` file can be imported directly into the browser and used in your application without an extra build step.

### Key aspects on the Import Maps approach

- It’s more tightly coupled with Rails as it is the way the creator encourages developers to go for and ships with a default Rails app.
- Can simplify your toolchain since no `npm` or bundlers are required to make use of JavaScript libraries.
- Requires less configuration, running a new `rails new myapp` is enough to get you started.
- It does not include an option if you prefer an approach of bundling your own styles. For example using SASS or Postcss, although nothing stops you from making use of a hybrid approach and add a build step yourself.
- Less control of your asset bundling so if you require more complex JavaScript and CSS handling such as using Postcss partials or using a custom way of transpiling JavaScript it might not be the best choice.

Using Import Maps in a website (including a Rails app) will result in the source code look something like this:

```html
<script type="importmap" data-turbo-track="reload">{
  "imports": {
    "application": "/assets/application.js", // A local JS file.
		"another-js-library": "/assets/another-js-library.js, // Another local JS file.
		"local-time": "https://ga.jspm.io/npm:local-time@2.1.0/app/assets/javascripts/local-time.js" // A library being imported via a CDN.
  }
}</script>
```

The example above shows a description of which modules the page is using as importable files. Others could be added such as React, JQuery or pretty much any other JavaScript library.

Then the modules are imported after the `importmap` script tag by rendering a few additional `module` tags (can be one per module at times). In this case the libraries in the `importmaps` script tag are being used in `application.js` so only a single `module` tag is required and this should work for most cases:

```jsx
<script type="module">import "application"</script>
```

Rails will generate these tags for you when the `<%= javascript_importmap_tags %>` is added to a layout, typically `application.html.erb` and will work out which modules need to be included. 

For browsers that do not fully support this feature, the Rails team has created a shim to make it work for now.

**What is a shim?**

Essentially it’s a program that intercepts the default behaviour of another program or implementation and adds new logic to it, with the aim to make it work better with the application is being used in. 

In this case it intercepts the Import Maps feature and adds logic to make sure it works correctly in all the modern browsers as well as making it compatible with the Rails pattern.

**Using Import Maps in Rails**

To import a package that is typically available in `npm` run the following command in the terminal. In this case it will install `local-time`:

```bash
./bin/importmap pin local-time
```

This will add a new line to `config/importmap.rb` to put the package to use. This file is essentially used for Rails to generate the Import Maps script tag that is placed in the final HTML output:

```ruby
pin "local-time", to: "https://ga.jspm.io/npm:local-time@2.1.0/app/assets/javascripts/local-time.js"
```

If you would like to download the package to store it in your application, using the `--download` flag will pull the module file into `vendor/javascript/local-time.js` and it would also change the `pin` statement to reflect the change:

```ruby
pin "local-time" # @2.1.0
```

The module can then be used in `app/javascript/application.js` like a regular import would be:

```jsx
import LocalTime from "local-time"
```

In some cases you might want to use a module you’ve been working on and is not hosted with `npm`. To do this, add the file to `assets/javascript` in this case I’ve named it `home.js`:

```jsx
console.log("Hello Home!")
```

Then it can be imported to `application.js`:

```jsx
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"
import "trix"
import "@rails/actiontext"
// Importing the home.js script here!
import "./home"
import LocalTime from "local-time"
LocalTime.start()
```

That should be it, the code inside `home.js` should run without the need to be pinned in `importmap.rb`.

The `importmap.rb` file is used to workout which modules will be in the following tag:

```html
<script type="importmap" data-turbo-track="reload">{
  "imports": {
    "application": "/assets/application.js", // A local JS file.
		"another-js-library": "/assets/another-js-library.js, // Another local JS file.
		"local-time": "https://ga.jspm.io/npm:local-time@2.1.0/app/assets/javascripts/local-time.js" // A library being imported via a CDN.
  }
}</script>

```

It will also render any other necessary tags in order for Import Maps to work. Each tag points to a module used by this app in particular so your output might be different than this snippet:

```html
<link rel="modulepreload" href="/assets/application-97114f95015a6fb5e0cb87c109b1397e96ba9a9d1e7422725a491c2034ce6580.js">
<link rel="modulepreload" href="/assets/turbo.min-305f0d205866ac9fc3667580728220ae0c3b499e5f15df7c4daaeee4d03b5ac1.js">
<link rel="modulepreload" href="/assets/stimulus.min-900648768bd96f3faeba359cf33c1bd01ca424ca4d2d05f36a5d8345112ae93c.js">
<link rel="modulepreload" href="/assets/stimulus-loading-685d40a0b68f785d3cdbab1c0f3575320497462e335c4a63b8de40a355d883c0.js">
<script src="/assets/es-module-shims.min-6982885c6ce151b17d1d2841985042ce58e1b94af5dc14ab8268b3d02e7de3d6.js" async="async" data-turbo-track="reload"></script>
```

This is currently the encouraged way to manage JavaScript in a Rails application but the Rails team has worked towards giving developers some freedom to implement their custom bundling as well.

## Custom Bundling

Using your own bundling system such as Webpack, Rollup, esbuild or other is also possible in cases where you require a more robust setup. Perhaps you would like to use TypeScript or implement your own configuration of React, Svelte or Vue. You could want a setup with Sass or Postcss. You might simply want to have more control on how dependencies are installed and where they end up. If you require a more convoluted setup this could be the right approach to take.

### Key aspects on the custom bundling approach

- The bundler choice and configuration is left completely up to you. This can either be a positive change, because you get more control or could mean that it requires an extra step when setting up the pipeline and a number of additional configuration files.
- The Rails team has made available the **[`jsbundling-rails`](https://github.com/rails/jsbundling-rails)** gem that streamlines configuring your application with esbuild, Webpack or Rollup along with `[cssbundling-rails](https://github.com/rails/cssbundling-rails)` which is the equivalent to manage CSS bundling. Yarn is used in this case.
- This approach requires  `yarn build --watch` to be run alongside the Rails server process but using `./bin/dev` will run both processes in one go.

In new Rails 7 apps, a bundler and CSS pre processor can be specified using the following command:

```bash
rails new myapp -j esbuild -c postcss
```

The options for bundlers and CSS pre processors are limited to the options `jsbundling-rails` and `cssbundling-rails` offer. See each of the repositories README files for details since they might provide a starting point and save you some time when creating a setup with your preferred tools.

After using this command, a `scripts` object with `build` and `build:css` tasks still need to be defined and configured in `package.json.` An example of how these tasks may look like using the previously selected bundler and pre-processor:

```json
// previous file contents...
"scripts": {
    "build": "esbuild ./app/javascript/*.* --outfile=./app/assets/builds/application.js --bundle",
    "build:css": "postcss ./app/assets/stylesheets/application.postcss.css -o ./app/assets/builds/application.css"
},
// file continues...
```

Using this approach still couples it with Rails configuration which expects a few things:

- The JS and CSS final output needs to be copied to `app/assets/builds`. This means your final transpiled `.js` and processed `.css` files are expected to be served from here.
- Rails makes use of `<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>` and `<%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %>` to look for a bundled `application.js` and a `application.css` in the builds directory and expect these to exist.

Other than that, it seems JavaScript files and CSS files can be put together in a flexible way. However, using the `stylesheet_link_tag` method to add `link` tags to the head of the document seems to still require bundled files to be in the `builds` folder:

```json
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "style", "data-turbo-track": "reload" %>
<%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %>
```

In the example above a link tag pointing at `app/assets/builds/style.css` will also be included in the rendered HTML. 

How does Rails determines that the `builds` folder should be where compiled assets are meant to be stored? This [is decided by the `jsbundling-rails` and `cssbundling-rails` codebases](https://github.com/rails/jsbundling-rails/blob/b6ec0980ff573203ac1031074e57bc59be5acbf5/lib/install/install.rb#L6), in their default internal configuration.

**How about creating a JavaScript module?**

The same way a bundled CSS file is expected to be in `/builds` when using `stylesheet_link_tag`, the same is expected for a bundle JS file when using `javascript_include_tag`.

By default, using this custom bundling approach, Rails uses `app/javascript/application.js` as an entry point to compile files and you could split your scripts within this folder and import them in, as well as any modules installed via Yarn, this is what the file looks like:

```jsx
// Entry point for the build script in your package.json
import "@hotwired/turbo-rails"
import "./controllers"  
```

Creating a new module in `app/javascript/external.mjs` shows how Rails picks up the change when the file is imported into `application.js` and that the `.mjs` extension can be used with no issues:

```jsx
export const external_message = "External module loaded";

export function result() {
  return 3 + 3;
} 
```

**What about TypeScript?**

Typescript can be added in a few steps, [check out Noel Rappin’s post on how to get TypeScript up and running](https://noelrappin.com/blog/2021/12/typescript-and-jsbundling-and-rails-7/).

Here’s a breakdown of an example setup that builds on the previous steps, start by installing the `typescript`, `tsc-watch` and a configuration package. I’ve used `@tsconfig/recommended`:  

```bash
yarn add --dev typescript tsc-watch @tsconfig/recommended
```

Then we want to run the TypeScript checker before esbuild transpiles the code so a `watch:ts` command was added along side of a `failure:ts` command to run on failure to the `package.json` scripts object:

```json
"scripts": {
    "build": "esbuild ./app/javascript/application.ts --outfile=./app/assets/builds/application.js --bundle",
    "build:css": "postcss ./app/assets/stylesheets/application.postcss.css -o ./app/assets/builds/application.css",
    "failure:ts": "rm ./app/assets/builds/application.js && rm ./app/assets/builds/application.js.map",
    "watch:ts": "tsc-watch --noClear -p ./tsconfig.json --onSuccess \"yarn build\" --onFailure \"yarn failure:ts\""
},
```

This requires a `tsconfig.json`, this might be tricky to configure if you don’t do it often so here’s the configuration I’ve used:

```bash
{
  "extends": "@tsconfig/recommended/tsconfig.json",
  "compilerOptions": {
    "target": "ES2015",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "downlevelIteration": true
  },
  "$schema": "https://json.schemastore.org/tsconfig",
  "display": "Recommended",
  "include": [
    "./app/javascript/**/*.ts"
  ],
  "exclude": [
    "./node_modules"
  ]
}
```

Next, it’s required to rename the entry file at `app/javascript/application.js` to `application.ts` so that the TypeScript checker picks up on it.

Finally, the contents `[Procfile.dev](http://Procfile.dev)` need to be edited in order to run the TS watch command instead of the build one. We are running the esbuild command via `ts-watch` and that’s why it does not need to be in the Procfile:

```yaml
web: bin/rails server -p 2077
js: yarn watch:ts
css: yarn build:css --watch
```

Running `./bin/dev` in the terminal will start the tasks and the track changes as well as run TypeScript checks on any `.ts` files in the `./app/javascript` directory.

## Conclusion

With Rails 7, the framework now ships with an Import Maps approach by default but it does leave options for more complex setups with custom bundling which still have to be done “the Rails way” in some sense. This is noticeable, for example, when a there are assigned default entry points for scripts and pre processed styles. It does help developers that are looking to get slightly more control over their bundling and this seems to be a step in the right direction.

As the [Rails getting started guide](https://guides.rubyonrails.org/getting_started.html) says:

*If you learn "The Rails Way" you'll probably discover a tremendous increase in productivity. If you persist in bringing old habits from other languages to your Rails development, and trying to use patterns you learned elsewhere, you may have a less happy experience.* 

This does become true when, for instance, trying to place the files in custom directories since Rails still expects entry files to exist in certain folders and to be used or when trying to decouple asset bundling completely from the framework. For those wanting more complex setups it is completely possible to get them working but it can lead to a greater effort investment on the developer side and it might mean that some Rails helper methods might have to be set aside in those cases, creating a more decoupled solution.

As with everything, each approach shows advantages and disadvantages so it is very much dependant on the use case which one to go for.