How I Use Hugo Render Hooks on This Site

When I moved this site to Hugo, I started by changing how it renders the Markdown basics: headings, images, links, tables, and code blocks. I wanted headings with anchor links and tables wrapped in a .table-wrapper.

Render hooks made that pretty straightforward.

1. Heading render hooks

Hugo converts Markdown headings (<h1>, <h2>, etc.) to HTML and gives them IDs automatically. A render hook lets me change the heading structure, add attributes, or insert extra elements.

For this to work, Hugo’s Goldmark parser needs to accept attributes in Markdown headings. This configuration enables that:

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 hook loops through the heading’s custom attributes, such as class and id, and builds them into a string. It skips noanchor and sup because they control special behavior. noanchor disables the anchor link, while sup adds superscript text.

Next, the hook renders the heading at the right level (<h1>, <h2>, etc.) with its valid attributes. When sup is present, its value goes inside a <sup> tag.

Lastly, an anchor link appears on hover unless the heading has noanchor. The link points to the 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 turns an image into standard Markdown HTML, something like <p><img></p>. I wanted more control over the result, including captions and a semantic <figure> wrapper. The configuration needs to enable Markdown attributes and stop 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>

The hook wraps the image in a <figure>. The alt and title values come from the Markdown description. This is the result:

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 normally become <a> tags. I use a render hook to add target="_blank" to external links. It needs no extra configuration unless I use the embedded resolver. This is my render-link.html file:

layouts/_markup/render-link.html
{{- $external := strings.HasPrefix .Destination "http" -}}
<a
  href="{{ .Destination | safeURL }}"
  {{ if $external }}target="_blank" rel="noopener"{{ end }}
>
  {{- .Text | safeHTML -}}
</a>
{{- "" -}}

An external link, meaning one that starts with http, opens in a new tab and gets rel="noopener" for a bit of extra safety. Relative links work normally and get 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 all I needed for link behavior.

Hugo 0.123+ also has an embedded link resolver. It can resolve internal links by matching them to pages or resources. The resolver is off by default, but this enables it:

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

4. Table render hooks

I wrap tables in a scrollable HTML container so they are easier to style with CSS and use on smaller screens. To pass attributes such as class, id, or custom data attributes through to the table, Hugo’s Goldmark parser needs block-level attributes enabled:

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

This is the render-table.html hook I use. It adds horizontal scrolling while preserving alignment and custom attributes from 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

I also added a render hook for code blocks. It wraps highlighted code in a <div> and can show an optional title. This is the render-codeblock.html file:

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 syntax-highlights the fenced code block with transform.HighlightCodeBlock. When a title attribute is present, the hook puts it above the code inside <span>. The code itself sits inside a <div class="highlight"> for styling.

Here are the two 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. These are just the tweaks I find useful when writing and maintaining the content.