Skip to content

Use Vite in Phoenix project - May 2025

Published: at 09:30 AM

This is a fllow-up on a previous post. I decided to post this one as an update with some slight improvements.

As of now (v1.7) new projects created in Phoennix framework by default use two packages (esbuild and tailwind). They are essentially wrappers that take care of installing npm packages (esbuild and tailwind) and running respective scripts. This works great and you don’t have to think much about Javascript setup, and if you need, there are some configurations available.

But you might need to use a different bundling system, different UI framework or just have more access to the configurations of the currently used libraries. For example you might want to use custom plugins (in esbuild or vite). Here I’ll explain how to do that.

First things first, let’s make it look like a real Javascript app ;) Create an empty package.json file in the assets folder:

{
  "type": "module",
  "dependencies": {
    "phoenix": "file:../deps/phoenix",
    "phoenix_html": "file:../deps/phoenix_html",
    "phoenix_live_view": "file:../deps/phoenix_live_view"
  }
}

This ensures the imports from phoenix, phoenix_html and phoenix_live_view will be handled correctly by the bundler. Now let’s start with adding the JS dependencies. I’m using pnpm in this post, but if you use a npm or yarn you’ll need to adjust the commants. Run these commands in assets folder:

pnpm install -D vite tailwindcss @tailwindcss/vite @tailwindcss/forms

Now we need to start using it and get rid of the esbuild and tailwind (the Elixir wrapper ones of course). First let’s change the watcher command in dev.exs file:

  watchers: [
-    esbuild: {Esbuild, :install_and_run, [:journal, ~w(--sourcemap=inline --watch)]},
-    tailwind: {Tailwind, :install_and_run, [:journal, ~w(--watch)]}
+    pnpm: ["vite", "build", "--mode", "development", "--watch", "--config", "vite.config.js", cd: Path.expand("../assets", __DIR__)]
  ]

This uses pnpm to run the build command for Vite.js in watch mode. Notice that we need a config file for Vite as well. Create the file assets/vite.config.js and put this inside:

import { loadEnv, defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), "");

  return {
    publicDir: false,
    plugins: [tailwindcss()],
    build: {
      outDir: "../priv/static",
      target: ["es2022"],
      rollupOptions: {
        input: "js/app.js",
        output: {
          assetFileNames: "assets/[name][extname]",
          chunkFileNames: "[name].js",
          entryFileNames: "assets/[name].js",
        },
      },
      commonjsOptions: {
        exclude: [],
        include: ["vendor/topbar.js"],
      },
    },
    define: {
      __APP_ENV__: env.APP_ENV,
    },
  };
});

We should also remove the configurations related to esbuild and tailwind from the config.exs file as they are no longer needed:

-# Configure esbuild (the version is required)
-config :esbuild,
-  version: "0.17.11",
-  ocrm: [
-    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.4.3",
-  ocrm: [
-    args: ~w(
-      --config=tailwind.config.js
-      --input=css/app.css
-      --output=../priv/static/assets/app.css
-    ),
-    cd: Path.expand("../assets", __DIR__)
-  ]

Speaking of cleanups!, we should now remove the esbuild and tailwind from our mix packages.

mix deps.unlock esbuild tailwind
mix deps.clean esbuild tailwind

Don’t forget to remove them from the mix.exs file as well:

-      {:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
-      {:tailwind, "~> 0.2", runtime: Mix.env() == :dev},

Alright now we’ll adjust our assets to make sure they’re working fine in this new system. First change is in our root template file root.html.heex:

-    <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
+    <script defer phx-track-static type="module" src={~p"/assets/app.js"}>
    </script>

Another important step is to adjust Tailwind configs. The new version of Tailwind (v4) now uses css variables for the configurations. We need to change the app.css file:

-@import "tailwindcss/base";
-@import "tailwindcss/components";
-@import "tailwindcss/utilities";
+@import "tailwindcss";
+
+@config "../tailwind.config.js";
+
+@theme {
+  --color-brand: #fd4f00;
+}
+
+@plugin "@tailwindcss/forms";

+@custom-variant phx-click-loading (&:where(.phx-click-loading, .phx-click-loading *));
+@custom-variant phx-submit-loading (&:where(.phx-submit-loading, .phx-submit-loading *));
+@custom-variant phx-change-loading (&:where(.phx-change-loading, .phx-change-loading *));

We can then remove all the content from assets/tailwind.config.js and replace it with the following content:

module.exports = {
  content: ["./js/**/*.js", "../lib/**/*.*ex"],
};

And as the last bit, add one line at the top of your app.js file:

import "../css/app.css";

That’s it! You can now run

iex -S mix phx.server

and see it working like a charm. There is one more small step to have the app fully ready, and that’s adjusting the mix scripts for setting up and building the application. Change the mix.exs file like this:

-      "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
+      "assets.setup": ["cmd --cd assets pnpm install"],
-      "assets.build": ["tailwind journal", "esbuild journal"],
+      "assets.build": ["cmd --cd assets pnpm vite build --config vite.config.js"],
      "assets.deploy": [
-        "tailwind journal --minify",
-        "esbuild journal --minify",
+        "cmd --cd assets pnpm vite build --mode production --config vite.config.js",
        "phx.digest"
      ]

Happy Hacking!