Tutorial: How to Check Broken Links in Hugo

I have over 3000 external and 4000 internal links on my website. I can’t possibly check them manually. So what to do? I solved the problem as follows:

Install

You must install the following in (or beside) your Hugo project:

Your package.json should then look something like this:

{
  "name": "...",
  "scripts": {
    "ts:links": "tsx my-transport-scripts/links-check.ts",
  },
  "dependencies": {
    "md-curcuma": "^2.0.11",
    "tsx": "^4.19.4"
  },
  "devDependencies": {
    "@types/node": "^22.10.6",
    "typescript": "^5.7.3",
  },
  "engines": {
    "node": ">=20.11.0"
  }
}

The Broken Links Checker script

  • Is stored like this: my-transport-scripts/links-check.ts.
  • The path and file name must of course be adapted if necessary.
import { Broken_Link_Checker, BLC_Parameter } from "md-curcuma"

// const url = 'http://localhost:1313/';
const url: string = 'http://192.168.178.91:81';

let external_links: BLC_Parameter = new BLC_Parameter();
external_links.scan_source = url;
external_links.write_to = 'data/links_checked/external.json';
external_links.mode = 'extern';
Broken_Link_Checker.run(external_links);

let internal_links: BLC_Parameter = new BLC_Parameter();
internal_links.scan_source = url;
internal_links.write_to = 'data/links_checked/internal.json';
internal_links.mode = 'intern';
Broken_Link_Checker.run(internal_links);

The script scans all internal and external links of the website, and creates two files in the data directory, which Hugo then evaluates in different ways:

  • external.json
  • internal.json

This is what it looks like:

[
    {
        "scan_source": "http://192.168.178.91:81",
        "mode": "extern",
        "special_excludes": [
            "data:image/webp",
            "blog:",
            "troubleshooting:",
            "mailto:"
        ],
        "lastrun": "2025-05-15 13:53:510",
        "runtime": 11.558675909033335,
        "runtime_unit": "min",
        "found": 7717,
        "dropped": 4396,
        "finished": false,
        "total": 3169,
        "ok": 2810,
        "broken": 355,
        "skipped": 4,
        "links_ok": [
            {
                "url": "https://github.com/nextapps-de/flexsearch",
                "state": "OK",
                "status": 200,
                "scantime": "2025-05-15 13:53:708",
                "parent": "http://192.168.178.91:81/"
            }],
        "links_broken": [
            {
                "url": "https://twitter.com/handle",
                "state": "BROKEN",
                "status": 400,
                "scantime": "2025-05-15 13:53:611",
                "parent": "http://192.168.178.91:81/projects/xyw/"
            }],
        "links_skipped": [
            {
                "url": "The project guide",
                "state": "SKIPPED",
                "status": 0,
                "scantime": "2025-05-15 13:55:014",
                "parent": "http://192.168.178.91:81/linktree/"
            }]
    }
]

Output a statistic

To output statistics, there is of course a shortcode. This is what it looks like.


{{/* Broken links */}}
{{ $scratch_ExLinks := newScratch }}
{{ $scratch_InLinks := newScratch }}
{{ $lc_extern := site.Data.links_checked.external }} {{/* all links_broken */}}
{{ range $lc_extern }} {{/* Only one */}}
{{ $scratch_ExLinks.Add "total" .total }}
{{ $scratch_ExLinks.Add "ok" .ok }}
{{ $scratch_ExLinks.Add "broken" .broken }}
{{ $scratch_ExLinks.Add "skipped" .skipped }}
{{ $p := mul (float .broken) 100 }}
{{ $p = div (float $p) (float .total) }}
{{ $scratch_ExLinks.Add "percent-broken" ((float $p) | lang.FormatPercent 2) }}
{{ $p1 := mul (float .ok) 100 }}
{{ $p1 = div (float $p1) (float .total) }}
{{ $scratch_ExLinks.Add "percent-ok" ((float $p1) | lang.FormatPercent 2) }}
{{ end }}
{{ $lc_intern := site.Data.links_checked.internal }} {{/* all links_broken */}}
{{ range $lc_intern }} {{/* Only one */}}
{{ $scratch_InLinks.Add "total" .total }}
{{ $scratch_InLinks.Add "ok" .ok }}
{{ $scratch_InLinks.Add "broken" .broken }}
{{ $scratch_InLinks.Add "skipped" .skipped }}
{{ $p := mul (float .broken) 100 }}
{{ $p = div (float $p) (float .total) }}
{{ $scratch_InLinks.Add "percent-broken" ((float $p) | lang.FormatPercent 2) }}
{{ $p1 := mul (float .ok) 100 }}
{{ $p1 = div (float $p1) (float .total) }}
{{ $scratch_InLinks.Add "percent-ok" ((float $p1) | lang.FormatPercent 2) }}
{{ end }}

