BunBundle

CIVersion

Bun-powered asset bundler for Elixir with fingerprinting, SRI, CSS hot-reload, and live reload. Works with any Elixir app, Phoenix included.

BunBundle vendors a small JS bundler ported from the Ruby gem bun_bun_bundle and runs it under Bun. Bun ships Lightning CSS built in, so glob imports, nesting, autoprefixing, and minification work without a separate CSS toolchain. No Node.js required.

Why use BunBundle?

In a Phoenix app, the default esbuild setup gets you JS bundling but leaves you to wire up CSS separately (often via the standalone tailwind mix task). BunBundle replaces both with a single tool: JS bundling, CSS processing (nesting, autoprefixing, minification via built-in Lightning CSS), fingerprinting, and live reload all in one watcher. No separate tailwind process, no two-asset-pipeline juggling.

Installation

Add bun_bundle to your dependencies in mix.exs:

def deps do
[
{:bun_bundle, "~> 0.1.0", runtime: Mix.env() == :dev}
]
end

Then set the Bun version in config/config.exs:

config :bun_bundle, version: "1.4.2"

And install the Bun binary:

mix bun.install

The binary is downloaded to _build/bun-<target> and is not required to be on your PATH.

Ignoring build artifacts

Add the bundled output and manifest to your .gitignore:

/priv/static/assets/
/priv/static/bun-manifest.json

Both are regenerated on every build, including the fingerprinted asset names inside the manifest, so committing them only adds churn. Adjust the paths if you override outDir or manifestPath in config/bun.json.

Configuration

All bundler configuration lives in config/bun.json at your project root. Bun reads it directly. BunBundle does not translate config.

Minimal example:

{
"entryPoints": {
"js": ["assets/js/app.js"],
"css": ["assets/css/app.css"]
},
"outDir": "priv/static/assets",
"publicPath": "/assets",
"manifestPath": "priv/static/bun-manifest.json"
}
Full config example (all values shown are defaults)
{
"entryPoints": {
"js": ["assets/js/app.js"],
"css": ["assets/css/app.css"]
},
"outDir": "priv/static/assets",
"publicPath": "/assets",
"manifestPath": "priv/static/bun-manifest.json",
"watchDirs": ["assets"],
"staticDirs": ["assets/images", "assets/fonts"],
"devServer": {
"host": "127.0.0.1",
"port": 3002,
"secure": false
},
"plugins": {
"css": ["aliases", "cssGlobs"],
"js": ["aliases", "jsGlobs"]
}
}

Creating a bun.json file is entirely optional. All values shown above are defaults, you only need to specify what you want to override.

watchDirs entries may be glob patterns. For example, in a modular app with multiple slices, "slices/*/assets" will watch every slice's assets directory without having to list them explicitly.

If you're developing inside a Docker container, set listenHost so the WebSocket server accepts connections from the host machine:

{
"devServer": {
"listenHost": "0.0.0.0"
}
}

Plugins

Three plugins are included out of the box.

aliases

Resolves $/ root aliases to absolute paths in both CSS and JS files. This lets you reference assets and modules from the project root without worrying about relative paths.

In CSS:

@import '$/assets/css/reset.css';
.logo {
background: url('$/assets/images/logo.png');
}

In JS:

import utils from '$/lib/utils.js'

All $/ references are resolved to your project root.

cssGlobs

Expands glob patterns in CSS @import statements. Instead of manually listing every file, you can import an entire directory at once:

@import './components/**/*.css';

This will be expanded into individual @import lines for each matching file, sorted alphabetically. A warning is logged if the pattern matches no files.

To exclude specific paths, add one or more not clauses:

@import './components/**/*.css' not './components/admin/**' not
'./components/internal/**';

Warning

Always include the file extension in glob patterns (e.g., **/*.css instead of **/*). Without it, editor temp files like Vim's ~ backups will be picked up by the glob, causing build failures during development.

jsGlobs

Compiles glob imports into an object that maps file paths to their default exports. Use the special glob: prefix in an import statement:

import components from 'glob:./components/**/*.js'

To exclude specific paths, add not clauses inside the string:

import components from 'glob:./components/**/*.js not ./components/admin/**'

This will generate individual imports and build an object mapping. For example:

import _glob_components_theme from './components/theme.js'
import _glob_components_shared_tooltip from './components/shared/tooltip.js'
const components = {
'theme': _glob_components_theme,
'shared/tooltip': _glob_components_shared_tooltip
}

Note

If no files match the pattern, an empty object is assigned.

Custom plugins

Custom plugins are JS files referenced by their path in the config. Each file must export a factory function that receives a context object. What the factory returns determines the plugin type.

The context object has the following properties:

