22.6 Creating a Reusable Hugo Module (Theme Component)
Right, so you’ve decided to build a Hugo theme component. Excellent choice. This is how we stop copying and pasting the same header.html partial across seventeen different projects and finally get paid for our good taste. A theme component is just a Hugo Module that lives in its own repository and gets pulled into your main project as a dependency. It’s like giving your best layouts their own apartment instead of letting them crash on your couch indefinitely.
The magic here, and the reason this chapter exists, is that Hugo ditched the old, slightly clunky git clone method and fully embraced Go Modules for dependency management. This is a good thing, I promise. It gives us versioning, immutability, and a sane way to handle transitive dependencies. It also means we have to speak a little Go, but don’t worry, we’ll keep it to the absolute minimum required to not blow up our site.
The Absolute Necessities: go.mod
Every Hugo project, and by extension every theme component, must have a go.mod file at its root. This file is the manifest, the contract, the recipe for your module. If you’re starting a new theme component from scratch, your first terminal command is this:
hugo mod init github.com/your-username/your-theme-name
This does a few key things. It creates the go.mod file, and it sets the module path. This path isn’t just a name; it’s the full URL from which Go (and Hugo) will eventually fetch your module. So make it the actual GitHub (or GitLab, etc.) path. Don’t be cute and call it my-awesome-theme because nothing will be able to find it later.
Your fresh go.mod will look beautifully sparse:
module github.com/your-username/your-theme-name
go 1.21
That’s it. The go directive specifies a minimum version of Go that the module is supposed to be compatible with. Hugo itself will usually dictate what version it expects, so just roll with whatever hugo mod init sets for you. It’s not something you’ll need to fuss with often.
The Anatomy of a Theme Component
Now, what actually goes into this module? Structure it exactly like a Hugo project directory, but only include the bits you want to be reusable. This isn’t a full site; it’s a collection of components.
your-theme-name/
├── go.mod
├── layouts/
│ ├── partials/
│ │ ├── header.html
│ │ └── footer.html
│ └── index.html
├── assets/
│ └── css/
│ └── main.css
└── archetypes/
└── default.md
The key directories are layouts, assets, static, archetypes, and i18n. Hugo’s magic is that when it pulls in your module, it will overlay these directories on top of the main project’s structure. Your layouts/index.html will be available to the main project just as if it lived in its own layouts folder. This is called directory merging, and it’s both powerful and a common source of head-scratching. If the main project has its own layouts/index.html, that one wins. The hierarchy is: Project > Theme Components > Base Theme. It’s a cascade.
The Main Event: Using Your Component
Now, for the main project to use your brilliant creation, you need to tell Hugo about it. In the main project’s config.yaml (or toml, or json—I don’t judge), you define it in the module section:
module:
imports:
- path: github.com/your-username/your-theme-name
But here’s the first “questionable choice” we get to call out: by default, Hugo will try to go get the latest published version of your module from the remote repo. This is terrible for local development. You are not going to commit and push every single tweak to see if it works. That way lies madness.
The escape hatch is the replace directive in the main project’s go.mod file. This is our best friend.
# In your main project's directory:
hugo mod init github.com/your-username/main-project
Then, crack open its go.mod and add a replace line pointing to your local filesystem:
module github.com/your-username/main-project
go 1.21
replace github.com/your-username/your-theme-name => ../path/to/your-local-theme-name
This tells Go (and Hugo): “Whenever anything in this project asks for that module, don’t go to the internet; use this local directory instead.” You can now develop your theme and see changes reflected in the main project in real-time. It’s beautiful. Remember to never commit this replace directive to your main project’s repository. It’s strictly a local development tool.
The Pitfall: Versioning and The v0 Trap
You’ll eventually want to release a version. This is where Go Modules get serious. You create a tag in your git repository. The tag must be a semantic version, and it must be prefixed with a ‘v’.
git tag -a v0.2.0 -m "Release version 0.2.0"
git push origin v0.2.0
Now, in your main project’s config, you can pin to that specific version, which is a fantastic idea for production.
module:
imports:
- path: github.com/your-username/your-theme-name
version: v0.2.0
Here’s the critical gotcha, the one that has caused more confused Slack messages than any other: If your module version is v0. or v1.*, Go Modules will happily use the latest commit on the default branch if no specific version is requested*, even if there are newer tagged versions. This is by design in the Go world, but it feels utterly insane for a Hugo site that you want to be stable. The solution is simple: always specify a version in your production config. Never leave it blank. v0 versions are considered “unstable,” so the tooling gives you enough rope to hang yourself. Consider this your warning.
So, to sum up: create a go.mod, structure your files like a Hugo project, use replace for local dev, and tag your versions properly to avoid the dependency hell that the Go team somehow decided was acceptable behavior. It’s a bit more ceremony than a simple git submodule, but the explicit versioning and proper dependency resolution are worth the hassle. Now go build something reusable.