<span>Stats:</span>
	<span>
		{{ ($scratch_ExLinks.Get "total") }} external links, of which<br>{{ ($scratch_ExLinks.Get "ok") }} okay ({{ ($scratch_ExLinks.Get "percent-ok") }}), and {{ ($scratch_ExLinks.Get "broken") }} broken ({{ ($scratch_ExLinks.Get "percent-broken") }}).
	<br/>{{ ($scratch_InLinks.Get "total") }} internal links, of which<br>{{ ($scratch_InLinks.Get "ok") }} okay ({{ ($scratch_InLinks.Get "percent-ok") }}), and {{ ($scratch_InLinks.Get "broken") }} broken ({{ ($scratch_InLinks.Get "percent-broken") }}).
	<br/>Broken links are formatted with a strikethrough. I will correct them in time.
</span>

Mark links

To make navigation easier for visitors to the website, I would like to mark the links accordingly:

  • External links with an icon
  • Broken links crossed out

This is done by the shortcode render-link.html which is stored in the directory layouts/_default/_markup/.

{{ $link := .Destination | safeURL }}
{{ $url := urls.Parse $link }}
{{ $message := "The link ist okay (200)" }}
{{ $is_broken_link := false }}

{{ if $url.IsAbs }}
{{/* external link -> komplett überprüfen */}}

{{ $data := site.Data.links_checked.external }} {{/* all links_broken */}}
{{ $d := index $data 0 }} {{/* Only one */}}
{{ $links_broken_array := $d.links_broken }}

{{ range $links_broken_array }}

{{if strings.Contains $link .url }}

{{ if eq (string .status) "0" }} {{/* i have to cast .status to string to compare */}}
{{ $message = println "This link seems okay with status=" .status }}
{{ else if eq (string .status) "404" }}
{{ $message = println "Sorry, this link does no longer exist, status=" .status }}
{{ $is_broken_link = true }}
{{ else if eq (string .status) "403" }}
{{ $message = println "Sorry, access to this link seems forbidden, status=" .status }}
{{ $is_broken_link = true }}
{{ else }}
{{ $message = println "Sorry, this link seems broken with status=" .status }}
{{ $is_broken_link = true }}
{{ end }} {{/* if status */}}
{{ end }} {{/* if url */}}
{{ end }} {{/* range */}}

{{ else }} {{/* if $url.IsAbs */}}
{{/* internal link -> nur path-anteil überprüfen */}}

{{ $data := site.Data.links_checked.internal }} {{/* all links_broken */}}
{{ $d := index $data 0 }} {{/* Only one */}}
{{ $links_broken_array := $d.links_broken }}

{{ range $links_broken_array }}
{{ $url_test := urls.Parse .url }}
{{if strings.Contains $url.Path $url_test.Path }}

{{ if eq (string .status) "0" }}
{{ $message = println "This link seems to be okay, but with status=" .status }}
{{ else if eq (string .status) "404" }}
{{ $message = println "Sorry, this link does no longer exist, status=" .status }}
{{ $is_broken_link = true }}
{{ else if eq (string .status) "403" }}
{{ $message = println "Sorry, access to this link seems forbidden, status=" .status }}
{{ $is_broken_link = true }}
{{ else }}
{{ $message = println "Sorry, this link seems broken with status=" .status }}
{{ $is_broken_link = true }}
{{ end }} {{/* if status */}}
{{ end }} {{/* if url */}}
{{ end }} {{/* range */}}

