Adding TailwindCSS, PostCSS, and optional plugins to a new Phoenix application

(revised on )

The Phoenix Framework ships with esbuild support out of the box. In fact, the default behavior is to invoke esbuild directly from a Mix task, powered by the esbuild package.

This allows me to compile my entire Javascript bundle with the following command:

mix esbuild default

Behind the scenes, the esbuild package uses a native esbuild binary. The binary includes the CSS loader by default, so this process will even extract my CSS from the Javascript.

➜  my_app mix esbuild default

  ../priv/static/assets/app.js   177.1kb
  ../priv/static/assets/app.css   13.5kb

⚡ Done in 10ms

The Phoenix team has also developed a TailwindCSS package that uses a similar native binary to add all Tailwind functionality to Phoenix applications. However, I want to take it a step further and use Tailwind as a PostCSS plugin. The biggest reason is that I want to add other PostCSS plugins to my CSS pipeline.

The first thing to do is bring in the tailwind package to mix.exs.

defp deps do
  [
    {:phoenix, "~> 1.6.6"},
    ...,
    {:tailwind, "~> 0.1", runtime: Mix.env() == :dev}
  ]
end

Then I added the recommended configuration to config.exs.

config :tailwind,
  version: "3.0.14",
  default: [
    args: ~w(
      --config=tailwind.config.js
      --input=css/app.css
      --output=../priv/static/assets/app.css
    ),
    cd: Path.expand("../assets", __DIR__)
  ]

Out of the box, I can use the tailwind package to compile my CSS. The package also uses sane content defaults for TailwindCSS's tree-shaking feature.

➜  my_app mix tailwind default

Done in 87ms.

Opening priv/static/assets/app.css will show the compiled CSS with only my required classes.

🛑 Is this enough?

Before going any further, this might be enough for most use-cases. TailwindCSS is designed to work with no preprocessing or plugins, and with less CSS overall. If PostCSS with plugins is not needed, then this approach works great as-is. All custom CSS can go in app.css underneath the tailwind imports.

Node-based CSS pipeline

One use-case I want to support is breaking up my CSS files and using imports. This can be done with PostCSS and the postcss-import plugin. For example, I cannot change my app.css file to the following:

@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";

@import "model/component";

The import of model/component will not be resolved by the TailwindCSS, since that is the responsibility of PostCSS.

The first thing I created was assets/postcss.config.js as:

module.exports = {
  plugins: {
    "postcss-import": {},
    tailwindcss: {},
    autoprefixer: {},
  }
}

Then I removed the tailwind package and the changes to config.exs. The rest of the pipeline will be completely Node-based. Install all of the needed packages with NPM.

npm install -D tailwindcss autoprefixer postcss postcss-import @tailwindcss/forms

I added a script to package.json to make running the pipeline easier.

{
  ...
  "scripts": {
    "compile": "cd assets && npx tailwind --postcss --config=tailwind.config.js --input=css/app.css --output=../priv/static/assets/app.css",
  }
}

Running npm run compile will now use the Node version with PostCSS and any plugins I want to include. One problem with this approach is that building my application in Docker requires adding a Node build layer to the Dockerfile. That hasn't been a big problem for me so far.

It's possible to hook the Node pipeline into Phoenix's watchers so that CSS is recompiled when changed. In dev.exs, update the watchers to:

config :my_app, MyAppWeb.Endpoint,
  ...
  watchers: [
    esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
    npx: [
      "tailwind",
      "--postcss",
      "--config=tailwind.config.js",
      "--input=css/app.css",
      "--output=../priv/static/assets/app.css",
      cd: "assets"
    ]
  ]