Check for broken links before deploying Hugo
There are a lot of internal links on the site. I make an effort to confirm that links work when they’re first created, but mistakes happen. Links to heading IDs are also liable to break when heading text is edited.
Automate the process of crawling and checking links
There are a few options:
- lychee (GitHub): a stream-based link checker written in Rust. It’s probably fast.
- muffet (GitHub): “Massive speed.” Written in Go.
- linkchecker (GitHub): It’s been around a long time. A Python library.
Linkchecker
I’ll use linkchecker for now. I may end up wanting to check links on the server (through an API route). It would be technically easiest to use a Python library, but possibly speed will be an issue.
Install and run linkchecker:
pip install linkchecker
hugo server &
linkchecker http://localhost:1313
The results are mostly Valid: 200 OK, but with an [http-redirected] warning:
URL `/notes/introduction-to-complex-numbers#three-main-forms-of-complex-numbers'
Name `Three main forms of complex\nnumbers'
Parent URL http://localhost:1313/notes/halleys-root-finding-fractal-code-analysis/, line 213, col 5
Real URL http://localhost:1313/notes/introduction-to-complex-numbers/#three-main-forms-of-complex-numbers
Check time 4.152 seconds
D/L time 0.000 seconds
Size 17.23KB
Warning [http-redirected] Redirected to
`http://localhost:1313/notes/introduction-to-complex-numbers/#three-main-forms-of-complex-numbers'
status: 301 Moved Permanently.
Result Valid: 200 OK
The 301 redirect warnings are because of a trailing slash mismatch:
URL `/notes/introduction-to-complex-numbers#three-main-forms-of-complex-numbers'
Real URL http://localhost:1313/notes/introduction-to-complex-numbers/#three-main-forms-of-complex-numbers
Note the absence of the / following numbers in the first URL. The 301 redirects are
happening because the actual URL is /notes/introduction-to-complex-numbers/#three-main-forms-of-complex-numbers' (with the trailing slash). In development, the Hugo server is handling the redirect. I hadn’t noticed the issue because in production the Nginx server is handling the redirect with the $uri/ part of this location block:
location / {
try_files $uri $uri.html $uri/ =404;
}
It’s not a big issue, but I’ll fix the redirects over time.
The Python linkchecker library is very slow though. If I get around to checking for broken links on the server (via an API that the site’s connected to), linkchecker would be too slow.
lychee
Cargo is installed locally:
❯ cargo --version
cargo 1.92.0 (344c4567c 2025-10-21) (Arch Linux rust 1:1.92.0-1)
I think I can compile and install lychee with:
cargo install lychee
# ...
Installing /home/scossar/.cargo/bin/lychee
Installed package `lychee v0.22.0` (executable `lychee`)
warning: be sure to add `/home/scossar/.cargo/bin` to your PATH to be able to run the installed binaries
❯ echo 'export PATH=$PATH:/home/scossar/.cargo/bin' >> ~/.bashrc
❯ lychee --version
lychee 0.22.0
Getting lychee to crawl all internal links took some trial and error. The correct approach is to
build the site with hugo build, then run:
❯ lychee './public/**/*.html' --verbose --base-url http://localhost:1313
Alternatively:
❯ lychee public/ --root-dir "$(pwd)/public"
Sample output:
# ...
Issues found in 11 inputs. Find details below.
[public/notes/a-simple-document-for-testing/index.html]:
[404] http://localhost:1313/notes/parsing-html-file-with-lxml | Rejected status code (this depends on your "accept" configuration): Not Found
[public/notes/alchemy-restored/index.html]:
[403] https://www.journals.uchicago.edu/doi/10.1086/660139 | Rejected status code (this depends on your "accept" configuration): Forbidden
[403] https://www.jstor.org/stable/10.1086/660139?read-now=1&seq=1#page_scan_tab_contents | Rejected status code (this depends on your "accept" configuration): Forbidden
# ...
Only check internal links
To only check internal links, use the --ofline flag:
❯ lychee public/ --root-dir "$(pwd)/public" --offline
Check anchor links
Checking anchor links is what motivated me to do this. Use the --include-fragments flag to get
lychee to check for anchor links:
❯ lychee public/ --root-dir "$(pwd)/public" --offline --include-fragments
1794/1794 ━━━━━━━━━━━━━━━━━━━━ Finished extracting links Issues found in 3 inputs. Find details below.
[public/notes/halleys-root-finding-fractal-code-analysis/index.html]:
[ERROR] file:///home/scossar/zalgorithm/public/notes/introduction-to-complex-numbers#three-main-forms-of-complex-numbers | Cannot find fragment: Fragment not found in document. Check if fragment exists or page structure
[ERROR] file:///home/scossar/zalgorithm/public/notes/polynomial-functions#general-form-for-finding-roots-of-complex-numbers | Cannot find fragment: Fragment not found in document. Check if fragment exists or page structure
[ERROR] file:///home/scossar/zalgorithm/public/notes/polynomial-functions#solving-polynomial-equations-for-complex-roots | Cannot find fragment: Fragment not found in document. Check if fragment exists or page structure
[public/notes/real-and-complex-numbers-in-space/index.html]:
[ERROR] file:///home/scossar/zalgorithm/public/notes/polynomial-functions#solving-polynomial-equations-for-complex-roots | Cannot find fragment: Fragment not found in document. Check if fragment exists or page structure
[public/notes/simple-algorithms/index.html]:
[ERROR] file:///home/scossar/zalgorithm/public/notes/cell-bubble-sort#confirming-that-cell-swapping-works-as-expected | Cannot find fragment: Fragment not found in document. Check if fragment exists or page structure
lychee is fast, but it’s going to require some configuration to get it to correctly resolve anchor
links. For example, the following anchor link isn’t broken. lychee is trying to resolve it on the
directory (/cell-bubble-sort/) and not on
/cell-bubble-sort/index.html#confirming-that-cell-swapping-works-as-expected.
[public/notes/simple-algorithms/index.html]:
[ERROR] file:///home/scossar/zalgorithm/public/notes/cell-bubble-sort#confirming-that-cell-swapping-works-as-expected | Cannot find fragment: Fragment not found in document. Check if fragment exists or page structure
Confirming that internal links are not broken with a Hugo template
External links can be checked with either the lychee or linkchecker
approaches shown above. The best approach I’ve found for checking internal links during the
development process is to add a /layouts/_markup/render-link.html template to the site. That
approach is suggested on the Hugo forum: https://discourse.gohugo.io/t/tutorial-how-to-check-broken-links-in-hugo/54750. The template and instructions on how to use it are here: https://www.veriphor.com/articles/link-and-image-render-hooks/#link-render-hook.
It deserves its own note: notes / Checking internal Hugo links with render link template.