Sharing a Theme Across Hugo Sites With Modules

Organisations with several Hugo sites — a product site, docs, a blog, a status page, a few campaign microsites — usually start by copying the theme into each. Within a year the copies have drifted: a header fix applied to two of five sites, an accessibility change made in the blog but not the docs, three slightly different versions of the footer. Every fix costs five pull requests and a careful diff, so fixes stop being propagated at all.

Hugo Modules solve this by turning the theme into a versioned dependency. Each site imports it by path and version, overrides only what it must, and receives updates through the same dependency tooling as everything else. This guide converts a theme shared by five sites into a module, sets up overrides, versioning and automated updates, and measures the effect on build time and on how quickly fixes reach every site. It belongs to Hugo Build Times for Large Repositories; the caching of modules in CI is covered in Hugo Modules and Dependency Caching.

Prerequisites

  • Hugo 0.120 or later extended, and Go 1.21 or later on developer machines and CI.
  • The theme in its own git repository (it was a folder in one of the sites; it moved to github.com/acme/hugo-theme).
  • An inventory of how each site's theme copy differs from the others.

Step 1: Find What Drifted

Before extracting the theme, the five copies were compared file by file:

for s in www docs blog status events; do (cd $s/themes/acme && find . -type f | sort | xargs md5sum) > /tmp/$s.sums; done
cat /tmp/*.sums | awk '{print $2}' | sort | uniq -c | sort -n | head

Of 142 theme files, 109 were identical across all five sites, 27 differed in at least one site, and 6 existed in only one site. Reviewing the 27 differences sorted them into three groups: fixes that should be in every site (14), deliberate site-specific changes (9), and accidental drift (4). The 14 fixes were merged into the module; the 9 deliberate changes became overrides in their sites.

Theme files by drift status across five copies Vertical bars. 109 files were identical in every site. 14 differed because a fix had been applied to only some sites. 9 differed because of deliberate site-specific changes. 4 differed by accident. 6 existed in only one site. How the five theme copies had drifted 109 Identical 14 Partial fixes 9 Deliberate changes 6 Site-only files 4 Accidental drift
md5 comparison of 142 theme files across five sites after two years of copy-and-edit.

Step 2: Make the Theme a Module

In the theme repository:

hugo mod init github.com/acme/hugo-theme

This creates go.mod. A hugo.toml in the theme declares its mounts and minimum Hugo version:

[module]
  [module.hugoVersion]
    min = "0.120.0"
    extended = true
  [[module.mounts]]
    source = "layouts"
    target = "layouts"
  [[module.mounts]]
    source = "assets"
    target = "assets"
  [[module.mounts]]
    source = "i18n"
    target = "i18n"
  [[module.mounts]]
    source = "static"
    target = "static"

Tag a release: git tag v1.0.0 && git push --tags. Go module versioning applies, so a breaking change later means a v2 with a /v2 suffix in the module path.

Step 3: Import It in Each Site

Remove themes/acme and the theme = "acme" line, then:

hugo mod init github.com/acme/www
# hugo.toml in each site
[module]
  [[module.imports]]
    path = "github.com/acme/hugo-theme"
hugo mod get github.com/acme/hugo-theme@v1.0.0

go.mod and go.sum record the exact version and checksum, so builds are reproducible. The site builds exactly as before if the module's files match what the site had, which the output comparison in Step 5 verifies.

Lookup order between site files and the theme module Three layers in lookup order. First the site's own layouts, assets and i18n files, which hold the nine deliberate overrides. Then the theme module at the pinned version, which provides the shared layouts. Then Hugo's embedded templates for things like sitemaps and RSS. The first match wins. How a site resolves a template Site files layouts/, assets/, i18n/ 9 overrides across 5 sites Theme module @ v1.x github.com/acme/hugo-theme 133 shared files Hugo embedded templates sitemap, rss, robots used unless overridden
An override is a full copy of the file, so it no longer receives module updates; keep them few.

Step 4: Keep Overrides Small With Hooks

A full-file override of layouts/partials/header.html to add one link to the status site's header is a copy that will miss every future header fix. The theme instead exposes small hook partials that sites can override without copying the surrounding template:

{{/* in the theme's header.html */}}
<nav aria-label="Main">
  {{ range .Site.Menus.main }}<a href="{{ .URL }}">{{ .Name }}</a>{{ end }}
  {{ partial "hooks/header-end.html" . }}
</nav>

The theme ships an empty hooks/header-end.html; the status site overrides only that one file. Seven of the nine deliberate changes became hook overrides of a few lines each. Two remained full overrides, documented in each site's README with the reason.

Step 5: Verify the Output Is Unchanged

For each site, build once with the old copied theme and once with the module, and diff:

git stash && hugo --gc -d /tmp/old && git stash pop
hugo --gc -d /tmp/new
diff -r /tmp/old /tmp/new | grep -v '^Only in' | head

