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.
Vogelaar Solutions helps organizations with DevOps, platform engineering, and web development. Contact us for a consultation without obligation.