Migrating Phoenix to esbuild, with Tailwind, SCSS and Font Awesome

Migrating my Phoenix app to use esbuild turned out to be more involved than I'd expected. The main difficulties were:

  1. Handling static assets like fonts and all the different file extensions in my app with esbuild
  2. Maintaining the ability to use the considerable amount of SCSS already written in my stylesheets
  3. Not breaking Font Awesome or Bootstrap (which I'm mostly but not 100% migrated away from)

What worked for me

My package.json

  "scripts": {
    "build": "vite build",
    "watch": "vite build --watch --minify false --emptyOutDir false --clearScreen false --mode development"
  "dependencies": {
    "@fortawesome/fontawesome-free": "^5.15.4",
    "@fortawesome/free-solid-svg-icons": "^5.15.4",
    "@popperjs/core": "^2.10.2",
    "axios": "^0.24.0",
    "bootstrap": "^5.1.2",
    "bootstrap.native": "^4.0.6",
    "highlight.js": "^11.3.1",
    "phoenix": "^1.6.5",
    "phoenix_html": "^3.1.0",
    "phoenix_live_view": "^0.17.5"
  "devDependencies": {
    "autoprefixer": "^10.4.1",
    "chokidar": "^3.5.2",
    "esbuild": "^0.14.9",
    "esbuild-sass-plugin": "^2.0.0",
    "fs": "^0.0.1-security",
    "path": "^0.12.7",
    "postcss": "^8.4.5",
    "postcss-nested": "^5.0.6",
    "postcss-preset-env": "^6.7.0",
    "postcss-url": "^10.1.3",
    "sass": "^1.42.1",
    "tailwindcss": "^3.0.8",
    "vite": "^2.7.9"

The endpoint watcher's config in dev.exs:

  watchers: [
    node: [
      cd: Path.expand("../assets", __DIR__)


import { defineConfig } from "vite";

export default defineConfig(({ command }) => {
  const isDev = command !== "build";
  if (isDev) {
    // Terminate the watcher when Phoenix quits
    process.stdin.on("close", () => {


  return {
    publicDir: "static",
    // plugins: [react()],
    build: {
      target: "esnext", // build for recent browsers
      outDir: "../priv/static", // emit assets to priv/static
      emptyOutDir: true,
      sourcemap: isDev, // enable source map in dev build
      manifest: false, // do not generate manifest.json
      rollupOptions: {
        input: {
          app: "./js/app.js"
        output: {
          entryFileNames: "assets/[name].js", // remove hash
          chunkFileNames: "assets/[name].js",
          assetFileNames: chunkAssets

* Watches and closes stdin when the main process closes.
* This avoids zombie esbuild processes accumulating in the system.
function maybeCloseStdin(command) {
  if (command == "build") return
  process.stdin.on("close", () => { process.exit(0) })

* Maps asset chunks to their corresponding folder.
* Keys are regexes that match the file name and values should be a asset folder.
* This first match is always used, so make sure to put the most specific regex first.
const assetChunkMappings = {
  "\.webfonts/\.(woff2?|ttf|eot|svg)": "fonts/[name][extname]",
  "\.(woff2?|ttf|eot)$": "fonts/[name][extname]",
  "\.(s?css)$": "assets/[name][extname]",

function chunkAssets(info) {
  return Object.entries(assetChunkMappings)
    .filter(([key, _value]) => info.name.match(key))
    .map(([_key, value]) => value)[0] || "[ext]/[name][extname]"

My app.scss:

$fa-font-path: "@fortawesome/fontawesome-free/webfonts/";
@import "@fortawesome/fontawesome-free/scss/fontawesome";
@import "@fortawesome/fontawesome-free/scss/regular";
@import "@fortawesome/fontawesome-free/scss/solid";
@import "@fortawesome/fontawesome-free/scss/brands";
@import "@fortawesome/fontawesome-free/scss/v4-shims";

// @import "bootstrap-sass";
@import "custom";
@import "highlight.js/scss/base16/tomorrow-night";

The root template

This part was in the resources I mentioned in the video but I didn't explain it specifically.

If you're using Vite with Phoenix in dev mode, then you'll also need to set up your root.html.heex template to hook into Vite's output. The important part here is the if-else-end block:

<!DOCTYPE html>
<html lang="en">
    <%= render_existing(
      "meta." <> Phoenix.Controller.view_template(@conn),
      Map.merge(assigns, %{
        view_module: Phoenix.Controller.view_module(@conn),
        view_template: Phoenix.Controller.view_template(@conn)
    ) || render CampsiteWeb.LayoutView, "meta.html", assigns %>

    <%= if Application.get_env(:campsite, :mix_env) == :dev do %>
      <script type="module" src="http://localhost:3000/@vite/client"></script>
      <script defer type="module" src="http://localhost:3000/js/app.js"></script>
    <% else %>
      <script defer phx-track-static type="module" src={Routes.static_path(@conn, "/assets/app.js")}></script>
    <% end %>
    <link phx-track-static rel="stylesheet" href={Routes.static_path(@conn, "/assets/app.css")}/>
    <%# <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.3.1/styles/xt256.min.css" /> %>

    <%= @inner_content %>

The reason I can get :mix_env from my application environment is because I added it via this line in config.exs:

config :campsite, mix_env: Mix.env()

Change :campsite to whatever the name of your application is.


Back to index

No Comments