Hugo: How to integrate Stimulus with an import map

By Pieter Vogelaar 7 March 2026

Learn how to integrate Stimulus with Hugo to add modern JavaScript to your static site. This guide will use an import map and asset pipeline setup. No build tools are required (like Webpack), which makes it very nice and fast to work with.

While heavy JavaScript frameworks feel like overkill for a static generator, Stimulus offers a HTML-first approach that aligns perfectly with Hugo’s philosophy. It doesn’t take over your entire frontend, you simply add data-controller attributes to your existing HTML.

Stimulus is used by default in the Ruby on Rails framework, but it can be perfectly used as lean and mean standalone JavaScript framework with Hugo.

This guide assumes that you can customize your own theme. If you use a standard available theme and don’t want to modify the theme itself, you can probably use the theme overrides that Hugo has available by default. All the example paths are relative to the theme directory, like themes/my-theme.

We have the following directory structure (only relevant files):

themes/
  my-theme/
    assets/
      js/
        controllers/
          application.js
          contact_form_controller.js
        utilities/                        # optional
          index.js                        # optional
        application.js
      vendor/
        js/
          @hotwired--stimulus.js
          @popperjs--core.js              # optional
          bootstrap.esm.min.js            # optional
    layouts/
      partials/
        headers.html
        importmap.html
    static/
      bootstrap.esm.min.js.map            # optional

The Bootstrap framework files are included to provide a more complete example on how to work with ESM (JavaScript modules). The bootstrap esm file can be taken from the source files ZIP. Bootstrap also requires @popperjs/core. But of course, this whole Bootstrap framework is optional.

Hugo asset pipeline

When hugo build is executed for building the website, files from the static folder are copied as-is to the public folder. Hugo provides an asset pipeline for the assets folder. A file like assets/js/application.js will become the file js/application.b9cd4be4fcb1aad651fcbc2e41c7a7edc940ba726286c460f6ab072f368e76af.min.js in the public folder. A sha256 fingerprint is made of the content of the file and that hash becomes part of the file name. This is great for caching. It can be cached indefinitly and if the content changes the hash changes. So the user will always get the most recent version and won’t have caching issues.

Import map

In the layout file that describes the <head> tag, for example layouts/partials/headers.html add:

{{ partial "importmap.html" . }}

You may be used to putting <script> tags just above the </body> tag. However ES Modules are loaded deferred by default, so there is no need to worry about blocked HTML parsing.

Create layouts/partials/importmap.html with the following content:

{{- $imports := dict -}}
{{- $vendorJS := resources.Match "vendor/js/**.js" -}}
{{- $customJS := resources.Match "js/**.js" -}}
{{- $allJS := $vendorJS | append $customJS -}}

{{- range $allJS -}}
  {{- $file := . | fingerprint -}}

  {{- if not (strings.HasPrefix .Name "/vendor/") -}}
    {{- $file = $file | minify -}}
  {{- end -}}

  {{- $key := .Name |
      strings.TrimPrefix "/vendor/js/" |
      strings.TrimPrefix "/js/" |
      strings.TrimSuffix ".esm.min.js" |
      strings.TrimSuffix ".min.js" |
      strings.TrimSuffix ".js" |
      strings.TrimSuffix "/index" |
      replaceRE "--" "/"
  -}}

  {{- $imports = merge $imports (dict $key $file.RelPermalink) -}}
{{- end -}}

{{- $importMap := dict "imports" $imports -}}

<script type="importmap">
{{ $importMap | jsonify (dict "indent" "  ") }}
</script>

<script type="module">import 'application'</script>

All the js file paths are discovered (recursively) in the assets/vendor/js and assets/js folder. The result will be an import map like:

<script type="importmap">
{
  "imports": {
    "@hotwired/stimulus": "/vendor/js/@hotwired--stimulus.d6c1c73b686d843e46e47599e038ee4deacfed039aa787a640b73a16dbf0a27b.js",
    "@popperjs/core": "/vendor/js/@popperjs--core.d1913da117b5d1e240698b51e32666f99e97bcb496aa2675f12b71ce733725d8.js",
    "application": "/js/application.b9cd4be4fcb1aad651fcbc2e41c7a7edc940ba726286c460f6ab072f368e76af.min.js",
    "bootstrap": "/vendor/js/bootstrap.esm.min.f9280841ae00ff8c36baa9e829833dd9fc893c70d67b63c552f601a88fdfbc81.js",
    "controllers/application": "/js/controllers/application.470f468ab36d303fd4bf350f951bbd30b8a2b02e49617252bc1631dd64a432c6.min.js",
    "controllers/contact_form_controller": "/js/controllers/contact_form_controller.bb95cadaae65a9257549aab6f90885241150e84a7b28b70c1934b09cec044873.min.js",
    "controllers/user/profile_controller": "/js/controllers/user/profile_controller.2a8966c2d475172588b56f935ef271c489f673e95cacff3f1a1476d1010d3791.min.js",
    "utilities": "/js/utilities/index.0b732d11c184e9b16c037a5f8a4376c1497332df644b254b79a810ece8dce65a.min.js"
  }
}
</script>

