Hugo Modules: Replacing Git Submodules with Versioned Theme Composition
Hugo Modules replace Git submodules with Go-module semantics for versioned, composable theme dependencies. This post walks through migration, mount ordering, asset pipelines, and the trade-offs you'll hit in practice.
30 Nov 2025, 15:02 UTC

The problem with Git submodules for Hugo themes
If you've maintained a Hugo site for more than a few months, you've likely wrestled with Git submodules. A theme lives in themes/ananke as a submodule pinned to a commit hash. Upgrading means git submodule update --remote, hoping the theme author tagged releases, then debugging why your custom layouts broke. Sharing a customized theme across projects? Fork the repo, maintain the fork, repeat. There's no versioning, no dependency graph, and no way to compose multiple themes without copy-pasting files.
Hugo Modules (introduced in v0.56) solves this by treating themes as Go modules. You declare dependencies in config/_default/module.toml, fetch specific versions with hugo mod get, and compose multiple modules where later imports override earlier ones — all without a central registry or external package manager.
How module imports compose file trees
Each module contributes a virtual filesystem: layouts/, static/, assets/, i18n/, archetypes/. When you list multiple imports, Hugo merges them in order. The last import wins on conflicts, which means you can layer a base theme, a component library, and your site-specific overrides without forking anything.
# config/_default/module.toml
module:
imports:
- path: github.com/gohugoio/hugoBasicExample # base theme
- path: github.com/user/components@v1.2.0 # shared components
- path: github.com/user/my-theme@v2.0.0 # your customizations
Run hugo mod tidy and Hugo resolves versions, downloads modules to the Go module cache ($GOMODCACHE), and builds the merged mount table. Inspect it with hugo config mounts — you'll see each module's directories mapped into the virtual project tree.
Worked example: migrating a submodule theme to a module
Suppose your site uses the Ananke theme as a Git submodule at themes/ananke. The theme repo tags releases (e.g., v3.0.0). Here's the migration:
- Remove the submodule:
git submodule deinit themes/ananke && git rm themes/ananke && rm -rf .git/modules/themes/ananke - Initialize your site as a Go module (run at site root):
hugo mod init github.com/youruser/yoursite - Add the theme as a module import:
echo -e 'module:\n imports:\n - path: github.com/theNewDynamic/gohugo-theme-ananke@v3.0.0' >> config/_default/module.toml - Resolve and download:
hugo mod tidy - Verify the mount merge:
hugo config mounts | grep -A2 ananke - Build:
hugo --minify
Your custom layouts in layouts/ still override the theme because the project's own filesystem is the final layer in the mount order. No fork required.
Asset pipelines work across module boundaries
Hugo Pipes processes assets from modules transparently. If a shared component module provides assets/scss/_variables.scss and assets/js/lib.js, your site's assets/scss/main.scss can @import "variables" and resources.Get "js/lib.js" — the build sees them as local files. Fingerprinting (resources.Fingerprint) and cache-busting work identically.
This enables genuine component libraries: a team maintains github.com/org/design-system@v1.4.0 with SCSS partials, JS utilities, and shortcodes. Projects import it, extend it, and upgrade with hugo mod get -u github.com/org/design-system@v1.5.0.
Trade-offs and limitations
- Mount ordering is global. A later import can unintentionally override a partial from an earlier module. Mitigate by namespacing inside modules:
partials/vendor/foo.htmlinstead ofpartials/foo.html. - Private repositories need auth. Set
GOPRIVATE=github.com/yourorg/*and provide credentials (SSH deploy key or token) in CI/CD. The Go module proxy (proxy.golang.org) cannot fetch private repos. - No transitive
replacesupport. If a dependency'sgo.modhas areplacedirective, it won't apply in your build. Declare all overrides in your owngo.mod. - Windows path length limits. Deep module caches can hit the 260-char limit. Enable long paths (
git config --system core.longpaths true) or setGOMODCACHE=C:\go-mod-cache. - Minimum version selection. Go's MVS can pull older transitive versions than expected. Run
hugo mod get -uperiodically to refresh.
Verify your setup
After any module change, run this checklist:
# 1. Show resolved dependency graph
hugo mod graph | head -30
# 2. Verify checksums match go.sum
hugo mod verify
# 3. Confirm mount order matches intent
hugo config mounts | grep -E '^(target|source)'
# 4. Clean build
hugo --minify --gc
If hugo mod verify fails, someone tampered with a module or go.sum is out of sync — re-run hugo mod tidy to regenerate.
Next steps
Start small: convert one submodule theme to a module import. Tag the theme repo if you control it, or use a pseudo-version (github.com/user/theme@v0.0.0-20240115120000-abc1234) if you don't. Once comfortable, extract shared partials and assets into a private component module. Your sites become thinner, upgrades become explicit, and the "which commit is this theme on?" question disappears.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.