Differences were expected where the module carried fixes the site had lacked — those were reviewed and kept. No unexpected differences remained in any of the five sites.

Step 6: Automate Updates

Each site's repository runs Renovate, which understands Go modules. A theme release opens a pull request in every site bumping go.mod, and each site's CI builds a preview. Minor and patch releases with passing checks are auto-merged; major versions wait for review:

{
  "extends": ["config:recommended"],
  "packageRules": [
    { "matchManagers": ["gomod"], "matchPackageNames": ["github.com/acme/hugo-theme"],
      "matchUpdateTypes": ["minor", "patch"], "automerge": true }
  ]
}

Renovate's postUpdateOptions: ["gomodTidy"] keeps go.sum consistent.

Step 7: Cache Modules in CI

- uses: actions/setup-go@v5
  with: { go-version: '1.22', cache: false }
- uses: actions/cache@v4
  with:
    path: ~/.cache/hugo_cache/modules
    key: hugo-mod-${{ hashFiles('go.sum') }}
- run: hugo --gc --minify

Hugo stores modules in its cache directory. With the cache restored, module resolution takes under a second; without it, downloading the theme took about 4 seconds.

Developing the Theme Against a Real Site

Theme changes need testing in context, and publishing a release for every experiment would be slow. Hugo's module replace directive points a site at a local checkout of the theme while developing:

# hugo.toml in the site, local only
[module]
  replacements = "github.com/acme/hugo-theme -> ../hugo-theme"

Or, without editing the config, HUGO_MODULE_REPLACEMENTS="github.com/acme/hugo-theme -> ../hugo-theme" hugo server. The environment variable form is safer, because a committed replacements line would break CI, where the sibling directory does not exist. The theme repository also holds a small example site covering every partial and hook, built in the theme's own CI with the minimum and latest supported Hugo versions, so a change that breaks an older Hugo is caught before release.

Releases follow a short checklist: build the example site, build two of the real sites against the branch with the replacement variable, write a changelog entry that names any new hook, and tag. Most releases take ten minutes from merge to tag.

Measured Impact

MeasureCopied themeModuleSource
Time for a theme fix to reach all 5 sites2–6 weeks, often neverSame day (auto-merged)Pull request history
Pull requests per theme fix5, hand-written1 + 5 automatedGitHub
Theme files duplicated across repos7100 (+ 9 overrides)find
Build time added, warm cache—0.4 shyperfine
Build time added, cold cache—4.1 shyperfine
Days for a theme fix to reach each site, before and after modules Paired bars for five sites. Before, using copied themes, the www site took 3 days, docs 12, blog 21, status 40 and events never received the fix, shown as 60. After, using the module with automated updates, every site received the fix on day 0. Days until a theme fix reached each site Copied theme Module www 3 d 0 d docs 12 d 0 d blog 21 d 0 d status 40 d 0 d events never 0 d
Measured for the same accessibility fix to the header focus style; the events site never got it under the old setup.

Pitfalls & Rollback

  • Overriding whole templates. Add hook partials to the theme instead, so sites override a few lines.
  • Unpinned module versions. Always pin with hugo mod get path@version; go.sum makes builds reproducible.
  • Breaking changes in minor releases. Use semantic versioning strictly, because Renovate auto-merges minors.
  • No Go in the CI image. Install it or vendor modules with hugo mod vendor.
  • Rollback: pin the previous version with hugo mod get path@v1.3.2; a site can also vendor the module to freeze it entirely.

Conclusion

A theme copied into several Hugo sites drifts until fixes stop propagating. Turning it into a versioned Hugo Module with small hook overrides and automated update pull requests made a theme fix reach all five sites the same day, removed 710 duplicated files, and cost under half a second per build with the module cache restored.

FAQ

What is the difference between a Hugo theme in the themes folder and a Hugo Module?

A theme in the themes folder is a copy or git submodule inside each site, updated by hand. A Hugo Module is a Go module imported by path and version in the site configuration, downloaded and cached by Hugo, and updated with a command or a dependency bot. Modules can also mount any directory into any part of the site, not just layouts and assets.

Do Hugo Modules require Go to be installed?

Yes, for downloading and updating modules, because Hugo uses the Go toolchain for module resolution. Building a site whose modules are already vendored with hugo mod vendor does not need Go. CI images used for Hugo builds usually include Go or install it in a few seconds.

How do sites override parts of a shared theme?

The same way as with any theme. A file at the same path in the site's own layouts, assets or i18n directory takes precedence over the module's file. Keep overrides small and documented, because each override is a copy that will not receive updates from the module.

Do Hugo Modules slow down CI builds?

Only the first download. Hugo caches modules in its cache directory, and restoring that directory in CI makes module resolution take under a second. Without the cache, fetching a small theme module adds 3 to 8 seconds depending on the network.

Filed underHow-tosHugo