Upon moving this site to Hugo, my first mission was to customize the basics: headings, images, links, and tables. I wanted headings with anchor links and tables wrapped in a .table-wrapper
div
. With render hooks, I could easily implement these features. The best part? It’s a clean, Hugo-native way to customize the site without any hacks.
Heading render hooks
Hugo renders basic <h1>
, <h2>
, etc., from Markdown, automatically adding IDs for easy linking. With render hooks, it’s easy to customize how headings are structured—add attributes, insert elements, whatever’s needed—without resorting to weird stuff.
To get this working, the following configuration tells Hugo’s Goldmark parser to allow attributes in Markdown’s headings, so the render hook can process them correctly:
markup:
goldmark:
parser:
attribute:
title: true
Here’s the render-heading.html
file:
{{ $attributes := "" }}
{{- range $k, $v := .Attributes }}
{{- if and (ne $k "noanchor") (ne $k "sup") }}
{{- $attributes = printf "%s %s=\"%s\"" $attributes $k $v -}}
{{- end }}
{{- end }}
<h{{ .Level }} {{ $attributes | safeHTMLAttr }}>
{{ .Text }}
{{- if (index .Attributes "sup") -}}
<sup>{{- index .Attributes "sup" -}}</sup>
{{- end -}}
{{- if not (index .Attributes "noanchor") -}}
<a href="#{{ .Anchor }}" anchor>
{{ partial "icons/link.html" }}
</a>
{{- end -}}
</h{{ .Level }}>
First, the custom attributes passed to the heading (like class
, id
, etc.) are looped through and built into a string. The noanchor
and sup
attributes are skipped as they control special behaviors—noanchor
disables the anchor link, sup
adds superscript text.
Next, the heading tag is rendered with the appropriate level (<h1>
, <h2>
, etc.) along with any valid attributes. If the sup
attribute is present, its value is wrapped in a <sup>
tag to render the superscript.
Lastly, unless the heading has the noanchor
attribute, an anchor link (with an icon) is added to the heading, making it easy for users to jump to that section. The href="#{{ .Anchor }}"
syntax points to the heading’s unique anchor, which Hugo generates automatically.
Here’s how different input would render:
## Technologies I use
<h2 id="technologies-i-use">
Technologies I use
<a href="#technologies-i-use" anchor>
<svg>...</svg>
</a>
</h2>
# Hi there, I'm Luthfi! {noanchor=true}
<h1 id="hi-there-im-luthfi">
Hi there, I'm Luthfi!
</h1>
## Work {.index-section-title sup=5}
<h2 id="work" class="index-section-title">
Work<sup>5</sup>
<a href="#work" anchor>
<svg>...</svg>
</a>
</h2>
Image render hooks
By default, Hugo renders images using standard Markdown-to-HTML, usually ending up with something like <p><img></p>
. That’s fine, but sometimes it’s better to have more control—like adding a caption or wrapping the image in a semantic <figure>
.
To support that, the config needs to enable Markdown attributes and prevent standalone images from being wrapped in a paragraph:
markup:
goldmark:
parser:
attribute:
block: true
wrapStandAloneImageWithinParagraph: false
Then, with a render hook at render-image.html
, the image output can be restructured like this:
<figure>
<a href="{{ .Destination | safeURL }}" target="_blank">
<img
src="{{ .Destination | safeURL }}"
alt="{{ .PlainText }}"
title="{{ .Title }}"
/>
</a>
<figcaption>{{ .Title }}</figcaption>
</figure>
This wraps the image in a <figure>
for better layout and semantics. The alt
and title
text comes from the Markdown description. Here’s what that looks like, clean and semantic:

<figure>
<a href="/images/cat.jpg" target="_blank">
<img src="/images/cat.jpg" alt="A cozy cat" title="Nap time" />
</a>
<figcaption>Nap time</figcaption>
</figure>
Link render hooks
Links in Markdown are usually rendered straight to <a>
tags. That works, but render hooks let you intercept and tweak the output—like adding target="_blank"
to external links. No special config is needed here (unless using the embedded resolver).
Here’s the render-link.html
file I use:
{{- $external := strings.HasPrefix .Destination "http" -}}
<a
href="{{ .Destination | safeURL }}"
{{ if $external }}target="_blank" rel="noopener"{{ end }}
>
{{- .Text | safeHTML -}}
</a>
{{- "" -}}
If the link is external (starts with http
), it opens in a new tab and uses rel="noopener"
for safety. If it’s a relative link, it works just like normal—no extra attributes. For example:
[My first article](/articles/article-1)
<a href="/articles/article-1">
My first article
</a>
[External link](https://example.com)
<a
href="https://example.com"
target="_blank"
rel="noopener">
External link
</a>
That’s it. Clean, simple control over link behavior without touching every Markdown file. Want to go further? Hugo 0.123+ has an embedded link resolver that can auto-resolve internal links by matching them to pages or resources. It’s off by default, but can be enabled like this:
markup:
goldmark:
renderHooks:
link:
enableDefault: true
Table render hooks
Tables in Markdown are fine—until they start overflowing small screens. That’s where render hooks come in handy. With a little custom HTML wrapper, it’s easy to make them scrollable without weird side effects or extra shortcodes.
To make attributes like class
, id
, or even custom data attributes work on tables, you’ll need to tell Hugo’s Goldmark parser to support block-level attributes:
markup:
goldmark:
parser:
attribute:
block: true
Here’s the custom render-table.html
I’m using to enable horizontal scrolling for tables, while still preserving alignment and any custom attributes added in the Markdown:
<div class="table-scroll">
<table
{{- range $k, $v := .Attributes }}
{{- if $v }}
{{- printf " %s=%q" $k $v | safeHTMLAttr }}
{{- end }}
{{- end }}
>
<thead>
{{- range .THead }}
<tr>
{{- range . }}
<th
{{- with .Alignment }}
{{- printf " style=%q" (printf "text-align: %s" .) | safeHTMLAttr }}
{{- end -}}
>
{{- .Text -}}
</th>
{{- end }}
</tr>
{{- end }}
</thead>
<tbody>
{{- range .TBody }}
<tr>
{{- range . }}
<td
{{- with .Alignment }}
{{- printf " style=%q" (printf "text-align: %s" .) | safeHTMLAttr }}
{{- end -}}
>
{{- .Text -}}
</td>
{{- end }}
</tr>
{{- end }}
</tbody>
</table>
</div>
Wrapping the whole thing in div.table-scroll
gives the table horizontal scroll on narrow screens—no layout-breaking surprises. It’s a nice way to keep tables accessible and styled, without injecting extra markup directly in Markdown.
Then you can do stuff like:
| Name | Score |
|-------|-------|
| Alice | 95 |
| Bob | 87 |
<div class="table-scroll">
<table>
<thead>
<tr>
<th>Name</th>
<th>Score</th>
</tr>
</thead>
<tbody>
<tr>
<td>Alice</td>
<td>95</td>
</tr>
<tr>
<td>Bob</td>
<td>87</td>
</tr>
</tbody>
</table>
</div>
Wrapping things up, using Hugo’s render hooks has made customizing my site a breeze. It’s all about controlling the little details without needing to resort to messy workarounds. If you’re looking to tidy up how your content renders, these hooks are definitely worth playing around with.