Skip to content

Using esbuild in Eleventy

Another Eleventy recipe, this time for bundling JavaScript, CSS, and other asset types with esbuild. You can adapt it to how you prefer your markup, or to other bundlers such as Vite. I’ve also used this approach to produce hashed front-end assets for WordPress and Kirby.

In Eleventy, you’ll be able to reference a JavaScript source file in your Nunjucks template and have the src attribute point to a browser-ready, content-hashed bundle:

<!-- From this… -->
<script type='module' src='{{ "js/widget.js" | bundle }}'></script>

<!-- …to this -->
<script type='module' src='/dist/widget.HSJDN132X.js'></script>

For this, we’ll tap into esbuild’s ability to produce as part of the build process a JSON metafile that maps source files to their destinations. The relevant part in the metafile is the outputs property:

{
	outputs: {
		'static/dist/widget.HSJDN132X.js': {
			entryPoint: 'static/js/widget.js'
			cssBundle: 'static/css/widget.css'
		}
	}
}

Assuming assets for our project live in a passthrough static/ directory, and esbuild puts the bundles in the static/dist/ directory, here’s an implementation for the async bundle filter:

import { join, relative } from 'node:path';
import esbuild from 'esbuild';

// .eleventy.js
export default function(config) {

	const STATIC_DIR = 'static';
	config.addPassthroughCopy({ [STATIC_DIR]: '/' });

	let buildCache = {};
	config.addAsyncFilter('bundle', async infile => {
		buildCache[infile] ??= new Promise(async resolve => {
			const entryPoint = join(STATIC_DIR, infile);
			const { metafile } = await esbuild.build({
				entryPoints: [entryPoint],
				outdir: join(STATIC_DIR, 'dist'),
				bundle: true,
				format: 'esm',
				entryNames: '[name].[hash]',
				assetNames: '[name].[hash]',
				metafile: true
			});
			const entry = Object.entries(metafile.outputs)
				.find(it => it[1].entryPoint === entryPoint);
			const outfile = '/' + relative(STATIC_DIR, entry[0]);
			resolve(outfile);
		});
		return await buildCache[infile];
	});
}

Let’s unpack how the bundle filter works.

It implements a basic cache so that repeated calls to build the same entry point don’t result in duplicate work:

let buildCache = {};
config.addAsyncFilter('bundle', async infile => {
	buildCache[infile] ??= new Promise(async resolve => {
		const result = await doTheWork();
		resolve(result);
	};
	return await buildCache[infile];
});

The work being an asynchronous build which, by virtue of the metafile: true option, returns a metafile property:

const entryPoint = join(STATIC_DIR, infile);
const { metafile } = await esbuild.build({
	entryPoints: [entryPoint],
	outdir: join(STATIC_DIR, 'dist'),
	bundle: true,
	format: 'esm',
	entryNames: '[name].[hash]',
	assetNames: '[name].[hash]',
	metafile: true
});

Finally, in the metafile, we locate the key corresponding to our entry point and make sure to format its output path appropriately. Node.js’s path module is more robust than string concatenation here, as join() and resolve() will sort out trailing slashes and segments like ./ and ../ in our entry point:

const entry = Object.entries(metafile.outputs).find(
	it => it[1].entryPoint === entryPoint
);
const outfile = '/' + relative(STATIC_DIR, entry[0]);
resolve(outfile);

This works out of the box for JavaScript and CSS entrypoints. Furthermore, whenever a JavaScript entrypoint imports CSS styles, esbuild includes a handy cssBundle property in the corresponding outputs object, so we can return from the bundle filter the paths for both the JS entrypoint and its CSS dependency:

const url = '/' + relative(STATIC_DIR, entry[0]);
const css = entry[1].cssBundle && ('/' + relative(STATIC_DIR, entry[1].cssBundle));
resolve({ url, css });

…to use in templates accordingly:

<link 
	rel='stylesheet' 
	type='text/css' 
	href='{{ ('js/widget.js' | bundle).css }}'
>

<script 
	type='module' 
	src='{{ ('js/widget.js' | bundle).url }}'
></script>

Note that for other types of imported content, you’ll have to specify the appropriate loader depending on how you want them handled:

await esbuild.build({
	// …
	loader: { 
		// Images
		'.png': 'file',
		'.jpg': 'file',
		'.svg': 'file',

		// Fonts
		'.woff': 'file',
		'.woff2': 'file',
		
		// etc.
	}
});

Handling updates. To make the technique work with Eleventy’s watch/serve mode, there are a couple of final tweaks to be made: we must trigger a rebuild whenever our source assets change using addWatchTarget(), and to clear the build cache to make room for the refreshed results.

config.addWatchTarget('static/!(dist/**/*)');
config.on('eleventy.beforeWatch', changedFiles => {
	buildCache = {};
});

The static/!(dist/**/*) glob pattern is an attempt to watch all files under the static/ directory, excluding the static/dist/ subtree, which prevents an infinite loop. I’ve tried a bunch of things, and this is the pattern that seems to do the trick for Eleventy’s underlying picomatch glob library.