Setup TailwindCSS and esbuild on Rails 7

Ahmed NadarAhmed Nadar
9 min read

Every time I run a new rails application, I’d need to configure essential tools for my front-end stacks such as TailwindCSS and esbuild. I’ve adopted my configuration from my friend Pete Hawkins’s post on The best way to run Tailwind CSS on Rails. And it has been serving me well 👍🏼. I would say. Recently I’ve stepped up my game 💪🏼 at setting up my Rails apps, trying to be more efficient and have a unified configuration.

As we know, building a modern Rails application starts with installing and setting up essential and reliable tools that help us focus on building our features. We will use well-known and documented tools.

Here is our Rails 7 application's initial configuration:

import-maps-rails

While I prefer using importmaps-rails to jsbundling-rails, I found many developers and companies want to have the ability to use JS libraries as they used with Webpack. Old habit I think.

What does it mean to use importmaps:

  • No more node_modules folder while I have the option to use external JS libraries directly from the browser. ✅

  • Preconfigured TailwindCSS from TailwindCSS-rails ready to use out of the box, pass --css tailwind as an option.✅

Extra and optional

While the above tools are fantastic to start up any Rails application, the following ones are the cherries on top 🍒 I’ve been using them for a while and I’m learning.

  • If you miss using remote: true rails form for AJAX communications, Mrujs keeps it in for you, even with Rails 7.

  • CableReady and StimulusReflex are installed for even more, server-powered frontend interactivity and reactive page updates.

  • Mrujs to replace a few features from Rails/UJS and for its powerful Cable Car plugin.

I will cover those in detail in another post.

Ready, Set, Run Rail…

Now we know a few essential tools, let’s generate new rails 7 app which comes with Stimulus and Turbo by default.

rails new hotwire_stack -d postgresql --css=tailwind --javascript=esbuild

Note: If you like to skip any other option such as rails test, jbuilder or mailbox, you would pass in the following --skip-system-test --skip-jbuilder --skip-mailbox.

After creating the Rails app, go ahead in your terminal or console and cd to our app, and type bin/dev to run the application. It might ask you to create its database though. Once all run properly, open your browser to http://localhost:3000/. Congratulations, you have a new rails app ready for creativity 👏🏼

But before we go any further, let’s step back in time ⏪ ⏳ and review some important files.

We ran our app via bin/dev. You can find the div file inside ./bin/dev folder. It is a ruby wrapper over the process manager forman which manages Procfile-based applications. Rails automatically install foreman gem but it doesn’t bundle it because forman recommends NOT to do so 🚫

Forman starts Procfile.dev by running, which isn’t obvious! Which is in our app root and it lists the processes we need to run with our rails server, tailcwindcss, and esbuild.

 web: bin/rails server -p 3000
 js: yarn build --watch
 css: yarn build:css --watch

Procfile.dev file watches any change that occurs to our html, css and js files and recompile the css and js files within app/assets/builds folder. Yarn 🧶 runs build these scripts communicate out to package.json file.

  "scripts": {
    "build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=assets",
    "build:css": "tailwindcss -i ./app/assets/stylesheets/application.tailwind.css -o ./app/assets/builds/application.css --minify"
  }

Easy and straightforward, right? Now that we have an “overview” of how our app initial start, let’s dig deeper.

Configure Tailwind

As we write more features we would need to organize our css files, write additional styles or wrap any tailwind components up using tailwinds @apply helper. Currently, we can’t import other css files into the main application.tailwind.css file because our node-powered TailwindCSS is provided by cssbundling-rails, which by default doesn’t allow it. Luckily we can fix it, thanks to postcss.

First, we need to install postcss other plugins via yarn. From terminal:

yarn add postcss postcss-flexbugs-fixes postcss-import postcss-nested 
touch postcss.config.js