Simple transform plugins

A simple transform plugin returns a function that receives the file content as a string and an args object from Bun's onLoad hook (containing path, loader, etc.). It should return the transformed content. The transform can be synchronous or asynchronous.

Transforms are chained in the order they appear in the config, so each transform receives the output of the previous one.

// config/bun/banner.js
export default function banner({minify}) {
return (content, args) => {
const stamp = minify ? '' : ` (dev ${args.path})`
return `/* My App${stamp} */\n${content}`
}
}

Raw Bun plugins

If the factory returns an object with a setup method instead of a function, it is treated as a raw Bun plugin. This gives you full access to Bun's plugin API, including onLoad, onResolve, and custom loaders.

// config/bun/svg.js
export default function svg() {
return {
name: 'svg-loader',
setup(build) {
build.onLoad({filter: /\.svg$/}, async args => {
const text = await Bun.file(args.path).text()
return {
contents: `export default ${JSON.stringify(text)}`,
loader: 'js'
}
})
}
}
}

Registering custom plugins

Reference custom plugins by their file path in your config:

{
"plugins": {
"css": ["aliases", "cssGlobs", "config/bun/banner.js"],
"js": ["aliases", "jsGlobs", "config/bun/svg.js"]
}
}

Warning

The order of the plugins matters here. For example, the aliases plugin needs to resolve the paths first before the glob plugin can do its work. Keep that in mind for your own plugins too.

Community plugins

A collection of ready-made plugins is available at bun_bun_bundle-plugins, including design token generation and build notifications.

Usage

Mix tasks

mix bun # build once with current settings
mix bun --dev # dev build with inline sourcemaps
mix bun --prod # production build (fingerprint + minify)
mix bun.install # download the configured Bun release

All flags after mix bun are passed straight through to the bundler.

Flags

Phoenix

Wire the watcher and asset aliases:

# config/dev.exs
config :my_app, MyAppWeb.Endpoint,
watchers: [
bun: {BunBundle, :install_and_run, [~w(--dev)]}
]
# mix.exs
defp aliases do
[
"assets.setup": ["bun.install --if-missing"],
"assets.build": ["bun"],
"assets.deploy": ["bun --prod", "phx.digest"]
]
end

Non-Phoenix apps

Call BunBundle.install_and_run/1 from wherever your app boots its asset pipeline. It installs Bun on first run then invokes the bundler with the given args.

BunBundle.install_and_run(~w(--dev))

Rendering asset tags

BunBundle ships a manifest reader and helpers that resolve source paths to their fingerprinted URLs. The manifest is cached in ETS and automatically reloaded when bun-manifest.json changes.

Plain functions

Framework-agnostic helpers live in BunBundle.Helpers. Tag functions return {:safe, iodata} when Phoenix.HTML is loaded (the Phoenix convention, safe to interpolate directly in <%%> blocks) and plain HTML strings in environments without it. URL functions always return strings.

import BunBundle.Helpers
asset("js/app.js")
# => "/assets/js/app-abc12345.js"
js_tag("js/app.js", defer: true)
# => <script src="/assets/js/app-abc12345.js" integrity="sha384-..." defer></script>
css_tag("css/app.css")
# => <link rel="stylesheet" href="/assets/css/app-def67890.css" integrity="sha384-...">
img_tag("images/logo.png")
# => <img src="/assets/images/logo-xyz.png" alt="Logo">

Underscored attribute names are hyphenated (data_turbo_track: becomes data-turbo-track). When an :asset_host is configured, tags that carry SRI hashes also get crossorigin="anonymous" so integrity checks pass on CDN fetches.

Missing keys raise BunBundle.MissingAssetError with a fuzzy-match suggestion when the typo is close enough.

HEEx components for Phoenix

If phoenix_live_view is available, BunBundle also compiles a BunBundle.Component module with HEEx-friendly components.

<BunBundle.Component.css href="css/app.css" />
<BunBundle.Component.js src="js/app.js" defer />
<BunBundle.Component.img src="images/logo.png" alt="Logo" width="128" />

Import the module in your html_helpers block to drop the prefix.

defp html_helpers do
quote do
import BunBundle.Component
# ...
end
end
<.css href="css/app.css" />
<.js src="js/app.js" defer />

Live reload

In development, render the reload tag in your layout to get CSS hot-reloading and full page reloads on asset changes:

<%= BunBundle.ReloadTag.tag() %>

It connects to Bun's WebSocket dev server, swaps fresh stylesheets in place, and reloads the page (preserving scroll position) for everything else. Outside the :dev environment it renders an empty string, so the call is safe to leave in a shared layout.

The environment defaults to Mix.env(). For releases or non-Phoenix boots, set it explicitly:

