Write Custom Shortcodes in Hugo
19 May 2026Copy-pasting the same HTML block into every post is how you end up with 40 slightly-different callout boxes. A shortcode fixes that once and stays fixed.
Where they live
Shortcodes go in layouts/shortcodes/, one file per shortcode, named after the tag you’ll use in markdown:
layouts/shortcodes/callout.html
Call it from any post with {{< callout >}}, using the actual angle-bracket-percent syntax without the escaping shown here.
1. A callout with named parameters
Named parameters are clearer than positional ones once you have more than one argument:
<!-- layouts/shortcodes/callout.html -->
<div class="callout callout-{{ .Get "type" | default "info" }}">
{{ .Inner | markdownify }}
</div>
Usage in a post:
{{< callout type="warning" >}}
This breaks on Hugo versions before 0.120.
{{< /callout >}}
.Inner lets you write markdown inside the shortcode body, and markdownify renders it — without that pipe, you’d get raw markdown text on the page instead of formatted HTML.
2. A grouped code block for multi-language examples
This is the one that saves the most repetition on a technical blog:
<!-- layouts/shortcodes/codegroup.html -->
<div class="code-group">
{{ .Inner }}
</div>
Pair it with a small bit of CSS/JS for tabs, or skip the JS entirely and just let it stack — a code group that just lists “npm / yarn / pnpm” versions in sequence is still more scannable than three separate unlabeled blocks.
3. A video embed that doesn’t leak third-party trackers on every page load
<!-- layouts/shortcodes/youtube-lite.html -->
{{ $id := .Get 0 }}
<div class="yt-lite" data-id="{{ $id }}">
<img loading="lazy" src="https://i.ytimg.com/vi/{{ $id }}/hqdefault.jpg" alt="Video thumbnail">
<button aria-label="Play video">▶</button>
</div>
.Get 0 reads the first positional argument — usage is {{< youtube-lite "dQw4w9WgXcQ" >}}. This defers loading the actual YouTube iframe (and its tracking scripts) until someone clicks play, instead of loading it on every page view whether or not they watch.
Positional vs. named — when to use each
Use positional (.Get 0) |
Use named (.Get "key") |
|---|---|
| One obvious argument (a URL, an ID) | Two or more arguments |
| Order is unambiguous | Order would be easy to mix up |
| Terse call sites matter | Clarity at the call site matters more |
Validate required arguments
A shortcode that silently renders blank when you forget an argument wastes your own time later. Fail loud instead:
{{ if not (.Get "type") }}
{{ errorf "callout shortcode in %s: missing required 'type' parameter" .Page.File.Filename }}
{{ end }}
errorf stops the build with a message pointing at the exact file, which is much faster to debug than scrolling through rendered output looking for the gap.
Once you have three or four of these, most posts stop needing raw HTML in the markdown at all — which means a later redesign is a shortcode-file edit, not a find-and-replace across every post you’ve ever written.