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.jsassets/css/app.css- Tailwind and esbuild wired via
config/config.exs - Dev watchers for live rebuilding
- A single layout loading
app.cssandapp.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.
If this post was enjoyable or useful for you, please share it! If you have comments, questions, or feedback, you can email my personal email. To get new posts, subscribe use the RSS feed.