Skip to main content

Phoenix 1.7 with npm

·5 mins

As of Phoenix 1.6, new projects are generated without a full package manager for JS dependencies. Instead, dependencies are either vendored or shipped as a hex package.

Let’s check out how to go from the generated setup to using npm to manage all JS dependencies.

If you’re here just for the raw changes you can see it all in this commit.

New project #

We start off by generating a new project:

mix phoenix_with_npm
cd phoenix_with_npm
mix setup

We start off with a fresh project which has:

  • wrapper packages for esbuild and tailwindcss
  • assets/vendor/topbar.js for topbar

This is great for getting started, but most bigger projects will want more control over JS dependencies and esbuild options.

Since we’re introducing a JS package manager into the project toolset, we might as well manage all our JS dependencies with it.

Add dependencies #

We’ll use the following commands to add our dependencies:

npm install --prefix assets esbuild tailwindcss --save-dev
npm install --prefix assets topbar @tailwindcss/forms ./deps/phoenix ./deps/phoenix_html ./deps/phoenix_live_view --save

Add esbuild script #

We’ll need a way to start esbuild, so let’s create a script for that purpose at assets/esbuild.js:

const esbuild = require("esbuild");

const args = process.argv.slice(2);
const watch = args.includes('--watch');
const deploy = args.includes('--deploy');

const loader = {
  // Add loaders for images/fonts/etc, e.g. { '.svg': 'file' }

const plugins = [
  // Add and configure plugins here

// Define esbuild options
let opts = {
  entryPoints: ["js/app.js"],
  bundle: true,
  logLevel: "info",
  target: "es2017",
  outdir: "../priv/static/assets",
  external: ["*.css", "fonts/*", "images/*"],
  loader: loader,
  plugins: plugins,

if (deploy) {
  opts = {
    minify: true,

if (watch) {
  opts = {
    sourcemap: "inline",
    .then((ctx) => {;
    .catch((_error) => {
} else {;

Note that this code won’t work for older esbuild versions because the API changed in 0.17.0.

Define npm scripts #

We want to expose the commands used to build JS and CSS in a nice interface. To do that we’ll define a scripts object in our assets/package.json with all our build commands.

The final file looks like this:

  "devDependencies": {
    "esbuild": "^0.17.11",
    "tailwindcss": "^3.2.7"
  "dependencies": {
    "@tailwindcss/forms": "^0.5.3",
    "phoenix": "file:../deps/phoenix",
    "phoenix_html": "file:../deps/phoenix_html",
    "phoenix_live_view": "file:../deps/phoenix_live_view",
    "topbar": "^2.0.1"
  "scripts": {
    "build.js": "node esbuild.js",
    "watch.js": "node esbuild.js --watch",
    "deploy.js": "node esbuild.js --deploy",
    "build.css": "tailwindcss -i css/app.css -o ../priv/static/assets/app.css",
    "watch.css": "tailwindcss -i css/app.css -o ../priv/static/assets/app.css --watch",
    "deploy.css": "tailwindcss -i css/app.css -o ../priv/static/assets/app.css --minify"

Now we can run these like npm --prefix assets run build.css.

Fix topbar usage #

topbar is no longer vendored in the project, npm is now responsible for managing it.

We just need to adjust the import:

--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -20,7 +20,7 @@ import "phoenix_html"
 // Establish Phoenix Socket and LiveView configuration.
 import {Socket} from "phoenix"
 import {LiveSocket} from "phoenix_live_view"
-import topbar from "../vendor/topbar"
+import topbar from "topbar"

 let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
 let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}})

And then we can delete the vendored version with rm assets/vendor/topbar.js.

Update mix aliases #

To use our newly defined commands in mix aliases:

--- a/mix.exs
+++ b/mix.exs
@@ -65,9 +63,15 @@ defmodule PhoenixWithNpm.MixProject do
       "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
       "ecto.reset": ["ecto.drop", "ecto.setup"],
       test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
-      "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
-      "": ["tailwind default", "esbuild default"],
-      "assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"]
+      "assets.setup": ["cmd npm --prefix assets install"],
+      "": [
+        "cmd npm --prefix assets run build.js",
+        "cmd npm --prefix assets run build.css"
+      ],
+      "assets.deploy": [
+        "cmd npm --prefix assets run deploy.js",
+        "cmd npm --prefix assets run deploy.css"
+      ]

Fix dev config #

We want to invoke our watch commands for JS and CSS:

--- a/config/dev.exs
+++ b/config/dev.exs
@@ -25,8 +25,16 @@ config :phoenix_with_npm, PhoenixWithNpmWeb.Endpoint,
   debug_errors: true,
   secret_key_base: "cZc9mWXsYwZho2V9maKIuDopEJUewOJeHezDRCFNcQZjUTIzH9u7ZKZ73+dMCyfs",
   watchers: [
-    esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
-    tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]}
+    npm: [
+      "run",
+      "watch.js",
+      cd: Path.expand("../assets", __DIR__)
+    ],
+    npm: [
+      "run",
+      "watch.css",
+      cd: Path.expand("../assets", __DIR__)
+    ]

Clean up #

We can remove the two wrapper packages and their configurations:

--- a/mix.exs
+++ b/mix.exs
@@ -41,8 +41,6 @@ defmodule PhoenixWithNpm.MixProject do
       {:phoenix_live_view, "~> 0.18.16"},
       {:floki, ">= 0.30.0", only: :test},
       {:phoenix_live_dashboard, "~> 0.7.2"},
-      {:esbuild, "~> 0.5", runtime: Mix.env() == :dev},
-      {:tailwind, "~> 0.1.8", runtime: Mix.env() == :dev},
       {:swoosh, "~> 1.3"},
       {:finch, "~> 0.13"},
       {:telemetry_metrics, "~> 0.6"},
--- a/config/config.exs
+++ b/config/config.exs
@@ -29,28 +29,6 @@ config :phoenix_with_npm, PhoenixWithNpmWeb.Endpoint,
 # at the `config/runtime.exs`.
 config :phoenix_with_npm, PhoenixWithNpm.Mailer, adapter: Swoosh.Adapters.Local

-# Configure esbuild (the version is required)
-config :esbuild,
-  version: "0.14.41",
-  default: [
-    args:
-      ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
-    cd: Path.expand("../assets", __DIR__),
-    env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
-  ]
-# Configure tailwind (the version is required)
-config :tailwind,
-  version: "3.2.4",
-  default: [
-    args: ~w(
-      --config=tailwind.config.js
-      --input=css/app.css
-      --output=../priv/static/assets/app.css
-    ),
-    cd: Path.expand("../assets", __DIR__)
-  ]

And then clean up the dependencies with mix deps.clean --unused --unlock.

Conclusion #

Finally, when we run mix phx.server we should see both watcher processes start up:

➜ mix phx.server
[info] Running PhoenixWithNpmWeb.Endpoint with cowboy 2.9.0 at (http)
[info] Access PhoenixWithNpmWeb.Endpoint at http://localhost:4000

> watch.css
> tailwindcss -i css/app.css -o ../priv/static/assets/app.css --watch

Done in 163ms.

> watch.js
> node esbuild.js --watch

[watch] build finished, watching for changes...

With these changes in place we can now manage all our JS dependencies in a uniform way. We also have more control over esbuild and tailwindcss, in case we need it.

Dino Kovač