Contents

Hugo Advanced: Shortcodes, Partials, and Making Your Static Site Feel Dynamic

The template machinery that turns a flat folder of Markdown into something that breathes

Contents

Most people meet Hugo as “the thing that turns Markdown into HTML very fast”, install a theme, and stop there. That is a perfectly good place to stop. But underneath the convenience sits a real templating system, and once you learn to drive it, a static site can do a surprising amount of what people reach for JavaScript frameworks to achieve — without shipping a single byte of runtime to the browser. The trick is that all the dynamism happens at build time. The visitor gets plain HTML; the cleverness was spent before they ever arrived.

Partials: the unglamorous workhorse

Advertisement

A partial is a reusable template fragment. If you have ever copied a chunk of HTML between two layouts and felt dirty about it, a partial is the cure. They live in layouts/partials/ and you pull them in with a single function call, passing whatever context they need.

1
{{ partial "author-card.html" .Params.author }}

The genuinely useful part is the second argument: you decide what context the partial sees. Pass it the whole page with ., or a single value, or a custom dict you assemble on the spot with (dict "title" .Title "count" 5). A well-factored Hugo theme is mostly partials calling partials, each receiving exactly the data it needs and nothing more.

There is also partialCached, which renders a partial once and reuses the result across every page that asks for it. For a sidebar or footer that is identical site-wide, this is free performance: on a site with hundreds of pages, computing the footer once instead of hundreds of times is the difference between a build that feels instant and one that makes you go for coffee.

The catch — and it bites everyone once — is that partialCached caches the result, so if the partial’s output should vary per page, you must pass a cache key as the extra argument:

1
2
3
4
5
{{/* WRONG: every page gets whatever the first page rendered */}}
{{ partialCached "menu.html" . }}

{{/* RIGHT: cache one variant per section */}}
{{ partialCached "menu.html" . .Section }}

Forget the key on a partial that reads .Title and every page on the site will wear the title of whichever page Hugo happened to build first. It is a wonderful bug, in the sense that it is reproducible, baffling, and entirely your own fault.

Shortcodes: dynamism inside your Markdown

Partials live in templates. Shortcodes live in your content. A shortcode is a small template you invoke from inside a Markdown file, which lets writers do things Markdown cannot express without dropping into raw HTML and ruining the readability of the source.

You define one in layouts/shortcodes/. Here is a simple notice box:

1
2
3
4
{{/* layouts/shortcodes/notice.html */}}
<div class="notice notice-{{ .Get 0 }}">
  {{ .Inner | markdownify }}
</div>

And in the Markdown, a writer uses it like this:

1
2
3
{{% notice warning %}}
This API is deprecated and will be removed in the next release.
{{% /notice %}}

The .Inner is whatever sits between the opening and closing tags, and markdownify means the writer can still use Markdown inside it. Suddenly your content authors have a vocabulary — notices, figures with captions, embeds, pricing tables — that stays readable in the source and renders consistently everywhere. That is a quietly enormous improvement over pasting <div> soup into prose.

One subtlety worth committing to memory now, because it will save you an hour later: the delimiter you choose changes how Hugo treats the inner content. {{% %}} passes the body through the Markdown renderer; {{< >}} does not and treats it as raw HTML. Get them the wrong way round and your nicely written Markdown either shows up as literal asterisks or your raw HTML gets mangled. The rule I keep on a sticky note: percent for prose, angle brackets for markup.

Shortcodes also take named or positional arguments, and you can mix .Get "type" (named) with .Get 0 (positional). Named arguments age better — six months on, {{< figure src="..." caption="..." >}} is self-documenting in a way that {{< figure "..." "..." >}} is not.

The dynamic illusion

Advertisement

Here is where it starts to feel alive. Hugo can read structured data from data/ files and loop over it at build time. The data does not have to be a checked-in TOML file either — getJSON and getCSV (now under resources.GetRemote in current Hugo) can pull a remote endpoint at build time and bake the response into the page, so a “live” pricing table or release-notes list is really a snapshot taken the moment you last deployed. That trade is usually the right one for a content site: you get the freshness of an API call with the speed and reliability of static HTML, and the third-party service being down at 3am no longer takes your page with it.

Drop a data/team.toml in place and you can generate a whole team page from it:

1
2
3
4
5
6
{{ range .Site.Data.team.members }}
  <article class="member">
    <h3>{{ .name }}</h3>
    <p>{{ .role }}</p>
  </article>
{{ end }}

Edit the TOML, rebuild, and the page updates. From the visitor’s side it looks data-driven; in reality it is HTML baked at build time. The same pattern powers related-posts sections (loop over pages sharing a tag), automatic tables of contents (Hugo exposes .TableOfContents for free), and faceted listings. None of it costs the browser anything, because all the work already happened.