# config/dev.exs
config :bun_bundle, env: :dev

The WebSocket URL comes from the devServer key in config/bun.json (defaults to ws://127.0.0.1:3002).

Dev cache headers

For Plug-based apps, BunBundle.Plug.DevCache sets no-cache headers on asset responses in development, so the browser always fetches fresh files after a rebuild:

# In your endpoint, or a dev-only pipeline.
plug BunBundle.Plug.DevCache

Like the reload tag it only acts in :dev; elsewhere it is a pass-through. Un-fingerprinted CSS served in dev also gets a ?bust=<mtime> query from css_tag/2, keeping stylesheets fresh without touching fingerprinted or production URLs.

CDN prefix

Set an asset host to serve bundled assets from a CDN in production.

config :bun_bundle, asset_host: "https://cdn.example.com"

The host is prepended to every URL returned by asset/1 and the tag helpers.

Migrating from esbuild

If your Phoenix app was generated with the default esbuild (and optionally tailwind) setup, replace it with BunBundle in these steps.

  1. Remove the deps in mix.exs. Drop {:esbuild, ...} and {:tailwind, ...} if present.

  2. Remove the config blocks in config/config.exs. Delete the config :esbuild, ... and config :tailwind, ... blocks.

  3. Swap the watcher in config/dev.exs. Replace the esbuild watcher (and any tailwind watcher) with

    watchers: [
    bun: {BunBundle, :install_and_run, [~w(--dev)]}
    ]
  4. Swap the aliases in mix.exs.

    defp aliases do
    [
    "assets.setup": ["bun.install --if-missing"],
    "assets.build": ["bun"],
    "assets.deploy": ["bun --prod", "phx.digest"]
    ]
    end
  5. Create config/bun.json at the project root. Point outDir at priv/static/assets to match Phoenix conventions and list your entry points.

    {
    "entryPoints": {
    "js": ["assets/js/app.js"],
    "css": ["assets/css/app.css"]
    },
    "outDir": "priv/static/assets",
    "publicPath": "/assets",
    "manifestPath": "priv/static/bun-manifest.json"
    }
  6. Delete assets/tailwind.config.js if you were on tailwind. Move any global styles into your CSS entry point.

  7. Drop the NODE_PATH env from the old watcher. Bun resolves natively.

  8. Replace ~p"/assets/app.js" and similar with BunBundle.Component.js / .css (or the plain js_tag / css_tag helpers) so templates use fingerprinted URLs from the manifest. See Rendering asset tags above.

You can now drop phx.digest from assets.deploy since Bun's own fingerprinting is the source of truth for cache-busting.

Resolving Phoenix JS deps

Phoenix apps import JS deps as bare specifiers like import {LiveSocket} from "phoenix_live_view". Those packages live in deps/ and _build/, not node_modules/. Bun resolves them via the paths mapping in the generated assets/tsconfig.json:

{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"phoenix-colocated/*": ["../_build/dev/phoenix-colocated/*"],
"*": ["../deps/*"]
}
}
}

Keep this file. It also gives your editor autocomplete for Phoenix APIs.

Prefer bun install? Add an assets/package.json with file: entries pointing at the same directories:

{
"dependencies": {
"phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html",
"phoenix_live_view": "file:../deps/phoenix_live_view",
"phoenix-colocated": "file:../_build/dev/phoenix-colocated"
}
}

Then run bun install. This populates assets/node_modules with symlinks, which Bun and your editor both resolve natively. In a plain JS project you can delete assets/tsconfig.json entirely. If you use TypeScript, keep the tsconfig but drop the paths block.

Either approach works. The tsconfig path is lighter (no lockfile, no bun install step). The package.json path is more idiomatic Node.

Global options

Deploying with Docker

Install Bun, your JS dependencies, then run the build step:

RUN mix bun.install
ENV PATH="/root/.bun/bin:${PATH}"
COPY assets/package.json assets/bun.lock ./assets/
RUN cd assets && bun install --frozen-lockfile
COPY . .
RUN mix bun --prod

If you only use the tsconfig path mapping (no package.json), skip the bun install step entirely. Bun resolves Phoenix deps via tsconfig.json without a lockfile.

Prior art

This setup has been used in production in a mission-critical app since March 2026. Our deployment process sped up significantly, and we haven't had any issues since.

Contributing

Setup

git clone https://codeberg.org/w0u7/bun_bundle.git
cd bun_bundle
mix deps.get

Running tests

mix test # Elixir tests (auto-installs Bun if missing)
mix bun.test # JS plugin tests (run via Bun)

Linting

mix credo # static analysis
mix format --check-formatted # format check

Commit conventions

We use conventional commits.

License

MIT