{{ end }} {{/* if $url.IsAbs */}}

<a {{ if $is_broken_link }} class="brokenlink" title="{{ $message }}" {{end}} href="{{ $link }}" {{ with .Title}}
  title="{{ . }}" {{ end }}>{{ .Text | safeHTML }}{{ if strings.HasPrefix .Destination "http" }}<span
    style="white-space: nowrap;">&thinsp;<svg style="margin-bottom: 5px" focusable="false"
      class="icon icon-tabler icon-tabler-external-link" role="img" xmlns="http://www.w3.org/2000/svg" width="14"
      height="14" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
      stroke-linejoin="round">
      <title>external link</title>
      <path stroke="none" d="M0 0h24v24H0z" fill="none" />
      <path d="M12 6h-6a2 2 0 0 0 -2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-6" />
      <path d="M11 13l9 -9" />
      <path d="M15 4h5v5" />
    </svg></span>{{ end }}</a>

Repair links

To repair links, I need to know which file they are in. There is a private overview that can only be accessed on localhost and is excluded from the production build:

http://localhost:1313/internal/link-check/

Exclude the page from the production build:

  1. create directory /content/internal/.
  2. create a file _index.md with the following content
---
title: Internal
cascade:
- _target:
environment: production
build:
list: never
render: never
---

The file link-check.md with the following content is now added to the directory:

---
title: "Link Check"
description: ""
summary: ""
date: 2024-01-18T16:53:00+00:00
draft: false
weight: 60
---

## Summary
| External | Internal |
|---|---|
| {{</* broken-links state="INFO" mode="extern" >}} | {{< broken-links state="INFO" mode="intern" */>}} |

## Broken External
{{</* broken-links state="BROKEN" mode="extern" */>}}

## Skipped External

{{</* broken-links state="SKIPPED" mode="extern" */>}}

## Broken Internal
{{</* broken-links state="BROKEN" mode="intern" */>}}

## Skipped Internal
{{</* broken-links state="SKIPPED" mode="intern" */>}}

## OKAY Internal
{{</* broken-links state="OK" mode="intern" */>}}

The shortcode broken-links.html for this is stored in the directory layouts/shortcodes:

{{- $my_state := "" -}} {{/* INFO, BROKEN, SKIPPED, OK */}}
{{- $my_mode := "" -}} {{/* intern, extern, all */}}

{{ $list := slice -}}

{{/* Defekte Links, siehe auch: link-check.md, broken-links.html, render-link.html, links-check.ts */}}

{{- if .IsNamedParams -}}
{{- $my_state = .Get "state" | default "BROKEN" -}}
{{- $my_mode = .Get "mode" | default "intern" -}} {{/* intern extern all*/}}

{{- else -}}
{{ errorf "Shortcode zitate.html: No Named Parameters provided!" }}
{{- end -}}

{{ $data := slice }}

{{ if eq $my_mode "intern"}}
{{ $data = site.Data.links_checked.internal }}
{{ else if eq $my_mode "extern"}}
{{ $data = site.Data.links_checked.external }}
{{ else }} {{/* all */}}
{{ $data = site.Data.links_checked }}
{{ end }}

{{/* TODO: .Destination | safeURL */}}
{{/* https://discourse.gohugo.io/t/iterate-through-an-array-of-nested-maps-json-objects/15028 */}}

{{ if eq $my_state "INFO"}} {{/* INFO or LINKS */}}

{{/* OUPTUT INFO */}}

