JS bundling with instant live-reload for Phoenix and Liveview

by Roman Heinrich

TLDR: for the impatient - here is the final outcome of this article:


Until recently one had 2 sensible choices when picking a frontend stack for Phoenix: - full SPA (and integration via an API) + average development experience - let the bundler do the full rebundle on any change + let Phoenix reload the browser based on filesystem events on file modifications - this **blows away the client-side state on every change** + has some delay, because it has to generate the complete asset bundle

Now there is also a new type of frontend bundlers that exploit capabilities of modern browsers supporting ES6 modules nativelly and skip the full rebundling during development to keep the cost for asset generation very small and unrelated to the total code size of your assets.

Snowpack.dev and Vitejs.dev are the most full-featured ones. The interesting bit about Vite.js is the fact that there is a documented approach of integrating it with a traditional backend like Rails / Phoenix - https://vitejs.dev/guide/backend-integration.html 🎉

This gave me hope and I took half a day aside to work on an integration with Phoenix + Liveview + Vite.js. The result looks quite promising and it is even not so much extra code in the end.

This gives us following benefits:

  • instant asset updates in the browser during development
  • still working automatic refresh on Phoenix template changes
  • using properly working LiveView components along React.js components
  • navigation handled by the server, to keep the JS logic focused on isolated areas and not ending up in a full SPA (Single-Page-Application)

Desired outcome

  • fast first booting experience
  • instant reload on code changes in dev
  • works with Elixir releases for deployment

Feel free to clone it and play with it locally.

## Assumptions:
# - you have a recent version of Elixir + Node.js + NPM installed on your system

# create a scratch folder
$ mkdir phx-vite ; cd phx-vite
$ git init

# fresh phx app + liveview (but without ecto to keep things a bit simpler)
$ mix archive.install hex phx_new
$ mix phx.new demo --live --no-ecto
$ cd demo

# now lets create a vite.js app as a sibling folder to assets
# we are going to use Typescript + Preact.js
$ yarn create @vitejs/app assets_new --template preact-ts
$ cd assets_new ; yarn
# start the dev server
$ yarn dev
⚡Vite dev server running at:

> Local: http://localhost:3000/
> Network:

ready in 498ms.

2:15:49 AM [vite] new imports encountered, updating dependencies...
2:15:49 AM [vite] ✨ dependencies updated.

# any asset change is instantly reflected in the browser, try it out!

If you peek into the sourcecode from vitejs HTML in browser you will see following:

<!DOCTYPE html>
<html lang="en">
<script type="module" src="/@vite/client"></script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>

<script type="module" src="/@vite/client"></script>
==> was injected by Vite.js to do some hot-reload magic in the browser.

Also notice type="module", this tells our (modern) browser to apply ES6 modules handling on such scripts. Similarly we request here: <script type="module" src="/src/main.tsx"></script> a seemigly un-transpiled Typescript JSX file directly, also of type = module, that should raise some brows right there. What the heck is going on here?


If we load src/main.tsx in browser, we see following code:

import { render, h } from 'preact'
import { App } from './app'
import './index.css'

render(<App />, document.getElementById('app')!)

Interestingly CURL is getting a slighty different result, probably because we dont have a proper session:

$ curl http://localhost:3000/src/main.tsx
import {render, h} from "/node_modules/.vite/preact.module.js?v=6045185e";
import {App} from "/src/app.tsx";
import "/src/index.css";
render(/* @__PURE__ */ h(App, null), document.getElementById("app"));

//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIi9Vc2Vycy9yb21hbi9EZXNrdG9wL2pzYnVuZGxlcnMvcGh4LXZpdGUvYXNzZXRzL3NyYy9tYWluLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgeyByZW5kZXIsIGggfSBmcm9tICdwcmVhY3QnXG5pbXBvcnQgeyBBcHAgfSBmcm9tICcuL2FwcCdcbmltcG9ydCAnLi9pbmRleC5jc3MnXG5cbnJlbmRlcig8QXBwIC8+LCBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnYXBwJykhKVxuIl0sIm1hcHBpbmdzIjoiQUFBQTtBQUNBO0FBQ0E7QUFFQSxPQUFPLGtCQUFDLEtBQUQsT0FBUyxTQUFTLGVBQWU7IiwibmFtZXMiOltdfQ==⏎

