Alright, let’s talk about making your JavaScript not suck. You’ve got a pile of modern JS—maybe some TypeScript, ES6 modules, fancy dependencies—and you need to serve it to a browser that still probably thinks let is a typo. This is where js.Build and its engine, esbuild, come in. Think of js.Build as Hugo’s direct line to one of the fastest, most no-nonsense bundlers on the planet. It doesn’t mess around with a million plugins; it just gets the job done, brutally efficiently.

The Basic Incantation

You’ll primarily use js.Build via Hugo Pipes in your templates. The most basic usage grabs a file from your assets directory and processes it.

{{ with resources.Get "js/main.js" }}
  {{ with . | js.Build }}
    <script src="{{ .RelPermalink }}" defer></script>
  {{ end }}
{{ end }}

This is the hello-world. We grab assets/js/main.js, pipe it to js.Build, and spit out a script tag. The defer is just you being a responsible web citizen, preventing render-blocking. The key thing to understand here is that resources.Get fetches from your assets directory, not your static directory. Stuff in static is copied verbatim; assets is for things you want to process.

Why esbuild is a Game-Changer

Hugo could have chosen Webpack, Parcel, or Rollup. It chose esbuild because the author of Hugo, Bjørn Erik Pedersen, isn’t a masochist. esbuild is written in Go (just like Hugo), which means it’s compiled to native code and avoids the drag of a Node.js process startup. The result is near-instantaneous bundling. You save a file, and by the time you’ve alt-tabbed to your browser, the new bundle is already there. This speed is what makes doing this on-the-fly during development actually feasible. It’s not just “fast”; it’s “blink and you’ll miss it” fast.

Targeting Specific Browsers (The Why)

You can’t just transpile everything down to ES5 anymore; it’s bloated and slow. You need a strategy. js.Build lets you target a specific set of browsers, and it uses the same sensible, industry-standard esbuild targets as its engine.

{{ $opts := dict "target" "es2018" }}
{{ with resources.Get "js/main.js" | js.Build $opts }}
  <script src="{{ .RelPermalink }}" defer></script>
{{ end }}

Why es2018? Because it gives you a great balance of modern features without leaving recent versions of Chrome, Firefox, and Safari in the dust. You get modern, compact code without the absurd overhead of transpiling every single async function into a state machine. Check the esbuild docs on target for the exact syntax. You can specify individual browser versions (["chrome58", "edge16"]) or a standard ES version like I did here. Be honest about your audience. If you’re building a dashboard for a hospital running IE11 on Windows 7… well, my condolences. You’ll need a different approach entirely.

Minification is a No-Brainer

This one’s easy. Minification shrinks your file size by removing comments, whitespace, and shortening variable names. In development, you skip it so you can debug. In production, you absolutely use it.

{{ $opts := dict "minify" true }}
{{ with resources.Get "js/main.js" | js.Build $opts }}
  <script src="{{ .RelPermalink }}" defer></script>
{{ end }}

To make this environment-aware, hook it into Hugo’s environment variables:

{{ $opts := dict "minify" (eq hugo.Environment "production") }}

The Magic of Source Maps (For God’s Sake, Use Them)

Trying to debug code in the browser when it’s been bundled and minified is like performing surgery with oven mitts on. Source maps solve this. They tell the browser how the transformed code maps back to your original source files.

{{ $opts := dict "sourceMap" "inline" }}
{{ with resources.Get "js/main.js" | js.Build $opts }}
  <script src="{{ .RelPermalink }}" defer></script>
{{ end }}

The "inline" option embeds the source map right into the output .js file. It’s slightly larger but simpler. For production, you might want "external" to generate a separate .js.map file. Just remember to not ship source maps to your production server if you’d rather not show the world your unminified code. Use Hugo’s environment check again: dict "sourceMap" (ne hugo.Environment "production") "inline" would turn it off for production.

External Dependencies and node_modules

Here’s where the designers made a… choice. A questionable one. By default, js.Build does not automatically traverse into your node_modules folder to resolve imports. This breaks most modern JS setups immediately. To fix it, you have to be explicit.

{{ $opts := dict "target" "es2018" "minify" true }}
{{ $built := resources.Get "js/main.js" | js.Build $opts }}
{{ with $built }}
  <script src="{{ .RelPermalink }}" defer></script>
{{ end }}

If main.js has import Alpine from 'alpinejs';, the above will fail spectacularly. You must provide the path to node_modules:

{{ $opts := dict "target" "es2018" "minify" true }}
// This is the crucial part
{{ $opts = dict "target" "es2018" "minify" true "defer" true }}
// The even more crucial part: pass the path
{{ $built := resources.Get "js/main.js" | js.Build (merge $opts (dict "params" (dict "nodeModules" "node_modules"))) }}
{{ with $built }}
  <script src="{{ .RelPermalink }}" defer></script>
{{ end }}

Yes, it’s clunky. It feels like a workaround because it is. You’re passing the path to node_modules as a parameter, which esbuild then uses to resolve those import statements. It works, but it’s the one part of this process that feels like it was bolted on as an afterthought.

The Gotchas and Pitfalls

  1. Caching and Fingerprinting: In production, you need to bust the browser cache when you update your JS. Always use .Permalink or .RelPermalink for the final output in your script tag. Hugo automatically appends a fingerprint (a hash of the content) to the filename when you use the Permalink, ensuring users always get the latest version.
  2. Imports Are Relative to Your File: When you import one of your own files (import { myFunc } from './lib/helpers.js'), the path is relative to the JavaScript file you’re importing from, not your project root. This trips everyone up once.
  3. It’s Not Webpack: esbuild is intentionally lean. It doesn’t have a built-in dev server with Hot Module Replacement (HMR). Hugo’s live reload is your HMR. It also doesn’t handle every possible asset (like CSS within JS) by default. For that, you’d need to configure esbuild’s loaders, which is possible but more advanced.

So, there you have it. js.Build is your fast lane to modern JavaScript in Hugo. It’s pragmatic, incredibly fast, and gets you 95% of the way there with 5% of the configuration headache of other bundlers. Just remember the node_modules quirk, and you’ll be golden.