BunBundle
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?
- Lightning fast. Bun's native bundler builds assets in milliseconds.
- CSS hot-reloading. Instant changes without a full page refresh.
- Asset fingerprinting. Fast, content-based file hashing.
- Subresource Integrity. Optional SRI digests for production deploys.
- No surprises in production. Dev and prod go through the same pipeline.
- Extensible. Plugins are simple, tiny JavaScript files.
- One dependency: Bun. Everything is included, no other dev dependencies.
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:
root: absolute path to the project rootconfig: the resolvedbun.jsonconfiguration objectdev:truewhen running in development modeprod:truewhen--prodwas passed (shortcut flag)fingerprint:truewhen asset filenames will be content-hashedminify:truewhen output will be minified (use this to strip comments, banners, or dev-only branches in your plugin)sourcemap: the sourcemap kind being produced (ornullfor default)manifest: the current asset manifest object
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
--dev: dev mode, watches files, starts the live reload server, and uses inline sourcemaps.--prod: shortcut for--fingerprint --minify.--fingerprint: hash asset filenames for cache busting.--minify: minify JS and CSS output.--sourcemap[=KIND]:inline,linked,external, ornone. Defaults toinlinein--devandlinkedfor builds, so production stack traces and browser devtools stay debuggable. Pass--sourcemap=nonewhen you explicitly do not want maps shipped.--sri[=ALGOS]: compute Subresource Integrity digests for each asset. Pass a comma-separated list ofsha256,sha384, orsha512(bare--sridefaults tosha384). When digests are present,js_tag/2andcss_tag/2automatically renderintegrity="..." crossorigin="anonymous"so browsers verify the response before executing it.--debug: verbose WebSocket logging.
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.
Remove the deps in
mix.exs. Drop{:esbuild, ...}and{:tailwind, ...}if present.Remove the config blocks in
config/config.exs. Delete theconfig :esbuild, ...andconfig :tailwind, ...blocks.Swap the watcher in
config/dev.exs. Replace the esbuild watcher (and any tailwind watcher) withwatchers: [bun: {BunBundle, :install_and_run, [~w(--dev)]}]Swap the aliases in
mix.exs.defp aliases do["assets.setup": ["bun.install --if-missing"],"assets.build": ["bun"],"assets.deploy": ["bun --prod", "phx.digest"]]endCreate
config/bun.jsonat the project root. PointoutDiratpriv/static/assetsto 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"}Delete
assets/tailwind.config.jsif you were on tailwind. Move any global styles into your CSS entry point.Drop the
NODE_PATHenv from the old watcher. Bun resolves natively.Replace
~p"/assets/app.js"and similar withBunBundle.Component.js/.css(or the plainjs_tag/css_taghelpers) 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
:version(required). The expected Bun version.:version_check(defaulttrue). Warn on version drift at boot.:path(defaultnil). Override the path to the Bun binary.:env(defaultMix.env()when available, else:prod). Gates live reload, CSS cache busting, and the dev cache plug.:asset_host(default""). CDN prefix prepended to asset URLs.:root(default current working directory). Project root used to locateconfig/bun.json, the manifest, and the output directory.
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
- Lucky Framework. This setup was originally created for Lucky to replace the old Laravel Mix implementation.
- bun_bun_bundle. Ruby gem. A port of the Lucky implementation and the reference and source of the vendored JS bundler in this repo.
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