Preparing normal releases

To properly configure release compilation for production, we have to understand how Phoenix usually expects assets.
Let us go through the instructions on https://hexdocs.pm/phoenix/1.5.7/releases.html and adjust our application work with releases.

  1. Rename config/prod.secrets.exs to config/releases.exs to enable runtime configuration
  2. Put following code in config/releases.exs:
# config/releases.exs
import Config

secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise """
environment variable SECRET_KEY_BASE is missing.
You can generate one by calling: mix phx.gen.secret

# Keep all Endpoint configs as runtime configuration for simplicity
config :demo, DemoWeb.Endpoint,
http: [
port: String.to_integer(System.get_env("PORT") || "4000"),
transport_options: [socket_opts: [:inet6]]
secret_key_base: secret_key_base,
url: [host: "example.com", port: 80],
# this is needed for our LiveSocket / Websockets
check_origin: ["https://example.com", "//localhost:4000"],
cache_static_manifest: "priv/static/cache_manifest.json"

# start HTTP server
config :demo, DemoWeb.Endpoint, server: true
  1. Adjust config/prod.exs:
use Mix.Config

# Do not print debug messages in production
config :logger, level: :info
  1. Create a small bash-script to quickly test our release process:
mkdir bin ; touch bin/prod_build ; chmod +x bin/prod_build
vim bin/prod_build
#!/usr/bin/env bash

set -e # fail quickly!
# Initial setup
mix deps.get --only prod
MIX_ENV=prod mix compile

# Install / update JavaScript dependencies
npm install --prefix ./assets

# Compile assets
npm run deploy --prefix ./assets
mix phx.digest

# Generate release
MIX_ENV=prod mix release
  1. Create a bash-script to start our released application:
touch bin/prod_start ; chmod +x bin/prod_start
vim bin/prod_start
#!/usr/bin/env bash
cd _build/prod/rel/demo
SECRET_KEY_BASE="YVxXywQP0QadeOQBT61hXugPTc+qRRDZvQnwRCp2Jvf+i2Vd5k11BJ0GdjXu3o67" bin/demo start

This should already work, although the provided PageLive example will raise errors when testing the dependencies search. Fix it by providing static data to play around:

defmodule DemoWeb.PageLive do
use DemoWeb, :live_view

@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, query: "", results: %{})}

@impl true
def handle_event("suggest", %{"q" => query}, socket) do
{:noreply, assign(socket, results: search(query), query: query)}

@impl true
def handle_event("search", %{"q" => query}, socket) do
case search(query) do
%{^query => vsn} ->
{:noreply, redirect(socket, external: "https://hexdocs.pm/#{query}/#{vsn}")}

_ ->
|> put_flash(:error, "No dependencies found matching \"#{query}\"")
|> assign(results: %{}, query: query)}

defp search(query) do
for {app, desc, vsn} <- started_apps(),
app = to_string(app),
String.starts_with?(app, query) and not List.starts_with?(desc, ~c"ERTS"),
into: %{},
do: {app, vsn}

defp started_apps() do
{:demo, 'demo', '0.1.0'},
{:plug_cowboy, 'A Plug adapter for Cowboy', '2.4.1'},
{:cowboy_telemetry, 'Telemetry instrumentation for Cowboy', '0.3.1'},
{:cowboy, 'Small, fast, modern HTTP server.', '2.8.0'},
{:ranch, 'Socket acceptor pool for TCP protocols.', '1.7.1'},
{:cowlib, 'Support library for manipulating Web protocols.', '2.9.1'},
{:jason, 'A blazing fast JSON parser and generator in pure Elixir.\n', '1.2.2'},
'Periodically collect measurements and dispatch them as Telemetry events.', '0.5.1'},
{:phoenix_live_dashboard, 'Real-time performance dashboard for Phoenix', '0.4.0'},
'Provides a common interface for defining metrics based on Telemetry events.\n', '0.6.0'},
{:phoenix_live_reload, 'Provides live-reload functionality for Phoenix', '1.3.0'},
'A file system change watcher wrapper based on [fs](https://github.com/synrc/fs)',
{:phoenix_live_view, 'Rich, real-time user experiences with server-rendered HTML\n',
{:phoenix_html, 'Phoenix view functions for working with HTML templates', '2.14.3'},
{:runtime_tools, 'RUNTIME_TOOLS', '1.15'},
{:logger, 'logger', '1.11.2'},
{:gettext, 'Internationalization and localization through gettext', '0.18.2'},
'Productive. Reliable. Fast. A productive web framework that\ndoes not compromise speed or maintainability.\n',
{:phoenix_pubsub, 'Distributed PubSub and Presence platform', '2.0.0'},
{:plug, 'A specification and conveniences for composable modules between web applications',
{:telemetry, 'Dynamic dispatching library for metrics and instrumentations', '0.4.2'},
{:plug_crypto, 'Crypto-related functionality for the web', '1.2.0'},
{:mime, 'A MIME type module for Elixir', '1.5.0'},
{:eex, 'eex', '1.11.2'},
{:hex, 'hex', '0.20.6'},
{:inets, 'INETS CXC 138 49', '7.2'},
{:ssl, 'Erlang/OTP SSL application', '10.0'},
{:public_key, 'Public key infrastructure', '1.8'},
{:asn1, 'The Erlang ASN1 compiler version 5.0.13', '5.0.13'},
{:crypto, 'CRYPTO', '4.7'},
{:mix, 'mix', '1.11.2'},
{:iex, 'iex', '1.11.2'},
{:elixir, 'elixir', '1.11.2'},
{:compiler, 'ERTS CXC 138 10', '7.6.1'},
{:stdlib, 'ERTS CXC 138 10', '3.13'},
{:kernel, 'ERTS CXC 138 10', '7.0'}


Now we have a very basic Phoenix release artefact, that we can quickly generate / start locally, without actually deploying it somewhere else or waiting for a CI/CD pipeline to finish. This might seem like a trivial minor detail, yet it is very important to keep your feedback loop quick and cheap.

Lets figure out where our assets ended up in the release folder:

$ find _build/prod|grep cache_manifest.json

$ find _build/prod/rel/demo/lib/demo-0.1.0/priv

$ find _build/prod/rel/demo/lib/demo-0.1.0/ebin

OK, so the priv folder was moved with the compiled beam files to _build/prod/rel/demo/lib/demo-0.1.0/. Interesting.

If we inspect the cache_manifest.json file with JQ we see following content

cat _build/prod/rel/demo/lib/demo-0.1.0/priv/static/cache_manifest.json| jq .
"digests": {
"css/app-5e472e0beb5f275dce8c669b8ba7c47e.css": {
"digest": "5e472e0beb5f275dce8c669b8ba7c47e",
"logical_path": "css/app.css",
"mtime": 63779049287,
"sha512": "ueZTyFHdGFerp5ayZT16kRg4NqL2qtMzY0uqWfJwrvUgaqzJdeR9AZZlmGN2riO/sHxsm+TLGHo0AEOf5+YQlw==",
"size": 11587
"favicon-a8ca4e3a2bb8fea46a9ee9e102e7d3eb.ico": {
"digest": "a8ca4e3a2bb8fea46a9ee9e102e7d3eb",
"logical_path": "favicon.ico",
"mtime": 63779049287,
"sha512": "vCKvNNXeSP/2RRr6IN8PVa8/Hl6ImUO7miAOIMABYwbCzlm0UTRsY30uYb1k5gcCOPIsv6nZuFlJj/h8z+InzQ==",
"size": 1258
"images/phoenix-5bd99a0d17dd41bc9d9bf6840abcc089.png": {
"digest": "5bd99a0d17dd41bc9d9bf6840abcc089",
"logical_path": "images/phoenix.png",
"mtime": 63779049287,
"sha512": "93pY5dBa8nHHi0Zfj75O/vXCBXb+UvEVCyU7Yd3pzOJ7o1wkYBWbvs3pVXhBChEmo8MDANT11vsggo2+bnYqoQ==",
"size": 13900
"js/app-1ba682cf699a4161f6097645e8f98dc6.js": {
"digest": "1ba682cf699a4161f6097645e8f98dc6",
"logical_path": "js/app.js",
"mtime": 63779049287,
"sha512": "aKcaXStEZylVM3Gl0atAymDrZu4OJUixl5aj+JX+8GdBhapDc1WDntsW1qHZmHCJcmgxTlcH0uyPj5JlhfM8lw==",
"size": 104672
"js/app.js.LICENSE-bcda1cd32249233358d1702647c75e56.txt": {
"digest": "bcda1cd32249233358d1702647c75e56",
"logical_path": "js/app.js.LICENSE.txt",
"mtime": 63779049287,
"sha512": "gtWEgdNWOlNG3zeNPwB0PpnvJ6l3YeXQ7Ti62j/mH4XiR42Q2ykyG85kLFgJzUaw4m+5TBp7eIj1yiUomN8Wpg==",
"size": 98
"robots-067185ba27a5d9139b10a759679045bf.txt": {
"digest": "067185ba27a5d9139b10a759679045bf",
"logical_path": "robots.txt",
"mtime": 63779049287,
"sha512": "8FA6TZeCo3hFYcQ+9knbh3TrhkqGzYJx/uD5yRvggwM7gwfBPrPGqqrbVTZjnnnvlsw1zs1WJTPYez1zr/U4ug==",
"size": 202
"latest": {
"css/app.css": "css/app-5e472e0beb5f275dce8c669b8ba7c47e.css",
"favicon.ico": "favicon-a8ca4e3a2bb8fea46a9ee9e102e7d3eb.ico",
"images/phoenix.png": "images/phoenix-5bd99a0d17dd41bc9d9bf6840abcc089.png",
"js/app.js": "js/app-1ba682cf699a4161f6097645e8f98dc6.js",
"js/app.js.LICENSE.txt": "js/app.js.LICENSE-bcda1cd32249233358d1702647c75e56.txt",
"robots.txt": "robots-067185ba27a5d9139b10a759679045bf.txt"
"version": 1

This file helps Phoenix to determine which of the digested files should be used when referenced with their plain names from our code, like:
images/phoenix.png -> images/phoenix-5bd99a0d17dd41bc9d9bf6840abcc089.png. It also helps LiveView to track static assets on backend and refresh the browser in case asset version changed due a deployment. You have to provide the phx-track-static attribute to your assets for this to work.

# from demo_web/templates/layout/root.html.leex
<link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>

All the information above should give you good starting points to understand how Phoenix deals with assets in releases.

Vite.js with Phoenix

Lets copy our root.html.leex layout template and adjust it for vite.js. By keeping files along each other we get a bit better feeling about the differences and also we can quickly toggle between a working and yet non-working versions to keep our motivation higher and working in smaller incremental changes.

$ cp lib/demo_web/templates/layout/root.html.leex lib/demo_web/templates/layout/vite.html.leex
# adjust router to use `vite` layout
plug :put_root_layout, {DemoWeb.LayoutView, :vite}

Lets create a src/vendor folder in assets_new (vite.js managed assets) for our Phoenix related assets:

cd assets_new
mkdir src/vendor
touch src/vendor/{phx-app.js, phx-app.css}

vim src/vendor/phx-app.css

/* This file is for your main application css. */
@import "../../node_modules/nprogress/nprogress.css"; /* <-- ADJUST THIS PATH

/* LiveView specific classes for your customizations */

.phx-no-feedback .invalid-feedback
display: none;

.phx-click-loading {
opacity: 0.5;
transition: opacity 1s ease-out;

cursor: wait;
.phx-disconnected *{
pointer-events: none;

.phx-modal {
opacity: 1!important;
position: fixed;
z-index: 1;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgb(0,0,0);
background-color: rgba(0,0,0,0.4);

.phx-modal-content {
background-color: #fefefe;
margin: 15% auto;
padding: 20px;
border: 1px solid #888;
width: 80%;

.phx-modal-close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;

color: black;
text-decoration: none;
cursor: pointer;

/* Alerts and form errors */
.alert {
padding: 15px;
margin-bottom: 20px;
border: 1px solid transparent;
border-radius: 4px;
.alert-info {
color: #31708f;
background-color: #d9edf7;
border-color: #bce8f1;
.alert-warning {
color: #8a6d3b;
background-color: #fcf8e3;
border-color: #faebcc;
.alert-danger {
color: #a94442;
background-color: #f2dede;
border-color: #ebccd1;
.alert p {
margin-bottom: 0;
.alert:empty {
display: none;
.invalid-feedback {
color: #a94442;
display: block;
margin: -1rem 0 2rem;

vim src/vendor/phx-app.js

// webpack automatically bundles all modules in your
// entry points. Those entry points can be configured
// in "webpack.config.js".
// Import deps with the dep name or local files with a relative path, for example:
// import {Socket} from "phoenix"
// import socket from "./socket"
import "phoenix_html"
import {Socket} from "phoenix"
import NProgress from "nprogress"
import {LiveSocket} from "phoenix_live_view"

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

// Show progress bar on live navigation and form submits
let progressTimeout = null
window.addEventListener("phx:page-loading-start", () => { clearTimeout(progressTimeout); progressTimeout = setTimeout(NProgress.start, 300) })
window.addEventListener("phx:page-loading-stop", () => { clearTimeout(progressTimeout); NProgress.done() })

// connect if there are any LiveViews on the page

// expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
// >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket
} catch (error) {

If we refresh now, we see an empty page in browser. Looking into dev console shows:

client:196 [vite] connecting...
client:218 [vite] connected.
phx-app.js:10 Uncaught SyntaxError: The requested module '/node_modules/.vite/phoenix_html/priv/static/phoenix_html.js?v=7cbaa1e1' does not provide an export named 'default'

Hmmm... OK, that's a bummer. I could not figure out a nice way to make it work, so I vendored it in and provided a default export.

vim src/vendor/phoenix_html.js:

Vendored from deps/phoenix_html/priv/static/phoenix_html.js + extra default `export` statement.
Otherwise `vite` complains with:
Uncaught SyntaxError:
The requested module '/node_modules/.vite/phoenix_html/priv/static/phoenix_html.js?v=304c9b60'
does not provide an export named 'default'

"use strict";

(function() {
var PolyfillEvent = eventConstructor();

function eventConstructor() {
if (typeof window.CustomEvent === "function") return window.CustomEvent;
// IE<=9 Support
function CustomEvent(event, params) {
params = params || {bubbles: false, cancelable: false, detail: undefined};
var evt = document.createEvent('CustomEvent');
evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail);
return evt;
CustomEvent.prototype = window.Event.prototype;
return CustomEvent;

function buildHiddenInput(name, value) {
var input = document.createElement("input");
input.type = "hidden";
input.name = name;
input.value = value;
return input;

function handleClick(element) {
var to = element.getAttribute("data-to"),
method = buildHiddenInput("_method", element.getAttribute("data-method")),
csrf = buildHiddenInput("_csrf_token", element.getAttribute("data-csrf")),
form = document.createElement("form"),
target = element.getAttribute("target");

form.method = (element.getAttribute("data-method") === "get") ? "get" : "post";
form.action = to;
form.style.display = "hidden";

if (target) form.target = target;


window.addEventListener("click", function(e) {
var element = e.target;

while (element && element.getAttribute) {
var phoenixLinkEvent = new PolyfillEvent('phoenix.link.click', {
"bubbles": true, "cancelable": true

if (!element.dispatchEvent(phoenixLinkEvent)) {
return false;

if (element.getAttribute("data-method")) {
return false;
} else {
element = element.parentNode;
}, false);

window.addEventListener('phoenix.link.click', function (e) {
var message = e.target.getAttribute("data-confirm");
if(message && !window.confirm(message)) {
}, false);
export {} // <== THIS little bit here

Adjust phx-app.js to use our fixed version of phoenix_html.js by changing import "phoenix_html" to import "./phoenix_html".

Now it seems to work, and now our console shows an issue for the csrfToken, but that is expected, because we dont load those assets into the HTML layout from Phoenix yet.

Lets do it!

$ vim lib/demo_web/temlates/layout/vite.html.leex

<!DOCTYPE html>
<html lang="en">
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<%= csrf_meta_tag() %>
<%= live_title_tag assigns[:page_title] || "Demo", suffix: " · Phoenix Framework" %>
<!-- dev/test -->
<script type="module" src="http://localhost:3000/@vite/client"></script>
<script type="module" src="http://localhost:3000/src/main.tsx"></script>
<!-- end dev -->
<%= @inner_content %>

If we run our Phoenix server and vite.js in parallel (in 2 terminal consoles for now) we would see a barely styled PageLive view. I threw the phoenix.css into the assets_new folder to get familiar styles back.


Let recap:

  • We have a working local setup that allows us to update assets in the browser instantly and liveview is still working.
  • play with CSS editing, for example:
.phx-hero input {
background: orange;

Your form input will stay intact, yet the color of the form input will change. :) This is already exciting news for your frontend developers! 🤩🤩🤩

But we are not finished yet... This post is getting a bit too long, let's configure asset compilation for releases.

Configure vite assets for releases:

Add the following line to src/main.tsx

import 'vite/dynamic-import-polyfill'

This makes sure that browsers are able to properly import asset modules.

In assets_new/vite.config.ts we add a build config section with following content:

import preactRefresh from '@prefresh/vite'
import { defineConfig } from 'vite'

// https://vitejs.dev/config/
export default defineConfig({
esbuild: {
jsxFactory: 'h',
jsxFragment: 'Fragment'
build: {
manifest: true,
target: "es2018",
outDir: "../priv/static", //<- Phoenix expects our files here
emptyOutDir: true, // cleanup previous builds
polyfillDynamicImport: true, // this might be redundant, because we manually included `vite/dynamic-import-polyfill` in our main.tsx
sourcemap: true, // we want to debug our code in production
rollupOptions: {
// overwrite default .html entry
input: {
main: "src/main.tsx",
plugins: [preactRefresh()]

Lets create a small script that will generate a release with our new assets:

$ touch bin/prod_build_new ; chmod +x bin/prod_build_new
$ vim bin/prod_build_new
#!/usr/bin/env bash

# Initial setup
mix deps.get --only prod
MIX_ENV=prod mix compile

# Install / update JavaScript dependencies
cd assets_new && yarn ; cd ..

# Compile assets
cd assets_new && yarn build ; cd ..
echo "renaming manifest.json -> cache_manifest.json"
mv priv/static/manifest.json priv/static/cache_manifest.json
## we skip phx.digest because files we get are already with a hash in the name
## there might be a smarter workaround, yet this seems to be simpler and portable
# mix phx.digest

# Generate release
# --overwrite forces update without manual prompt
MIX_ENV=prod mix release --overwrite

If we run our new build script bin/prod_build_new, we see following static assets:

$ find priv/static

Yet when we run our release, none of of the files seem to be accessible via HTTP. This is because the Static Plug in our Endpoint setup white-lists only certain folders / files. We need to add the assets folder + maybe the cache_manifest.json file to the list.

In lib/demo_web/endpoint.ex:

plug Plug.Static,
only: ~w(assets css fonts images js favicon.ico robots.txt cache_manifest.json)


Now we need to somehow get those files into our production HTML. Yet we have a small issue: the manifest JSON file has a different format than what Phoenix expects it to be.

cat priv/static/cache_manifest.json | jq .
"src/main.tsx": {
"file": "assets/main.574d286a.js",
"src": "src/main.tsx",
"isEntry": true,
"imports": [
"css": "assets/main.0758dce7.css"
"_vendor.ef08aed3.js": {
"file": "assets/vendor.ef08aed3.js"

We need to conditionally render different links for assets, let's implement some view helpers:

Add to config/config.exs:

# Set current env during compilation, because Mix is absent in releases
config :demo, :environment, Mix.env()

Adjust demo_web/views/layout_view.ex:

defmodule DemoWeb.LayoutView do
use DemoWeb, :view
def env() do
Application.get_env(:demo, :environment, :dev)

def is_prod(), do: env() == :prod

Now the meaty part: we need to parse the Vite.js manifest.json and extract our digested file-paths from it. This is a bit trickier, yet
following module should help you with that:

In lib/vite/manifest.ex:

defmodule Vite do
defmodule PhxManifestReader do
@moduledoc """
Finding proper path for `cache_manifest.json` in releases is a non-trivial operation,
so we keep this logic in a dedicated module with some logic copied verbatim from
a Phoenix private function from Phoenix.Endpoint.Supervisor

