How I Use Hugo Render Hooks on This Site

When I moved this site over to Hugo, my first goal was to tweak the basics of how Markdown gets rendered: headings, images, links, tables, and code blocks. For example, I wanted headings to have anchor links and tables to be wrapped in a .table-wrapper.

Thanks to render hooks, that was pretty straightforward.

1. Heading render hooks

Hugo converts Markdown headings (<h1>, <h2>, etc.) to HTML with automatic IDs. Render hooks let you customize heading structure, add attributes, or insert extra elements.

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:

hugo.yaml
markup:
  goldmark:
    parser:
      attribute:
        title: true

And here’s the render-heading.html file:

layouts/_markup/render-heading.html
{{ $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></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 a heading has the noanchor attribute, an anchor link appears on hover, pointing to the heading’s unique anchor (href="#{{ .Anchor }}") that Hugo generates automatically.

Here’s how different input would render:

input.md
# Hi there, I'm Luthfi! {noanchor=true}

## Technologies I use

### Work {.index-section-title sup=5}

Lorem ipsum dolor sit amet.
output.html
<h1 id="hi-there-im-luthfi">
  Hi there, I'm Luthfi!
</h1>

<h2 id="technologies-i-use">
  Technologies I use
  <a href="#technologies-i-use" anchor>
    <svg>...</svg>
  </a>
</h2>

<h3 id="work" class="index-section-title">
  Work<sup>5</sup>
  <a href="#work" anchor>
    <svg>...</svg>
  </a>
</h3>

<p>Lorem ipsum dolor sit amet.</p>

2. Image render hooks

By default, Hugo renders images using standard Markdown-to-HTML, producing something like <p><img></p>. I prefer more control, such as adding captions or wrapping images in a semantic <figure>. To support that, the config needs to enable Markdown attributes and prevent standalone images from being wrapped in a paragraph:

hugo.yaml
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:

layouts/_markup/render-image.html
<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:

input.md
![A cozy cat](/images/cat.jpg "Nap time")
output.html
<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>

Markdown links are normally rendered as <a> tags. I use render hooks to add target="_blank" to external links. No extra configuration is needed unless using the embedded resolver. Here’s the render-link.html file I use:

layouts/_markup/render-link.html
{{- $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 a bit of safety. If it’s a relative link, it works just like normal, no extra attributes. For example:

input.md
[My first article](/articles/article-1)

[External link](https://example.com)
output.html
<a href="/articles/article-1">
  My first article
</a>

<a
  href="https://example.com"
  target="_blank"
  rel="noopener"
>
  External link
</a>

That’s it. Clean, simple control over link behavior.

Note that 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:

hugo.yaml
markup:
  goldmark:
    renderHooks:
      link:
        enableDefault: true

4. Table render hooks

Tables are wrapped in a scrollable container with HTML, making them easier to style with CSS and view on smaller screens. To support attributes like class, id, or custom data attributes, Hugo’s Goldmark parser needs block-level attributes enabled:

hugo.yaml
markup:
  goldmark:
    parser:
      attribute:
        block: true

Here’s the custom render-table.html I’m using it to enable horizontal scrolling for tables, while still preserving alignment and any custom attributes added in the Markdown:

layouts/_markup/render-table.html
<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>

Here’s the HTML result after wrapping the Markdown table in a scrollable container:

input.md
| Name  | Score |
|-------|-------|
| Alice | 95    |
| Bob   | 87    |
output.html
<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>

5. Code block render hooks

To gain more control over how code blocks are displayed, I added a custom render hook that wraps highlighted code in a <div> and optionally shows a title. Here’s the render-codeblock.html:

layouts/_markup/render-codeblock.html
{{ $result := transform.HighlightCodeBlock . }}

<div class="highlight">
  {{ with .Attributes.title }}
    <span class="highlight__title">{{ . }}</span>
  {{ end }}
  <pre class="chroma"><code>{{ $result.Inner }}</code></pre>
</div>

Hugo processes the fenced code block with syntax highlighting via transform.HighlightCodeBlock. If a title attribute is provided, it’s rendered above the code inside <span>. The code block is wrapped in a <div class="highlight"> container for styling.

Here are some concrete examples that illustrate both cases:

input.md
```js
console.log("Hello, world!");
```

```js {title="app.js"}
console.log("Hello, world!");
```
output.html
<div class="highlight">
  <pre class="chroma"><code><span class="line"><span class="cl"><span class="nb">console</span>.<span class="na">log</span>(<span class="s2">"Hello, world!"</span>);</span></span></code></pre>
</div>

<div class="highlight">
  <span class="highlight__title">app.js</span>
  <pre class="chroma"><code><span class="line"><span class="cl"><span class="nb">console</span>.<span class="na">log</span>(<span class="s2">"Hello, world!"</span>);</span></span></code></pre>
</div>

Conclusion

That’s how I handle headings, images, links, tables and code blocks with Hugo render hooks on this site. Nothing fancy, just the tweaks I find useful to keep content structured, clear, and easy to work with.