What is the officially recommended syntax for relative links?

Hi,

I’m trying to understand the correct way to write relative links in markdown. To investigate this, I created a custom render-link.html:

{{- $u := urls.Parse .Destination -}}
{{- with .PageInner.GetPage (strings.TrimPrefix "./" $u.Path) }}
Success: {{ .LinkTitle }}
{{- else }}
Failed to get page: {{ $u.Path }}
{{- end }}

with directory structure:

❯ tree content/posts
content/posts
├── _index.md
├── post-3
│   ├── bryce-canyon.jpg
│   └── index.md
└── post-4
    └── index.md

From post-3/index.md, I tested these different relative link syntaxes:

1. [a](../post-4)           -> Failed
2. [b](../post-4/)          -> Failed
3. [c](../post-4/index.md)  -> Success
4. [d](./post-4)            -> Success
5. [e](./post-4/index.md)   -> Success
6. [f](post-4)              -> Success

In my understanding, a, b, c, and f should work, while d and e should fail because they point to posts/post-3/post-4.

It would be great if the behavior could match VSCode’s path auto-completion logic (a, b, c).

So what is the officially recommended syntax for writing relative links between pages?

Thank you!

I would use site-relative link destinations instead of page-relative destinations:

  1. Page-relative destinations can be difficult to reason about
  2. Site-relative destinations from Page A to Page B won’t break if you move Page A

Having said that, with a well-formed render hook that uses PageInner.GetPage to resolve link destinations, I would use logical paths, not file paths. What’s the difference?

source (file paths)
content/
├── s1/
│   ├── p1/
│   │   └── index.md
│   ├── p2.md
│   └── _index.md
├── s2/
│   ├── p3/
│   │   └── index.md
│   ├── p4.md
│   └── _index.md
└── _index.md
source (logical paths)
content/
├── s1/
│   ├── p1
│   └── p2
└── s2/
    ├── p3
    └── p4
  source destination page-relative URL
file path s1/p1/index.md s1/p2.md ../p2.md
logical path s1/p1 s1/p2 p2
 
file path s1/p1/index.md s2/p3/index.md ../../s2/p3/index.md
logical path s1/p1 s2/p3 ../s2/p3

More examples here:
https://gohugo.io/methods/page/getpage/

3 Likes

This example site might be helpful, providing examples of:

  1. Page-relative link destinations using file paths (compatible with VS Code’s path completion feature)
  2. Page-relative link destinations using logical paths
  3. Site-relative link destinations using logical paths (my preference)
git clone --single-branch -b hugo-forum-topic-56603 https://github.com/jmooring/hugo-testing hugo-forum-topic-56603
cd hugo-forum-topic-56603
hugo server

The link render hook is identical to Hugo’s embedded link render hook, except that it throws an error if unable to resolve the link destination to a page, a page resource, or a global resource.

Thanks for looking into this. I agree that page-relative syntax is not intuitive.

For cross-section links, I believe most users would prefer site-relative links because they are easier to read. That said, my question can be simplified to adjacent linking, specifically parent/child/sibling links.

Regarding the example site, it took me some time to understand the results, and I noticed several noteworthy behaviors:

  1. Self-linking: The [p1](./index.md) link in s1/p1/index.md resolves to /s1 instead of /s1/p1.

  2. Syntaxes that include file names (*.md) behave as follows:

    1. With no changes (commit 3ef7ffd4), everything works as expected.
    2. After removing render-link.html, it still works.
    3. After further removing the markup settings, the links break and resolve to the raw *.md files.

At this point, the site is complete using only the default settings, no markup or render hooks are used. Continuing from step 2-3, I tested additional cases beyond the *.md syntaxes:

  1. Cross-section links and page-relative links also break.
  2. After adding slug: p1-slug to p1/index.md, links in s1/p2 fails to resolve /s1/p1-slug.
  3. After restoring hugo.toml and render-link.html, /s1/p1-slug resolves successfully.

Tested using rm -rf public && hugo server --disableFastRender --ignoreCache each run with hugo v0.154.5-a6f99cca223a29cad1d4cdaa6a1a90508ac1da71+extended darwin/arm64 BuildDate=2026-01-11T20:53:23Z VendorInfo=gohugoio

I fixed the self-referential link, which was only present for symmetry. Do you have any other questions? Test results without the render hook in place (either mine or the embedded version) are irrelevant.

Removing this means using the embedded render hook, and I expect the same syntax to work whether useEmbedded is set or the almost identical custom render hook is used.

(The main issue is resolved: use site-relative links.)

The only difference between the embedded link render hook and the one I included in my example is that the embedded render hook passes the destination through (untouched) when it cannot resolve the destination to a page. The render hook in my example, on the other hand, throws an error.

I understand their difference. What I’m saying is that none of your examples work when nothing is set. This is a bug.

What does that mean?

It can be reproduced by removing the render hook and the markup settings. It appears that the embedded render hooks are not applied when no settings are defined.

That’s exactly what should be happening:
http://localhost:1313/configuration/markup/#renderhookslinkuseembedded

This isn’t a bug.

I don’t get it. The documentation says it uses auto if nothing is set, and auto uses the embedded render hook.

Automatically use the embedded link render hook for multilingual single-host sites.

Is your site multilingual single-host? If so, what version of Hugo are you using?

Before I do that, please answer my questions.

I used exactly the same site as your example. The only difference is that I removed the markup configuration and the render hook. I hope this is clear enough.

Again, please answer this question.

If you are referring to this, the example site is a non-multilingual single-host site with no multilingual settings.

Automatically use the embedded link render hook for multilingual single-host sites .

I’m not sure how to make that any clearer.

Sorry if I didn’t read it carefully—it only works for multilingual sites. In any case, thank you very much for the clarification. Your example is extremely helpful, and I really appreciate it.

I’m not sure what that means.

There are four possible values for the useEmbedded configuration parameter. The fallback, always, and never options apply to both monolingual and multilingual sites.