397 words, 2 min read

Phoenix ships with a single asset pipeline by default, but real-world applications often need more. An admin area or backoffice is a common case where separate CSS and JS bundles keep concerns isolated. This post shows how to add a second bundle, including Tailwind configuration and dev-time watchers.

Default Phoenix asset setup recap

A standard Phoenix app includes:

  • assets/js/app.js
  • assets/css/app.css
  • Tailwind and esbuild wired via config/config.exs
  • Dev watchers for live rebuilding
  • A single layout loading app.css and app.js

We’ll extend this setup without affecting the default bundle.

Adding a second JavaScript entry point

Create a new JS entry:

assets/js/admin.js
console.log("Admin bundle loaded");

This becomes the root of the admin bundle.

Adding a second CSS entry point

Create a new stylesheet:

assets/css/admin.css

For Tailwind:

@tailwind base;
@tailwind components;
@tailwind utilities;

This allows full Tailwind usage without leaking styles into the main app.

Updating Tailwind build configuration

Open config/config.exs and extend the :tailwind config:

config :tailwind,
version: "4.1.16",
default: [
args: ~w(
--input=css/app.css
--output=../priv/static/assets/app.css
),
cd: Path.expand("..", __DIR__)
],
admin: [
args: ~w(
--input=css/admin.css
--output=../priv/static/assets/admin.css
),
cd: Path.expand("..", __DIR__)
]

You now have two independent Tailwind builds.

Updating esbuild configuration

Still in config/config.exs, add a second esbuild profile:

config :esbuild,
version: "0.25.11",
default: [
args:
~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets --external:/fonts/* --external:/images/* --alias:@=.),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
],
admin: [
args:
~w(js/admin.js --bundle --target=es2022 --outdir=../priv/static/assets --external:/fonts/* --external:/images/* --alias:@=.),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
]

Wiring both bundles into Mix aliases

Ensure both Tailwind and esbuild profiles run during deployment by editing mix.exs:

defp aliases do
[
"assets.deploy": [
"tailwind default --minify",
"tailwind admin --minify",
"esbuild default --minify",
"esbuild admin --minify",
"phx.digest"
]
]
end

Adding dev watchers

Without watchers, the second bundle won’t rebuild in development. Update config/dev.exs:

config :my_app, MyAppWeb.Endpoint,
watchers: [
esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
esbuild_admin: {Esbuild, :install_and_run, [:admin, ~w(--sourcemap=inline --watch)]},
tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]},
tailwind_admin: {Tailwind, :install_and_run, [:admin, ~w(--watch)]}
]

Each watcher maps cleanly to a build profile.

Referencing the admin assets in a layout

Include the admin bundle:

<link phx-track-static rel="stylesheet" href={~p"/assets/admin.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/admin.js"}></script>

Why this setup works well

  • Tailwind scanning stays fast and precise
  • Admin styles and JS are fully isolated
  • Dev experience remains identical to the default setup
  • Adding a third bundle follows the same pattern

This approach fits naturally into Phoenix’s asset pipeline while keeping growth manageable.