If a module imports another module, for example with import 'utilities', the browser will know that it needs to load /js/utilities/index.0b732d11c184e9b16c037a5f8a4376c1497332df644b254b79a810ece8dce65a.min.js.

Our application is started with:

<script type="module">import 'application'</script>

This will load:

"application": "/js/application.b9cd4be4fcb1aad651fcbc2e41c7a7edc940ba726286c460f6ab072f368e76af.min.js",

The content of assets/js/application.js:

// Main application

// Stimulus import
import 'controllers/application';

// Other imports

This is the main application file and imports the controllers/application module which starts Stimulus.

Starting Stimulus

The content of assets/js/controllers/application.js:

import { Application } from '@hotwired/stimulus';

const application = Application.start();
const importMapElement = document.querySelector('script[type="importmap"]');

if (importMapElement) {
  // Get the import map by parsing the raw JSON string from the element
  const importMap = JSON.parse(importMapElement.textContent);

  if ('imports' in importMap) {
    Object.entries(importMap.imports).forEach(([key, url]) => {
      if (key.startsWith('controllers/') && key != 'controllers/application') {
        // Derive the Stimulus identifier, e.g.:
        // "controllers/contact_form_controller" -> "contact-form"
        // "controllers/user/profile_controller" -> "user--profile"
        const identifier = key.replace(/^controllers\//, '')
          .replace(/_controller$/, '')
          .replace(/\//g, '--')
          .replace(/_/g, '-');

        // Eager load the controller
        import(key).then(module => {
          application.register(identifier, module.default);
        }).catch(error => {
          console.error(`Failed to register controller: ${identifier}`, error);
        });
      }
    });
  }
}

// Configure Stimulus development experience
application.debug = false;
window.Stimulus = application;

export { application };

This code will (eagerly) load all Stimulus controllers and registers them with Stimulus. This loads very fast, 100 controller files are no problem at all, especially with HTTP/2 multiplexing. A mix is also possible, eagerly load the most general important controllers and lazy load the rest. A MutationObserver is required for that, if an element is added to the DOM that contains a data-controller attribute, that controller must be imported. But that is out of the scope for this guide.

Using Stimulus

Stimilus reacts if it finds DOM elements with a data-controller or data-action attribute. Read more about it in the official documentation.

<form method="post" data-controller="contact-form" data-action="contact-form#send">
  <button type="submit" class="btn btn-primary">Send message</button>
</form>

The content of assets/js/controllers/contact_form_controller.js:

import { Controller } from '@hotwired/stimulus'
import { Utilities } from 'utilities'

export default class extends Controller {
  send(event) {
    event.preventDefault();
    this.element.querySelector('button[type="submit"]').disabled = true;

    Utilities.successMessage('The message is sent successfully');
  }
}

Note that you never import with something like import { Utilities } from '../utilities/index.js'. The exact import from name must be found in the import map.

The content of assets/js/utilities/index.js:

import { Toast } from 'bootstrap';

export class Utilities {
  static successMessage(text) {
    const myToastEl = document.getElementById('myToastEl')
    const myToast = Toast.getOrCreateInstance(myToastEl)
    myToast.show();
  }
}

Conclusion

Integrating Stimulus with an import map is awesome for Hugo projects. It respects the static nature of the tool while giving you a clean, organized way to manage your scripts. To recap:

  • No asset build tools like Webpack are required: Everything is handled by Hugo and the browser.
  • Automatic fingerprinting: Hugo ensures your users always have the latest code.
  • Clean structure: Your JavaScript remains modular and easy to maintain.
Are you looking for a partner with this expertise for your project?
Vogelaar Solutions helps organizations with DevOps, platform engineering, and web development. Contact us for a consultation without obligation.