How I Use Hugo Render Hooks on This Site

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:

![A cozy cat](/images/cat.jpg "Nap time")

<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>

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.