<ul>
  {{ range $data }} {{/* Only one */}}
  <li>scanned: {{ .scan_source }}</li>

  <li>lastrun: {{ .lastrun }}</li>
  <li>runtime: {{ math.Round .runtime }} {{ .runtime_unit }} ({{ .runtime }})</li>
  <li>finished: {{ .finished }}</li>

  <li>found: {{ .found }}</li>
  <li>dropped: {{ .dropped }}</li>

  <li>total: {{ .total }}</li>
  <li>ok: {{ .ok }}</li>
  <li>broken: {{ .broken }}</li>
  <li>skipped: {{ .skipped }}</li>
  {{ end }}
</ul>

{{ else }} {{/* INFO OR LINKS */}}

{{/* OUPTUT LINKS */}}

{{/* Alle Links sammeln die zu einem parent gehören */}}

{{ $groups := slice }}

{{ range $data }}
{{ range .links_broken }}
{{ $groups = $groups | append .parent }}
{{ end }}
{{ end }}

{{ $groups = $groups | uniq | sort }}

<ul>
  {{ range $groups }}
  {{ $u := urls.Parse . }}
  <li><a href="{{.}}">{{ $u.Path }}</a>

    <ul>
      {{ $d := index $data 0 }} {{/* Only one */}}

      {{ $source := $d.links_broken }}
      {{/* TODO immer andere Quelle: $d.links_broken ja nach $my_state "BROKEN" */}}
      {{ if eq $my_state "BROKEN"}}
      {{ $source = $d.links_broken }}
      {{ else if eq $my_state "SKIPPED"}}
      {{ $source = $d.links_skipped }}
      {{ else if eq $my_state "OK"}}
      {{ $source = $d.links_ok }}
      {{ end }}

      {{ range where $source "parent" . }}

      {{/* shorten links for display: hallo-welt.de/../slug-ende/ */}}
      {{/* https://gohugo.io/functions/urls/parse/ */}}

      {{ if eq $my_state .state }}

      {{ $u := urls.Parse .url }}
      {{ $path := ""}}
      {{ if ne $u.Path "/"}}
      {{ $path = $u.Path }}
      {{else}}
      {{ $path = $u.Path }}
      {{ end }}

      <li>
        <a href="{{.url}}">
          {{ if eq .state "BROKEN" }}
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
            stroke-linecap="round" stroke-linejoin="round" width="24" height="24" stroke-width="2">
            <path d="M9 15l3 -3m2 -2l1 -1"></path>
            <path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464"></path>
            <path d="M3 3l18 18"></path>
            <path d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463"></path>
          </svg>
          {{ else if eq .state "OK"}}
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
            stroke-linecap="round" stroke-linejoin="round" width="24" height="24" stroke-width="2">
            <path d="M9 15l6 -6"></path>
            <path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464"></path>
            <path d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463"></path>
          </svg>
          {{ else if eq .state "SKIPPED"}}
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
            stroke-linecap="round" stroke-linejoin="round" width="24" height="24" stroke-width="2">
            <path d="M9 15l6 -6"></path>
            <path d="M11 6l.463 -.536a5 5 0 1 1 7.071 7.072l-.534 .464"></path>
            <path d="M12.603 18.534a5.07 5.07 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463"></path>
            <path d="M16 19h6"></path>
          </svg>
          {{ end }}
        </a>├ {{ $u.Hostname }} ┼ {{ $path }}
      </li>

      {{ end }} {{/* eq my_State .state */}}
      {{ end }} {{/* range where $d.links_broken "parent" . */}}
    </ul>

  </li>
  {{ end }}
</ul>

{{end }} {{/* INFO ORE LINKS */}}

That is all. Good luck with fixing your links.

1 Like

On the Hugo documentation site we use a link render hook that performs real-time validation of internal links, including fragments (e.g., /functions/foo/#something). It is based on this code with the addition of some special handling unique to our documentation site.

With the amount of content restructuring we’ve done over the last 18 months, and the restructuring currently in progress, this hook is (at least to me) essential. Getting immediate feedback when, for example, you change the name of heading, saves a lot of time. If you only check your internal links periodically, you can end up with what I would describe as “stacked” broken links that can be a hassle to fix.

For external links, validation prior to or during deployment is sufficient.

2 Likes