Hugo also gives you query-like access to the whole content set through .Site.RegularPages, which you can filter and sort however you like. Want the five most recent posts in a section, or every page tagged a certain way, sorted by date? That is one where/first pipeline, evaluated once at build:

1
2
3
4
{{ $recent := first 5 (where .Site.RegularPages "Section" "story") }}
{{ range $recent.ByDate.Reverse }}
  <li><a href="{{ .RelPermalink }}">{{ .Title }}</a>{{ .Date.Format "2 Jan 2006" }}</li>
{{ end }}

This is the bit people miss when they assume “static” means “dumb”. The generator has a full view of every page and its front matter while it builds, so it can assemble cross-references, indexes and navigation that would otherwise need a database behind a running server. You are essentially running the queries up front and serializing the answers to disk.

For the genuinely interactive bits — search, comments — you still need a little client-side help, but Hugo’s job is to generate the index or the markup those tools consume, and it does that well. It is the same separation you see in a well-built retrieval-augmented generation pipeline: do the expensive structuring work ahead of time, build a clean index, and let the live request do as little as possible. Hugo just runs that index-building step on every deploy instead of on every query.

Troubleshooting: where Go templates fight back

The two recurring sources of pain are both about silence and terseness, not difficulty.

Nil and the empty render. Hugo will not always shout when a value is missing; it will sometimes render nothing and move on, leaving you with a blank where you expected content. The fix is to be explicit about absence and to fail loudly when something genuinely should exist:

1
2
3
4
5
{{ with .Params.author }}
  {{ partial "author-card.html" . }}
{{ else }}
  {{ errorf "page %q has no author set" .File.Path }}
{{ end }}

with rebinds . only if the value is non-empty, so the partial never runs on a nil; errorf aborts the build with a message that names the offending file. A build that fails clearly beats a site that ships a hole.

The scope trap inside range. Inside a range or with block, the dot (.) is reborn as the current item, so reaching back to the page or site context fails confusingly. Capture what you need into a variable before you enter the loop:

1
2
3
4
{{ $page := . }}
{{ range .Site.Data.team.members }}
  <a href="{{ $page.RelPermalink }}">{{ .name }}</a>
{{ end }}

Without the $page capture, .RelPermalink inside the loop refers to a team member, which has no such field, and Hugo’s error points at a line that looks fine in isolation.

Builds that pass locally but fail in CI. This one is not a template bug but it costs the most time. Hugo’s behaviour depends on the exact binary version and on whether the extended build is installed — partialCached keying, image processing and some functions differ across releases. Pin the version in CI, build with the same hugo everywhere, and if a deploy pipeline runs on a schedule, wire it to a dead-man’s-switch so a silently broken build actually pages you. I cover that pattern in making sure your cron jobs actually ran; a static site rebuild is exactly the kind of job that fails quietly and is noticed weeks later.

Is it worth it?

If your site is five pages and never changes, no. Reaching for partials and shortcodes there is over-engineering a problem you do not have, and you should go and do something more fun.

But the moment a site has structure that repeats — many posts, multiple authors, recurring content patterns — this machinery stops being optional and starts being the thing that keeps the site maintainable. A site I run lives or dies by exactly these techniques: one place to change the post layout, shortcodes that keep the Markdown clean, data files that drive listings. The payoff is that adding the hundredth article is no harder than adding the first.

The learning curve is real, mostly because Go’s template syntax is terse and its error messages are blunt. But there is a ceiling to how much you need. Learn partials, learn shortcodes, learn to loop over a data file, and you have covered ninety per cent of what makes a static site feel dynamic. The remaining ten per cent you can look up the day you need it — which, pleasingly, is the same advice the tool’s own philosophy keeps quietly giving you.

Who is this for? People who maintain a site they expect to keep for years, who would rather edit one layout than fifty pages, and who are temperamentally allergic to shipping a megabyte of JavaScript to render text that never changes. If that’s you, the hours spent learning partials and shortcodes pay back every single time you add content. If instead you have a brochure site that gets touched twice a year, a theme out of the box is the correct amount of engineering and you should not feel one ounce of guilt about it.

The deeper point is about where you spend complexity. A static-site generator lets you front-load all of it into the build, so the running site is just files on a CDN with nothing to crash, patch, or scale. That is a genuinely good trade for the vast majority of content on the web, and it is the reason this old, unglamorous architecture keeps quietly winning. You move the cleverness to a moment when a slow, careful machine can do it once — and the visitor, who only ever wanted to read something, gets bytes that were ready before they asked.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.