Plugins definitions:

  • postcss a tool for transforming styles with JS plugins.

  • postcss-import inlines the stylesheets referred to by @import rules.

  • postcss-nested unwraps nested rules.

  • postcss-flexbugs-fixes fixes a few known flexbox bugs.

Now we can update postcss.config.js:

module.exports = {
  plugins: [
    require('postcss-import'),
    require('tailwindcss'),
    require('autoprefixer'),
    require("postcss-nested"),
    require("postcss-flexbugs-fixes"),
  ]
}

Likewise, update application.tailwind.css to replace the @tailwind directives with imports, as described in the Tailwind docs. Besides, we can create a components folder and add any css files to app/assets/stylesheets/components/*.css and @import it into the main application.tailwind.css file:

@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
/* app components */
@import "./components/card.css";

As we used plugins for postcss, we can use TailwindCSS ones likewise.

 yarn add @tailwindcss/forms @tailwindcss/typography

Plugins definitions:

  • @tailwindcss/typography A Tailwind CSS plugin for automatically styling plain HTML content with beautiful typographic defaults.

  • @tailwindcss/forms A plugin that provides a basic reset for form styles that makes form elements easy to override with utilities.

After adding previous updates, add these plugins to the Tailwind config file tailwind.config.js which is in the app directory.

module.exports = {
  content: [
    "./app/**/*.html.erb",
    "./app/helpers/**/*.rb",
    "./app/javascript/**/*.js",
  ],
  plugins: [
    require('@tailwindcss/forms'),
    require('@tailwindcss/typography')
  ],
}

You can customize tailwind.config.js file to your liking by adding themes, plugins, presets, and content again.

Phew, that was a lot to do for postcss and tailwindcsss configuration. You deserve a pat on your shoulder, well done 👏🏼

Now, it’s time to write a costume configuration for esbuild. It will replace the default one which ships with jsbundling-rails gem. But, do we need to do so for rsbuild?! you might ask. The answer ‘sometimes’ is yes.

While configurations are optional, it is helpful and efficient. For example, automatic page refresh (watch and rebuild) upon a file change, it makes life easier and fun coding😎 during development. Besides, it is good to get familiar with esbuild API.

Here is a summary of what our customization does:

  • Minify js bundle in production similar to tialwindcss in package.json file.

  • Enable source maps in both development and production.

  • Watch and rebuild the page upon any changes to assets and views.

API definitions:

  • Minify. When enabled, the generated code will be minified instead of pretty-printed. Minified code downloads faster but is harder to debug. You minify code in production but not in development.

  • Source maps, make it easier to debug your code. They encode the information necessary to translate from a line/column offset in a generated output file back to a line/column offset in the corresponding original input file.

  • Watch. Enabling watch mode tells esbuild to listen for changes on the file system and to rebuild file changes that could invalidate the build.

First, we need to install chokidar to enable watching and automatically refreshing our files.

 yarn add chokidar -D
 touch esbuild.config.js

And then update esbuild.config.js:

#!/usr/bin/env node

const esbuild = require('esbuild')
const path = require('path')

// Add more entrypoints, if needed
const entryPoints = [
  "application.js",
]
const watchDirectories = [
  "./app/javascript/**/*.js",
  "./app/views/**/*.html.erb",
  "./app/assets/stylesheets/*.css",
  "./app/assets/stylesheets/*.scss"
]

const config = {
  absWorkingDir: path.join(process.cwd(), "app/javascript"),
  bundle: true,
  entryPoints: entryPoints,
  outdir: path.join(process.cwd(), "app/assets/builds"),
  sourcemap: true
}

async function rebuild() {
const chokidar = require('chokidar')
const http = require('http')
const clients = []

http.createServer((req, res) => {
  return clients.push(
    res.writeHead(200, {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      "Access-Control-Allow-Origin": "*",
      Connection: "keep-alive",
    }),
  );
}).listen(8082);

let result = await esbuild.build({
  ...config,
  incremental: true, // use "esbuild": "~0.16.17" to avoide unsloved error with esbuild 7
  banner: {
    js: ' (() => new EventSource("http://localhost:8082").onmessage = () => location.reload())();',
  },
})

chokidar.watch(watchDirectories).on('all', (event, path) => {
  if (path.includes("javascript")) {
    result.rebuild()
  }
  clients.forEach((res) => res.write('data: update\n\n'))
  clients.length = 0
});
}

