533 words, 3 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.