21.5 JavaScript: Bundling with esbuild via Hugo Pipes
Right, so you’ve decided to build a Hugo theme. Good for you. You’ve got your HTML templated, your CSS is piping hot, and now you need some actual, functioning JavaScript. You could just slap a <script src="main.js"> in your head and call it a day, but then you’d be serving a massive, un-minified, ES6+ mess to every browser, including a Nokia phone running Opera Mini. We’re better than that.
Enter Hugo Pipes and esbuild. This is where Hugo stops being a simple static site generator and starts feeling like a full-fledged build tool. The beauty of it is that you don’t need a separate package.json, node_modules, or a sprawling Webpack configuration that requires a blood sacrifice to maintain. Hugo handles it all internally, and it’s brilliantly fast.
The Basic Setup: Your First Bundle
First, you need to get your JS source files in the right place. Hugo Pipes only processes files within your assets directory. So, toss your modern, potentially modular, JavaScript in there. Let’s say assets/js/main.js.
To process and include this in your templates, you use the resources function. Here’s how you’d do it in your baseof.html, right before the closing </body> tag:
<!-- layouts/_default/baseof.html -->
<body>
{{ block "main" . }}{{ end }}
{{ $js := resources.Get "js/main.js" | js.Build | minify | fingerprint }}
<script src="{{ $js.RelPermalink }}" integrity="{{ $js.Data.Integrity }}" defer></script>
</body>
</html>
Let’s break down that chain of pipe-delivered glory:
resources.Get "js/main.js"fetches the file from your assets.| js.Buildpasses it to Hugo’s internal esbuild to bundle it. This is the key move. If yourmain.jsimports other modules, esbuild will find them, bundle them together, and output a single file.| minifydoes what it says on the tin: crunches the bundled output into a single, horrifying-to-read-but-tiny-to-download line.| fingerprintis the final touch of professionalism. It appends a unique hash to the filename (e.g.,main.min.abc123.js). This is for cache busting. You update your JS, the hash changes, and every user’s browser instantly gets the new file instead of clinging to the old, cached version. Theintegrityattribute adds Subresource Integrity (SRI), which is a nice security touch.
Configuration: Talking to esbuild
Sometimes, your brilliant JavaScript uses things that browsers haven’t quite agreed on yet. You might need to set a build target or pass esbuild some specific flags. This is where you create a hugo.toml (or config.toml) file.
Let’s say you’re using the logical assignment operator (||=) and want to make sure esbuild doesn’t transpile it away for older browsers. You can set the target. You can also define custom entry points for larger projects.
// assets/js/components/super-calculator.js
export function calculate() {
// ... some brilliant code ...
}
// assets/js/main.js
import { calculate } from './components/super-calculator.js';
// Init your app
# hugo.toml
[build]
[[build.targets]]
GOOS = 'linux'
GOARCH = 'amd64'
[js]
target = 'es2020'
The [js] section in your config is how you talk to esbuild. The target is the most important option. Set it to the ECMAScript version you want to target. es2020 is a safe bet for modern browsers.
The Power of esbuild: Why This Rules
The reason this setup is so much better than a separate Node build script is integration. Hugo is aware of the bundled output. When you’re running hugo server, it doesn’t just watch your content and layouts for changes; it also watches your JS and CSS assets. Change a single character in a deeply-nested JavaScript module, and Hugo will instantly re-run esbuild and live-reload the page. The feedback loop is tight, and you avoid the mental context switch of managing two separate build processes.
Common Pitfalls and How to Avoid Them
The Path Trap: The most common headache.
resources.Getlooks in theassetsdirectory. Your import statements inside.jsfiles are relative to the file doing the importing. If you get a “module not found” error, check your case and your paths. Linux servers are case-sensitive, and your Mac development environment probably isn’t../Components/File.jsis not the same as./components/file.js.Where’s My Bundle?: Remember, the bundled file is generated in Hugo’s resource cache (usually in
/publicor a temporary directory when serving). You don’t check it into git. You do need to make sure your deployment process runshugo(nothugo server) to build the site, which will then generate the final, processed JS file.External Libraries: “But how do I use npm packages?” I hear you cry. This is the one rough edge. Hugo Pipes can’t directly pull from
node_modules. The official workaround is… pragmatic. You vendor the specific library files you need. Copy the minified ES module (e.g.,node_modules/alpinejs/dist/cdn.min.js) into yourassets/js/vendor/directory and import it from there. It’s not elegant, but it works and keeps the build process simple and contained. For massive dependencies, you might still want a separate build process, but for 95% of projects, this is more than enough.