if (process.argv.includes("--rebuild")) {
  rebuild()
} else {
  esbuild.build({
    ...config,
    minify: process.env.RAILS_ENV == "production",
  }).catch(() => process.exit(1));
}

That’s a lot of ‘gibberish’ code ⌨️. Let’s decode it.

  • Set our entryPoints for applications.js

  • We set watchDirectories for watching files we care about within assets, javascript, and views folders.

  • Create a server and listen to it at http://localhost:8082

  • Build our JS with esbuild both incremental and banner configuration options set. Incremental is useful at calling esbuild’s build API repeatedly, such as implementing a file watcher service. While banner it is used to insert comments at the beginning of generated JavaScript and CSS files.

  • chokidar watch our directories for any change, each time it occurs chokidar opens a new EventSource connection to the web server. This kicks off reload() the function to rebuild any JS file that had changed.

  • We end the file with if and else block, it checks whether the above rebuild() function passed to esbuild or runs the default esbuild.build() function in production.

Revisit our package.json

Earlier we mentioned when we run bin/div it runs forman it manages Procfile.dev file. The latter watches any change for selected files via the script property of package.json the file.

Now and after we created our esbuild.config configuration, we should update package.json file as below:

{
    "esbuild": "~0.16.17",  
  },
"scripts": {
  "build": "node esbuild.config.js",
  "build:css": "tailwindcss --postcss -i ./app/assets/stylesheets/application.tailwind.css -o ./app/assets/builds/application.css"
}

Note: Using this "esbuild": "~0.16.17" version prevents an error caused by calling incremental. 'ERROR] Invalid option in build() call: "incremental". ' with the latest version. https://github.com/floydspace/serverless-esbuild/issues/427

Check out both package.json before…

  "scripts": {
    "build": "esbuild app/javascript/*.* 
        --bundle 
        --sourcemap 
        --outdir=app/assets/builds 
        --public-path=assets",

    "build:css": "tailwindcss 
        -i ./app/assets/stylesheets/application.tailwind.css 
        -o ./app/assets/builds/application.css 
        --minify"
  }

…And after the update.

    "scripts": {
      "build": "node esbuild.config.js",

      "build:css": "tailwindcss 
          --postcss 
          -i ./app/assets/stylesheets/application.tailwind.css 
          -o ./app/assets/builds/application.css"
    }

One last change before we jump on coding, we need to update Prockfile.dev file and pass the --rebuild argument.

 web: bin/rails server -p 3000
 js: yarn build --rebuild
 css: yarn build:css --watch

That’s all folks. Easy, right?

Before I leave to get coding, let’s do a quick recap.

Now, when Rails runs bin/dev it calls out to Prockfile.dev which runs yarn build command that communicates with package.json file. The script properly execute build command for both js and css options. And in order, both options call out to esbuild.config.js and postcss.config.js+ tailwind.config.js respectively.

The recent updates package.json save us time and manual labor to run those scripts.

At any time you are ready to start up the application, use bin/dev the command to start the rails server which will build JavaScript and CSS all in one go for you.

That is it. We have accomplished a lot in a short time ‘relatively’ speaking. Hope this helps.

Next post, we will cover Hotwire stack, CableReady, StimulusReflex, Mrujs, and UUIDs.

Happy Coding 😀 💻

0
Subscribe to my newsletter

Read articles from Ahmed Nadar directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ahmed Nadar
Ahmed Nadar

Developer and Product Design. RapidRails UI components creator Run RapidRails Agency. https://rapidrails.cc