require Logger

@endpoint DemoWeb.Endpoint
@cache_key {:vite, "cache_manifest"}

def read() do
case :persistent_term.get(@cache_key, nil) do
nil ->
res = read(current_env())
:persistent_term.put(@cache_key, res)
res ->

@doc """
# copy from
- `defp cache_static_manifest(endpoint)`
- https://github.com/phoenixframework/phoenix/blob/a206768ff4d02585cda81a2413e922e1dc19d556/lib/phoenix/endpoint/supervisor.ex#L411

def read(:prod) do
if inner = @endpoint.config(:cache_static_manifest) do
{app, inner} =
case inner do
{_, _} = inner -> inner
inner when is_binary(inner) -> {@endpoint.config(:otp_app), inner}
_ -> raise ArgumentError, ":cache_static_manifest must be a binary or a tuple"

outer = Application.app_dir(app, inner)

if File.exists?(outer) do
outer |> File.read!() |> Phoenix.json_library().decode!()
Logger.error "Could not find static manifest at #{inspect outer}. " <>
"Run \"mix phx.digest\" after building your static files " <>
"or remove the configuration from \"config/prod.exs\"."

def read(_) do
File.read!(manifest_path()) |> Jason.decode!()

def manifest_path() do
@endpoint.config(:cache_static_manifest) || "priv/static/cache_manifest.json"

def current_env() do
Application.get_env(:demo, :environment, :dev)

defmodule Manifest do
@moduledoc """
Basic and incomplete parser for Vite.js manifests
See for more details:
- https://vitejs.dev/guide/backend-integration.html
- https://github.com/vitejs/vite/blob/main/packages/vite/src/node/plugins/manifest.ts
Sample content for the manifest:
"src/main.tsx": {
"file": "assets/main.046c02cc.js",
"src": "src/main.tsx",
"isEntry": true,
"imports": [
"css": "assets/main.54797e95.css"
"_vendor.ef08aed3.js": {
"file": "assets/vendor.ef08aed3.js"

# specified in vite.config.js in build.rollupOptions.input
@main_file "src/main.tsx"

@spec read() :: map()
def read() do

@spec main_js() :: binary()
def main_js() do

@spec main_css() :: binary()
def main_css() do

@spec vendor_js() :: binary()
def vendor_js() do
get_imports(@main_file) |> Enum.at(0)

@spec get_file(binary()) :: binary()
def get_file(file) do
read() |> get_in([file, "file"]) |> prepend_slash()

@spec get_css(binary()) :: binary()
def get_css(file) do
read() |> get_in([file, "css"]) |> prepend_slash()

@spec get_imports(binary()) :: list(binary())
def get_imports(file) do
read() |> get_in([file, "imports"]) |> Enum.map(&get_file/1)

@spec prepend_slash(binary()) :: binary()
defp prepend_slash(file) do
"/" <> file

Now we need to adjust our layout template to include assets files from our manifest during in :prod env:

in lib/demo_web/templates/layout/vite.html.leex:

  <%= if is_prod() do %>
<!-- prod -->
<link phx-track-static rel="stylesheet" href="<%= Vite.Manifest.main_css() %>"/>
<script type="module" crossorigin defer phx-track-static src="<%= Vite.Manifest.main_js() %>"></script>
<link rel="modulepreload" href="<%= Vite.Manifest.vendor_js() %>">
<!-- end prod -->
<% else %>
<!-- dev/test -->
<script type="module" src="http://localhost:3000/@vite/client"></script>
<script type="module" src="http://localhost:3000/src/main.tsx"></script>
<!-- end dev -->
<% end %>

ATTENTION: pay close attention to type="module" for Vite.Manifest.main_js() and rel="modulepreload" for Vite.Manifest.vendor_js()!
Also vendor_js is a link, not script! Maybe this can be slightly modified, yet this version seems to work best.


Now try to recompile your release by running bin/prod_build_new and start it by running bin/prod_start.

If you visit "http://localhost:4000", you should have your styles + JS assets loaded properly and LiveView also working.

Now give yourself a nice reward for finishing this article!

There are many other things that need further clarification, like:

  • how do we mount React.js apps only on some parts of our application and how do we keep LiveView happy at the same time?
    • phx-ignore + some LiveView hooks seem to work ok
  • how do we deal with JS libraries that dont adhere to the ESNext6 format?
  • how do we deal with images / PDFs / fonts / SVGs?
    • probably in similar manner, using our Vite.Manifest module with some adjustments

Anyways, this is getting too long and it's time to wrap-up this article. Hope you enjoyed it and you will find some time to experiment with your Phoenix app and give it a turbo-boost for local development If you liked it, please share it with your team-member or colleagues who could benefit from a modern frontend setup with Phoenix!

Also stay tuned for the Part 2, where we configure Tailwind.css + include basic layout, and use LiveView navigation with some (P)React.js widgets!