When building a static site with Zola, one common issue is discovering broken internal links only after deployment. External links pointing to your own content (like [post](/blog/my-post/)) are treated as external by Zola's link checker, missing valuable validation opportunities. Here's how I converted all my external self-referencing links to proper internal links and the benefits it provided.

Initially, my blog posts contained links like this:

[important post](/blog/insights-google-search-console/#trailing-slash-inconsistency)
[related article](/blog/using-uuid-in-atom-feed/#url-vs-urn-which-one-to-choose)

While these work fine for readers, Zola treats them as external links because they start with /. This means:

  • No link validation during build
  • No anchor checking
  • Potential for broken links to slip through

Not sure how did I found about this just now. But hey, learning something new every day!

I actually noticed discovered this issue when running zola check:

Checking site...
Checking all internal links with anchors.
> Successfully checked 1 internal link(s) with anchors.

I mean, the same output is present with zola build or even zola serve but who reads that when there are no red errors, anyway? However, when I searched for all my internal links with anchors:

rg '\[([^\]]+)\]\((/blog/[^)]+)\)' | rg '#'

I found 6 different links, not one! The discrepancy revealed that only one link was being treated as truly internal - a self-referencing anchor link:

[Postgres database](#postgres-database)

Clearly something was eluding me.

Zola provides special syntax for internal links using @/ which enables proper validation. I needed to convert links from:

[text](/blog/post-name/#anchor)

To:

[text](@/blog/post-name/index.md#anchor)

It is a little bit harder this way because you need to link actual physical file, not just URL it will be assigned. But it has a meaning, as URL can be configured and changed at will.

Step 1: Folderizing Posts#

Since some of my posts were already in folders (for images, if the post included some) and others weren't, I first standardized the structure by "folderizing" all posts, with the script I was using:

#!/bin/bash

for file in content/blog/*.md
    if test -f "$file" && not string match -q "*/index.md" "$file"
        filename=$(basename "$file" .md)
        mkdir "content/blog/$filename"
        mv "$file" "content/blog/$filename/index.md"
    end
end

Before, the folder structure looked like this:

content/blog/
├── setting-url-prefix-in-zola.md
├── using-electronic-id-on-arch.md
├── optimize-many-pdfs-at-once.md
├── high-cpu-usage-with-pcscid.md
├── install-php7-with-composer.md
└── running-mastodon-with-docker/     # already folderized
    └── index.md
    └── docker.png

After it was consistent:

content/blog/
├── setting-url-prefix-in-zola/
│   └── index.md
├── using-electronic-id-on-arch/
│   └── index.md
├── optimize-many-pdfs-at-once/
│   └── index.md
├── high-cpu-usage-with-pcscid/
│   └── index.md
├── install-php7-with-composer/
│   └── index.md
└── running-mastodon-with-docker/     # unchanged
    └── index.md
    └── docker.png

This is also important because at a time of writing an internal link, you probably do not remember if the post is "folderized" or not. Having it consistent removes potential setbacks and keeps mental load lower. Having it consistent however keeps our next step simpler, preventing a regex hell.

After several iterations, the final working command for fish shell was:

sd '\[([^\]]+)\]\(/blog/([^/)]+)/?(\)|#[^)]*\))' '[$1](@/blog/$2/index.md$3' content/blog/**/*.md

What it does:

  • \[([^\]]+)\] - captures the link text
  • \(/blog/([^/)]+)/? - matches /blog/post-name with optional trailing slash
  • (\)|#[^)]*\)) - captures either ) or #anchor)
  • Replaces with proper Zola internal link syntax

What it prevents:

  • Removing trailing punctuation: Early regex captured commas and other punctuation

    -[could not write from the phone](/blog/post/),
    +[could not write from the phone](@/blog/post.md)
    
  • Matching external URLs: Overly broad regex matched /blog/ in external URLs like https://docker.com/blog/post, creating malformed links, which produced errors with zola check:

    could not parse domain `https://www.docker.com@/blog/post/index.md` from link: `empty host`
    

Running zola check was a success, git diff also looked nice, so it appears to be correct at least for my use case.

After conversion, zola check --skip-external-links now properly validates all internal links:

``Checking all internal links with anchors.
> Successfully checked 7 internal link(s) with anchors.
-> Site content: 236 pages (0 orphan), 3 sections
Done in 74ms.

The output is a little bit misleading though. It states that it checks 7 internal links, but it in fact checks all internal links! It only states that from all the internal links, 7 of them also have an anchor, and this anchor is checked further, meaning not only the referenced page exists internally but also the heading can be reached via its anchor. Just try to include a typo in an internal link without anchor and run zola build:

Building site...
Error: Failed to build the site
Error: Failed to render content of ~/my-blog/content/blog/my-post/index.md
Error: Reason: Broken relative link `@/blog/another-post/TYPO-index.md` in blog/my-post/index.md

See? Very good at catching errors early.

Key Advantages#

  1. Build-time Link Validation Zola now catches broken internal links during build, preventing deployment of broken sites.
  2. Anchor Verification The system validates that referenced anchors actually exist in target posts.
  3. Refactoring Safety When renaming posts or restructuring content, Zola will catch broken internal references.
  4. Consistent Structure All internal links now follow the same @/blog/post/index.md pattern, regardless of the original file structure.
  5. Development Confidence No more guessing whether internal links work - the build process guarantees it.

Conclusion#

Converting external self-referencing links to Zola's internal link syntax transforms link management from a manual, error-prone process to an automated, validated system. The initial setup requires some regex work, but the long-term benefits of catching broken links at build time far outweigh the effort.

If you're running a Zola site with internal links, I highly recommend making this conversion. Your future self will thank you when refactoring content or restructuring your site. Enjoy!