Setting up Phoenix with Vite
1/14/2025Here’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