I would use site-relative link destinations instead of page-relative destinations:
Page-relative destinations can be difficult to reason about
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?
This example site might be helpful, providing examples of:
Page-relative link destinations using file paths (compatible with VS Code’s path completion feature)
Page-relative link destinations using logical paths
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:
Self-linking: The [p1](./index.md) link in s1/p1/index.md resolves to /s1 instead of /s1/p1.
Syntaxes that include file names (*.md) behave as follows:
With no changes (commit 3ef7ffd4), everything works as expected.
After removing render-link.html, it still works.
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:
Cross-section links and page-relative links also break.
After adding slug: p1-slug to p1/index.md, links in s1/p2 fails to resolve /s1/p1-slug.
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.
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.
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.
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.
There are four possible values for the useEmbedded configuration parameter. The fallback, always, and never options apply to both monolingual and multilingual sites.