All articles in the "My Hugo Site" series
Adding icons to external links
I wanted two things from links on my Hugo site:
- UX: External links should open in a new tab and show a small icon so readers know they’re leaving.
- Security/Accessibility: Add
rel="external nofollow noreferrerand an ARIA cue (“aria-label="{{ $text }} (opens in a new tab)”).
Doing this inside Markdown is messy. Doing it site wide with a Hugo render hook is cleaner, is adopted by existing function rendering and future proof.
Options to solution
Initially I thought I had to approach this with shortcodes but reading through the documentation to implement another feature for admonitions and code blocks, I learnt that Hugo includes an embedded link render hook to resolve Markdown link destinations. You can adjust its behaviour in the site configuration that is documented in detail and has a code example to get me started
{{- $u := urls.Parse .Destination -}}
<a href="{{ .Destination | safeURL }}"
{{- with .Title }} title="{{ . }}"{{ end -}}
{{- if $u.IsAbs }} rel="external"{{ end -}}
>
{{- with .Text }}{{ . }}{{ end -}}
</a>
{{- /* chomp trailing newline */ -}}
My approach
- Hugo lets you override how Markdown links render via
/layouts/_default/_markup/render-link.html. - I detect external vs internal (respecting my own
baseURL), treat the handling of other uri types (mailto:) safely to not add my render hook:- For external: add
target="_blank",rel="external nofollow noreferrer, an ARIA label, and a small external-link inline image (I am using font awsome icons for this). - For internal: output a normal link.
- For external: add
- The hook doesn’t change your content; it wraps Markdown output consistently across the site.
Setting target="_blank" on <a>, <area> and <form> elements implicitly provides the same rel behavior as setting rel="noopener" which does not set window.opener. MDN noopener
The render hook
Create: /layouts/_default/_markup/render-link.html
{{- $u := urls.Parse .Destination -}}
{{- with .Title }} title="{{ . }}" {{ end -}}
{{- if hasPrefix .Destination "mailto:" -}}
<a href="{{ .Destination | safeURL }}" {{- with .Title }} title="{{ . }}" aria-label="{{ $text }} (opens in a new tab)" {{ end -}}>
{{- with .Text }}
{{ . }}
{{- end -}}
</a>
{{- else -}}
<a href="{{ .Destination | safeURL }}"
{{- if $u.IsAbs }} rel="external nofollow noreferrer" target="_blank" {{ end -}} >
{{- with .Text }}
{{ . }}
{{- if $u.IsAbs }}
<span style="vertical-align: super; display: inline-block; font-size: 0.7rem; font-weight: 200;" >
<i class="fa-solid fa-arrow-up-right-from-square"></i>
</span>
{{ end -}}
{{ end -}}
</a>
{{- end -}}
Why this logic works :
- External = absolute
http(s)link whose prefix is not the sitesbaseURL. - Internal = relative links (
/articles/…,articles/…) or absolute links that start with yourbaseURL. mailto:are not forced new-tab and don’t get the icon (keeps assistive tech expectations).
Minimal CSS
In the render-link template I applied some inline styling to the to appended external link icon to give it that familiar look
<span style="vertical-align: super; display: inline-block; font-size: 0.7rem; font-weight: 200;" >
<i class="fa-solid fa-arrow-up-right-from-square"></i>
</span>
Config tweaks
You don’t need special settings configured in hugo.toml to use a render hook. Since I am sanitising the HTML, keeping the icon inline as above should keep things safe. But I if extending Markdown attributes like {.btn}, ensuring I have following goldmark attribute set gives me future proofing if I need it:
[markup.goldmark.parser.attribute]
block = true
title = true
A quick test matrix
Paste these in a draft and preview:
- Internal relative: [Home](/)
- Internal absolute: [Self](/)
- External: [Hugo docs](https://gohugo.io/)
- Mail: [Email me](mailto:hello@example.com)
- Tel: [Call](tel:+61855501234)`
Expected Result: only “Hugo docs” should open in a new tab and shows the icon.
Dam edge cases and gotchas
Custom domains / GitHub Pages paths: The hook compares against
site.BaseURL. I need Make sure the paths are correct otherwise Hugo may misclassify links.Links rendered by shortcodes/HTML: This hook only affects Markdown links. Other build links in shortcodes/partials will add the same attributes there. This is a bonus for my use case but something I need to be aware of.
Analytics or CSP: Since I added external icon fonts, I need to be mindfull of Content Security Policies. HTTP header (or
<meta>tag) that tells the browser which sources are allowed for scripts, styles, images, fonts, etc. The goal: block XSS and rogue resources. If something loads from a source you didn’t whitelist, the browser refuses it (and can report it).Accessibility: The ARIA label announces the new-tab behaviour; don’t also add “(external)” in the visible text unless your style guide requires it.
Why a render hook (and not JS)?
- Deterministic: Works at build time; no FOUC or client-side mutation. FOUC - Flash Of Unstyled Content, It’s when a page briefly shows raw HTML without your CSS applied, then “snaps” to the styled version once the stylesheet loads. I deal with this in the header loading the style sheet with
{{ $css := resources.Get "css/style.css" | minify }}. - Consistency: Every Markdown link gets the same treatment, perfect for consistency avoiding human error.
- Fast: No runtime overhead, no brittle DOM rewrites.