Taking a look at what ESBuild would look like in a Phoenix app

I’m really liking the way this example app does assent building and watching

At the surface, you will notice that it does not delegate the building or watching of the style sheets to esbuild and instead just defaults to postcss. The more I look at this the more it makes sense.

I know that phoenix needs to have a trigger to tell live reload that something changed.
What issues could I run into if I had multiple watches running simultaneously?

I really want to mimic this asset pipline in my phoenix app. ESbuild is just too fast to ignore for webpack.

Isn’t it something that can replace webpack?

You might not need both…

I have used multiple watchers without problem. I have one for wasm-pack for example. When I change rust code, it compiles to wasm and reloads the page.

    cargo: [
      "wasm-pack build",
      cd: Path.expand("../assets/wasm/rustyapp", __DIR__)

But I could not make it work for multiple instances of the same watcher. I had too many files open error.

I have used 2 bundlers sequentially too, without problem, maybe not really useful.

It was bucklescript, and then webpack :slight_smile:

I don’t think it’s a problem to use ESBuild and Webpack, but have never tried.

1 Like

Esbuild can replace webpack.

It would be EsBuild + PostCss
EsBuild for all the JS
And Postcss for the CSS.

My only real concern at the moment for EsBuild is around wasm

1 Like

I’ve written about this in case you want to take a look: How I Handle Static Assets in my Phoenix apps | Mitchell Hanberg


Thank you! I’m gonna read this right now

postcss-cli processes my CSS and can run the
TailwindCSS JIT
mode without any problems.

Noice! the JIT mode + ESBuild. The build times must be so much faster.

Question about your post:

Why did you not put your commands in the package.json file and use npm run ?

Edit: never mind I forgot there is a legit reason why phoenix does not already do this too.

I will note that since writing this article, I have noticed that cpx has a problem with leaving zombie processes.

I also noticed that there is a fork, cpx2, that is more actively maintained. I opened a pull request on that fork to fix the zombie process issue Exit when stdin closes by mhanberg · Pull Request #23 · bcomnes/cpx2 · GitHub, but I am not sure if or when it will be merged.

1 Like

I have also looked into just using the new tailwind cli, but it also has the zombie process issue.


Have you seen GitHub - AvianFlu/ncp: Asynchronous recursive file copying with Node.js. before? Thinking about giving it a try my self in place of cpx

Edit: ah missed the lacking the watch feature. Prob not the better option

I’m not sure why we would reinvent cp -r with a Node.js version. If a simple cp -r will do the job, why writing something in another language?

As for watching and copying changed files over, a simple Makefile will do the job nicely.

I have pretty much using the combination of Makefile, tailwind CLI with JIT, and ESBuild.

#!/usr/bin/make -f

# Recursive wildcard function to find a pattern inside a folder.
# Example: $(call rwildcard,css,*.scss)
rwildcard = $(foreach d,$(wildcard $(1:=/*)),$(call rwildcard,$d,$2) $(filter $(subst *,%,$2),$d))

# Uses built-in `wildcard` function to find all immediate children of each asset folder.
# Then use another built-in `pathsubst` function to "rename" them to the final output.
styles := $(patsubst %.css,%.min.css,$(filter-out %.min.css, $(wildcard priv/static/css/*.css)))
scripts := $(patsubst %.mjs,%.min.js,$(wildcard priv/static/js/*.mjs))

# The first target will be executed if running `make` with no target specified.
# This target is marked as "phony" with the `.PHONY`. Phony targets do not correspond
# to actual file or folder, and will always be considered outdated.
# This target also has no recipe, but have other targets as prerequisites. These
# prerequisites will be checked for freshness.
.PHONY: all
all: node_modules $(styles) $(scripts)

priv/static/css/%.min.css: priv/static/css/%.css priv/static/css/%/tailwind.css
	@npx esbuild '$<' --minify --sourcemap --bundle --outfile='$@'

priv/static/css/%/tailwind.css: tailwind.config.js $(call rwildcard,lib/usenet_web,*.eex) $(call rwildcard,lib/usenet_web,*.leex)
	npx tailwind build -o $@

priv/static/js/%.min.js: priv/static/js/%.mjs
	@npx esbuild '$<' --minify --sourcemap --bundle --outfile='$@'

# The node_modules target will run whenever `package.json` and/or `package-lock.json`
# changes, which is usually when we checkout from remote repo, or installing new module.
# Here, we run `npm install` to ensure all node modules are installed.
# We have to `touch` the folder since only addition and removal of files will change
# the modified date of the folder.
node_modules:	## Ensure dependencies are up-to-date
node_modules: package.json package-lock.json
	npm install
	@touch -m node_modules

watch: ## Simple interval-polling watcher that will run `make` when there is something to be done.
	while true; do $(MAKE) -q || $(MAKE); sleep 0.5; done

I actually prefer @cnck1387’s method of just letting node handle the watching, but this is my setup from a project without webpack and with elixir watchers:

zombie.sh is from Port — Elixir v1.12.3 which fixes the hanging process issues. If you use it, you must run the binaries directly, as shown, npx tailwindcss won’t work, you’ll end up with zombies still.

# dev.exs
anti_zombie = Path.expand("../assets/zombie.sh", __DIR__)
config :project, ProjectWeb.Endpoint,
  http: [port: 4000],
  debug_errors: true,
  code_reloader: true,
  check_origin: false,
  watchers: [
    "#{anti_zombie}": [
      cd: Path.expand("../assets", __DIR__)
    "#{anti_zombie}": [
      cd: Path.expand("../assets", __DIR__)
    "#{anti_zombie}": [
      "./node_modules/.bin/cpx-fixed", # replace with entr?
      cd: Path.expand("../assets", __DIR__)
// package.json
  "scripts": {
    "prod:css": "tailwindcss --input css/app.css --postcss --output ../priv/static/css/app.css",
    "prod:js": "esbuild js/app.js --target=es2015 --bundle --outdir=../priv/static/js",
    // depending on how you build, you may need to clear the dest
    // my docker build process starts with a clean dest naturally so I don't worry
    "prod:static": "cp -R static/** ../priv/static",
    "prod:build": "npm run prod:static && npm run prod:js && npm run prod:css"

What I don’t like about this, is having to maintain both separately.

Since zombie.sh npm run dev:css will leave you with hanging processes, you must make sure to keep any options in sync between both files (hence why letting node do it all can feel nicer).

The hanging process stuff can be particularly painful with tailwind’s jit since it writes incrementally and things tend to get clobbered.


I’ve adapted the solution from @mhanberg’s nice blog post a little bit and it’s working great for me:


  "scripts": {
    "cpx": "./zombie.sh cpx './static/**/*' ../priv/static",
    "esbuild": "./zombie.sh esbuild ./js/app.js --target=es2015 --bundle --outdir=../priv/static/js --sourcemap --minify",
    "postcss": "./zombie.sh postcss ./css/app.css --dir ../priv/static/css",
    "watch": "NODE_ENV=development TAILWIND_MODE=watch sh -c 'npm run cpx -- --watch & npm run esbuild -- --watch & npm run postcss -- --watch'",
    "deploy": "NODE_ENV=production TAILWIND_MODE=build sh -c 'npm run cpx & npm run esbuild & npm run postcss'"
  "dependencies": { .. },
  "devDependencies": { .. }


config :app, App.Endpoint,
  watchers: [
    npm: [
      cd: Path.expand("../assets", __DIR__)

(+ assets/zombie.sh from the Elixir documentation)

1 Like

:eyes: :fire: :hatched_chick:

1 Like

Hey everyone,

I’ve been hacking at this for a few days but finally got it working—using tailwind, scss, and font-awesome with esbuild ONLY. No tailwind cli, no postcss cli because I am leveraging the esbuild plugin system…it requires you to use a script.js file but it works. I don’t need to hack together any npm commands, I did have to tweak the elixir specific configs for watchers/building but that was about it.

Any feedback would be appreciated


npm install ../deps/phoenix ../deps/phoenix_html ../deps/phoenix_live_view

is also necessary. Otherwise, those imports in the app.js will fail.

1 Like

I’m still learning the esbuild usage in Phoenix 1.6 so my question might be ignorant.
In config/dev.exs, is it OK to have hard-coded NODE_ENV as "development". Should this be different for prod env?

Despite my questions, I’m glad you did your hackery and found a way to make this work. I see SCSS working in dev and in prod. :+1:

1 Like

The mix.exs, you can set NODE_ENV=production … this is explicitly needed by tailwind for purging/jit mode I believe.

The config/dev.exs, since that is specifically for development mode, I am using the NODE_ENV as development

@cnck1387 If you ever decide to switch or add esbuild as an option, I created this to use it with tailwind JIT WITHOUT tailwind cli

It’s working pretty well for us , would be happy to submit a PR if you decide to make that switch