josephlozano.dev

Setting up Phoenix with Vite

1/14/2025
tools

Here’s a quick guide to setting up Phoenix with Vite. I assume you have a basic understanding of Elixir and Phoenix and have elixir and node installed.

Setting up the project

First setup the project, skipping assets, then create a Vite project in the assets directory. Select the React/Typescript template.

mix phx.new my_app --no-assets
cd my_app
npm create vite asssets

mix.exs

In mix.exs, add cmd --cd assets npm install to the setup alias

def aliases do
  [
    setup: ["deps.get", "cmd npm install --prefix assets"],
    # ... other aliases
  ]
end

config/dev.exs

In config/dev.exs, add the following to your watcher (under the :my_app, MyAppWeb.Endpoint config): We’ll get to the run.sh script in the next section.

 watchers: [
    bash: ["run.sh", "node_modules/.bin/vite", cd: Path.join(__DIR__, "../assets")],
  ]

In the same config file, delete out the live_reload config for controllers. You don’t want to reload the page whenever you change an API controller. Fiddle with this to suite your application’s needs.

config :my_app, MyAppWeb.Endpoint,
  live_reload: [
    patterns: [
      ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$"
-     ~r"lib/my_app/(controllers|live|components)/.*(ex|heex)$"
+     ~r"lib/my_app/(live|components)/.*(ex|heex)$"
    ]
  ]

run.sh

Copy the script from https://hexdocs.pm/elixir/Port.html#module-zombie-operating-system-processes into assets/run.sh. As the Port docs indicate, this script helps prevent a zombie node process from being left behind after we shut down our Phoenix dev server. If we don’t do this, then port 5173 will be left open and we’ll have to kill the process manually.

#!/usr/bin/env bash

# Start the program in the background
exec "$@" &
pid1=$!

# Silence warnings from here on
exec >/dev/null 2>&1

# Read from stdin in the background and
# kill running program when stdin closes
exec 0<&0 $(
  while read; do :; done
  kill -KILL $pid1
) &
pid2=$!

# Clean up
wait $pid1
ret=$?
kill -KILL $pid2
exit $ret

vite.config.ts

Add the following to your vite.config.ts

export default defineConfig({
  plugins: [react()],
  logLevel: "warn",
  server: {
    port: 5173,
    strictPort: true,
    origin: "http://localhost:5173",
  },
  build: {
    emptyOutDir: true,
    manifest: true,
    outDir: "../priv/static",
    rollupOptions: {
      input: {
        main: "src/main.tsx",
        css: "src/main.css",
      },
      output: {
        entryFileNames: "assets/[name]-[hash].js",
        chunkFileNames: "assets/[name]-[hash].js",
        assetFileNames: "assets/[name]-[hash].[ext]",
      },
    },
  },
})

my_app_web/components/layouts.ex

In my_app_web/components/layouts.ex add the following vite_tags function:

if Mix.env() == :prod do
  @manifest :my_app
            |> :code.priv_dir()
            |> Path.join("static/.vite/manifest.json")
            |> File.read!()
            |> Jason.decode!()
            |> Enum.map(fn {_k, %{"file" => file}} -> file end)
            |> Enum.uniq()
            |> Enum.group_by(fn file -> Path.extname(file) end)

  defp manifest(type), do: @manifest[type]

  def vite_tags(assigns) do
    ~H"""
    <script :for={js_file <- manifest(".js")} type="module" src={js_file} />
    <link :for={css_file <- manifest(".css")} rel="stylesheet" href={css_file} />
    """
  end
else
  def vite_tags(assigns) do
    ~H"""
    <script type="module">
      import RefreshRuntime from 'http://localhost:5173/@react-refresh'
      RefreshRuntime.injectIntoGlobalHook(window)
      window.$RefreshReg$ = () => {}
      window.$RefreshSig$ = () => (type) => type
      window.__vite_plugin_react_preamble_installed__ = true
    </script>
    <script type="module" src="http://localhost:5173/@vite/client" />
    <script type="module" src="http://localhost:5173/src/main.tsx" />
    <link rel="stylesheet" href="http://localhost:5173/src/main.css" />
    """
  end

Call the vite_tags function in your root layout, replacing the existing static asset tags (my_app_web/components/layouts/root.html.heex).

 - <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
 - <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
 </script>
 + <%= vite_tags() %>

my_app_web/controllers/page_controller.ex

In your router add the following route:

get "/app/*path", PageController, :app

In my_app_web/controllers/page_controller.ex add the following:

def app(conn, _params) do
  render(conn, "app.html", layout: false)
end

In my_app_web/templates/page/app.html.heex add the following:

<div id="app"></div>

Now navigating to http://localhost:4000/app/ should show you the Vite app!

Dockerfile

After running mix phx.gen.release --docker, add a node stage to your Dockerfile.

FROM node:22-slim AS assets
WORKDIR /app/assets

COPY assets/package.json assets/package-lock.json ./
RUN npm ci

# copy elixir files for tailwindcss to see them
COPY lib ../lib

COPY assets ./

RUN npm run build

Lower down when copying assets, copy them from the assets stage.

COPY priv priv
COPY lib lib
+COPY --from=assets /app/priv/static ./priv/static

Now thats it! Vite should work in both development and production.

← Back to home