A collection of notes and learnings from using Hugo as a primary CMS. This post will be updated periodically.
Here are some notes of things I’ve learnt while using Hugo as my main CMS. I will be updating this post as I discover new tips and tricks. Hope it helps!
Hugo Templates
Render-heading Anchors for Templates
Hugo provides layouts/_markup/render-heading.html for use to render anchor links in Markdown content. But what if you want to add anchor links to headings created in template files (.html)?
Create a partial e.g. layouts/_partials/render-heading.html and reference the example below:
1{{- $headingText := .text -}}
2{{- $anchor := .id | default ($headingText | anchorize) -}}
3<h{{ .level }} id="{{- $anchor | safeURL -}}" {{- if .class -}}class="{{- .class -}}"{{- end -}} {{- if .style -}}style="{{- .style | safeCSS -}}"{{- end -}}>
4 <a href="#{{- $anchor | safeURL -}}" class="h-anchor" title='Permalink to #{{- $headingText | plainify | safeHTMLAttr -}}'>
5 <svg>Use your own svg like a link logo or just use the symbol like #</svg>
6 </a>
7 {{- $headingText | safeHTML -}}
8</h{{ .level }}>To use the partial in .html files:
1{{ partial "render-html-heading.html" (dict "text" "This Is a Test Heading" "level" 2) }}This will create the heading anchor in H2. Adjust the “level” number for the respective H2-H6 options. If your heading has a class or id attribute, use the following syntax:
1{{ partial "render-html-heading.html" (dict "text" "Example Heading" "level" 2 "id" "unique-id-name") }}
2{{ partial "render-html-heading.html" (dict "text" "Another Heading" "level" 2 "class" "p-name") }}If the link text is a Hugo computed value like title $name, the correct way is to compute the value first, then pass it into the partial. For example:
1<div class="taxonomy-list">
2{{ range $name, $taxonomy := .Site.Taxonomies }}
3{{ if gt (len $taxonomy) 0 }}
4 {{- $headingText := title $name -}} <!-- compute first -->
5 {{ partial "render-html-heading.html" (dict "text" $headingText "level" 4) }} <!-- pass $headingText into partial -->
6{{ end }}
7</div>Range: Section or Type
To iterate over a range, use section to select content by the content path name, e.g. /content/posts would be posts or /content/pages would be pages. The slice values must match the directory name exactly (case-sensitive). For example:
1{{- range where .Site.RegularPages "Section" "in" (slice "posts" "pages" "weeknotes") -}}On the other hand, use type to select content based on the page’s front matter type value, e.g. type = "post". For example:
1{{- range where .Site.RegularPages "Type" "in" (slice "post" "page" "weeknote") -}}Tip
To iterate over all pages (including non-regular pages like taxonomy pages), use
.Site.Pagesinstead of.Site.RegularPages.Another tip for debugging templates: check a page’s section or type status with:
<pre>{{ printf "Section=%s Type=%s" .Section .Type }}</pre>
Minify
1[minify]
2 disableHTML = false
3
4[outputFormats.HTML]
5 mediaType = "text/html"
6 isPlainText = false
7 isHTML = true
8 noUgly = trueTo have more readable HTML output, don’t use --minify in the build pipeline, or check if you have minification set to true in hugo.toml (config) and set HTML outputs.
Whitespace Trimming
- Use whitespace control
{{-and-}}to remove unwanted extra blank lines when writing templates in Go. For example, template blocks like{{ if }},{{ range }},{{ else }}will include all the lines surrounding them in the output. This means when viewing the HTML like in “View Page Source”,1 there will be lots of empty lines.
{{-will remove all the whitespace and new lines left of the action, and vice versa for-}}. I do this for allif,else if,end,range,with, andblock. But don’t do it for all curly brackets to prevent breaking layouts, as sometimes new lines and spaces are necessary for HTML formatting. Always test after changing.For
{{ end }}, I tend to add the trim to left of the action like{{- end }}because it is closing a block, is on its own line, and I want the next item to start on a new line. As for partials, use selectively—it may trim too much, so try to fix whitespace within the partial itself.
- I used to use HTML comment syntax like:
<!-- This is a comment -->but I realise that they would appear in HTML output. So I’ve switched to Hugo comment syntax like:{{/* Another comment */}}
Custom Templates
If you’ve read how to make customizations to your Hugo theme, you probably came across the instruction not to modify the theme files directly; but make use of Hugo’s lookup order.
Any file with the same name in the project’s
layoutsdirectory will override the one in/themes/theme_name/layouts. This is to prevent your changes overwritten during theme upgrades.I also suggest reading how to use blocks and partials to incorporate your own theme customizations as it will make code maintenance easier in the long run.
Exclude Partials in Server Mode
I use iine upvote buttons for most of my pages on the site. The iine section is in it’s own partial called “iine.html”. To exclude something while working in
hugo serverlocally (but shown on the live site), wrap the partial or whatever you don’t want to see with:1{{ if not hugo.IsServer }} 2 {{ partial "iine.html" . }} 3{{ end }}If the opposite is what you want, i.e., you don’t want something to load on the live site, but only in
hugo servermode, use:1{{ if hugo.IsServer }} 2 {{ partial "draft-status-indicator.html" . }} 3{{ end }}
Hugo Front Matter
Having a
slugparameter in the front matter is useful as it prevents Hugo from creating the url based on the title, which can cause 404 errors if you decide to rename the folder/file.If you need to redirect old URLs, use the handy
aliasesparameter to point to the new URL.I use TOML for my front matter, and I recommend
"double quotes as it’s more reliable and less error prone than using'single quotes (tip don’t wrap dates in quote marks though!) For example:
1+++
2draft = true
3type = "post"
4date = {{ .Date }}
5# lastmod =
6title = "{{ replace .File.ContentBaseName "-" " " | title }}"
7slug = "post-slug"
8description = "160 char meta description"
9+++Hugo offers options to output many formats in the config file. I wanted my sections (i.e. the folders within content, e.g. posts, pages, weeknotes ) to have RSS feeds generated, but I wanted to exclude pages.
Apparently, there’s no way to selectively choose sections or have an exclude option. To workaround it:
Turn on ‘RSS’ generation globally in the
hugo.tomlconfig file. (I have my own customoutputFormatscalledATOM, but the default isrss—check your config’soutputFormats).1section = ["HTML", "ATOM"]Override the global setting by adding the following line in the front matter of
content/pages/_index.md, the section you want to exclude, like so:1outputs = ["HTML"]
Any outputs included in the front matter of a section will OVERWRITE the output setting regardless of what is set in the global config. But this is not true for pages, where it will APPEND the extra outputs on that specific page, e.g. content/pages/about/index.md.
Tip
Upper and lower case matters here—if your
outputFormatisRSS, use that consistently.Also the order of the output is important,
HTMLshould remain first so that it can act as the primary output format.
Internal Links
When linking internal, pages or posts, a simple Markdown link pointing to a relative file path will work fine if you won’t be changing the target folder name or directory structure.
1Page link: [About page](/pages/about)
2Post link: [A post](/posts/2025/001-hugo-tips/index.md)Shortcodes
Note
ref Shortcodes are obsolete, please skip to the render hook section!
In the beginning, I used Hugo’s ref shortcode to generate the link dynamically like this example screenshot below. It will generate the correct permalink on build based on Hugo’s content structure and front matter settings—namely slug or url.
Tip
To get the right syntax highlighting in VSCodium for Hugo shortcodes, I recommend the Hugo Utilities extension.
Hugo provides ref and relref shortcodes which generates absolute or relative URLs respectively. The advantages of using Hugo shortcode for generating links are:
- Hugo validates that the target exists; warning you on build that the link is broken, therefore reducing broken links on your site
- Shortcodes are useful for multilingual sites as it can add language prefixes to the permalink
- Using shortcodes means nothing is hardcoded, e.g., you decide to change your slug, the link will not break as Hugo will read the updated front matter to generate the new permalink
But after a while, with about 20 of these ref shortcodes scattered in my Markdown content, I decided it was maybe not such a good idea to internally link with a non-portable, Hugo-specific method. Let’s say one day I decide to switch to another static site generator, or even, just wanting to browse my content locally in a Markdown parser, these clunky links, will not work.
1[Link]({{< ref "/posts/2025/001-post-title/index.md" >}})And guess what, I didn’t even know, but according to the docs, the ref shortcode is already depreciated and obsolete for some time (since v0.123). It is now recommended to use Embedded Link Render Hooks.
Render Hooks
So what are Hugo render hooks? From my understanding, they are like templates for generating how links are rendered in HTML from Markdown content. You can customize and add custom rules to make the links have features that you otherwise wouldn’t get in plain Markdown links, e.g., adding attributes like class, rel, target, or validating if a link target exists, etc. In other words, it can do all that of the ref shortcode and more without cluttering the Markdown files with Hugo shortcode syntax; by using standard Markdown link syntax. I think it’s pretty powerful.
I have now replaced all ref shortcodes with standard looking Markdown link syntax. In the link render hook template (/_layouts/_markup/render-link.html), there are these features, doing more than the original shortcode:
Opens external links (not internal ones) in new tabs with
target="_blank" rel="noopener noreferrer"and adds an inline SVG to indicate it is an external linkGenerates the correct permalink using the front matter
Warns if the target doesn’t exist on build
Has the option to add link title (i.e. tooltip) with this Markdown syntax:
[Topic](/posts/topic "Read more about topic")
Shortcode vs Render Hook
| Feature | ref Shortcodes | Render Hooks |
|---|---|---|
| Lock-in Risk | High (Hugo-specific syntax) | Low (Markdown is standard syntax, only the hook is Hugo-specific) |
| Link Validation | Validates target page exists | Can add logic to template |
| Broken Link Risk | Low (build-time validation) | Low (with validation logic) |
| Dynamic URL Support | Resolves to permalink or slug | Resolves via template (e.g., .Permalink) |
| Multilingual Support | Handles language prefixes | Can add logic to template |
| Ease of Use | Complex syntax | Simple input; but complex initial template setup |
| Status (as of 2025) | Obsolete, not recommended | Recommended in official docs |
| Customization | Limited to shortcode params | Highly customizable via template |
Preventing Shortcodes From Rendering in Code Examples
In scenarios when you want type an example of a real Hugo shortcode you’re using (say for a tutorial post), how can you stop it from activating or rendering? Wrapping it in a single or triple backtick (`) doesn’t work. To fix this, comment out the shortcode name like so:
1{{</* random-function */>}}Dependencies
I’ve used additional tools with Hugo to add extra functionality. I will add notes on each in due course.
Static Search Function: Pagefind
Pagefind adds search functionality to static sites, perfect for my use case because it wasn’t built-in to the theme I use. Follow the official Quick Start guide to index the site.
On the results page, Pagefind displays the subheadings as links of every matching term within a single page result. It looked very cluttered, so I used a CSS rule to only show a single link to the page where the search term appears multiple times.
1/* Show the context heading ONLY on the first occurrence of each page */
2.pagefind-ui__result-nested .pagefind-ui__result-title a {
3 display: none;
4}For sections that I don’t want to be indexed, use the class no-index. Pagefind also supports a config file, pagefind.toml in the root directory of the site. Mine is set to:
1site = "public"
2output_subdir = "pagefind"
3exclude_selectors = [".no-index"]Diagrams
D2 Lang (Declarative Diagramming) is what I use to generate all my Open Graph images. I used to use Mermaid but I’m transitioning away from it due to its hard AI push and reliance on JS (I’m using SVG export for D2 diagrams which is simpler to manage). See my guide on using D2 for OG images to learn more.
To Be Continued
Consider this post work in progress.
To see page source with word wrap in Firefox, go to
about:configand switchview_source.wrap_long_linestotrue. ↩︎




Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.