Your RSS Feed Is Full of Broken Links

Open your own feed in a real feed reader. Not the XML in a browser tab, a reader. Scroll to a post with a diagram in it.
There is a decent chance the image is a broken-image icon and the “see the previous post” link goes nowhere. On your site, both work perfectly. Your build passed, your link checker passed, and your feed has been shipping dead links to every subscriber since the day you added it.
The cause is one character: the leading slash in /media/diagram.png.
Relative URLs need a document to be relative to
A root-relative URL like /posts/other-post/ is not an address. It is half of one. The browser supplies the other half from the document’s own URL: scheme, host, and, for path-relative URLs like ./diagram.png, the directory.
On your site, that works because the HTML is served from your origin. The moment the same HTML is served from somewhere else, the base changes and every relative URL resolves against it.
Your post body leaves your origin more often than you think:
- Feed readers render your
<content>inside their own page, on their own domain, or in a native app with no domain at all. - Email digests built from your feed (RSS-to-email services, newsletter tools) put the HTML in an inbox. An email has no base URL.
/media/diagram.pngresolves to nothing. - Cross-posts to platforms that import from your feed or accept pasted HTML get resolved against their host, so your link to
/posts/other-post/becomes a 404 on someone else’s site. - Read-later apps and archivers that store the article body out of context.
None of these fail loudly. The reader sees a broken image, shrugs, and moves on. You never find out.
Readers disagree about how to fix it for you
Some feed readers try to repair relative URLs. The trouble is that they do not agree on what to resolve against.
RSS 2.0 has no mechanism for declaring a base URL at all. Readers guess: some resolve against the item’s <link>, some against the feed’s own URL, some against the channel link, and some do not resolve anything. Those are four different answers for ./diagram.png, and at most one of them is right.
Atom is better on paper. It permits xml:base on any element, so an entry can declare the URL its content should resolve against. In practice, support across readers has always been uneven, and email tools that consume your feed generally ignore it. xml:base is worth setting if you publish Atom, but it is a hint, not a fix.
The only relative URL that renders correctly in every consumer is one that is not relative anymore.
Author relative, publish absolute
Do not start writing absolute URLs in your Markdown. Relative links are correct for your site: they survive a domain change, they work on preview deploys, and they keep your content portable. The fix belongs at the boundary, the point where HTML is about to leave your origin.
For most static sites, that boundary is the feed generator. The post body is rendered to HTML, dropped into <content> or <description>, and shipped. Insert one transform between those steps that rewrites every URL-bearing attribute to an absolute URL.
If your pipeline already uses unified and rehype (Astro, most Markdown setups, and many Next.js blogs do), it is a small plugin:
import { visit } from 'unist-util-visit'
import type { Root, Element } from 'hast'
const URL_ATTRS = ['href', 'src', 'poster', 'cite'] as const
function absolutize(value: string, base: string): string {
try {
return new URL(value, base).href
} catch {
return value
}
}
export function rehypeAbsoluteUrls(base: string) {
return (tree: Root) => {
visit(tree, 'element', (node: Element) => {
const props = node.properties ?? {}
for (const attr of URL_ATTRS) {
const value = props[attr]
if (typeof value === 'string') props[attr] = absolutize(value, base)
}
// srcset is a comma-separated list of "url descriptor" pairs
if (typeof props.srcSet === 'string' || Array.isArray(props.srcSet)) {
const raw = Array.isArray(props.srcSet) ? props.srcSet.join(', ') : props.srcSet
props.srcSet = raw
.split(',')
.map((candidate) => {
const [url, ...descriptor] = candidate.trim().split(/\s+/)
return [absolutize(url, base), ...descriptor].join(' ')
})
.join(', ')
}
})
}
}
A few details in there matter more than they look.
Pass the post’s URL as the base, not the site root. new URL('./diagram.png', 'https://example.com/') and new URL('./diagram.png', 'https://example.com/posts/my-post/') produce different results. Only the second matches what the browser did on your site. Use the post’s canonical URL, including the trailing slash if your site uses one, or path-relative assets will resolve one directory too high.
new URL() leaves absolute URLs alone. https://, mailto:, and tel: links pass through unchanged, and protocol-relative //cdn.example.com/x.js picks up the base’s scheme. You do not need a list of exceptions.
Fragment links need rewriting too. A table-of-contents link to #setup is fine on your page. In a feed reader, it points at a heading in the reader’s UI, or nowhere. Resolved against the post URL, it becomes https://example.com/posts/my-post/#setup, which takes the reader to the right place on your site.
Do not forget srcset. If your image pipeline emits responsive images, the src gets fixed and the browser happily ignores it in favor of a srcset full of relative candidates. It is the most common reason the fix “works” in testing and still shows broken images on a phone.
Run the plugin only in the feed pipeline, with the base computed per post. Your site’s own HTML keeps its relative URLs.
The same boundary exists in other places
The feed is the biggest leak, but it is not the only one. Anything that serializes your content for a consumer outside your origin has the same problem:
- JSON Feed
content_html, if you publish one alongside Atom or RSS. - Open Graph and Twitter card tags.
og:imagemust be an absolute URL. A relative one is not “resolved”; many scrapers simply drop the image and your link preview renders without one. - Structured data.
image,url, andauthor.urlin JSON-LD are expected to be absolute. - Anything you POST to another service: a cross-posting script, a newsletter API, a webhook that forwards the body to a chat channel.
Each one needs the same transform with the same base. Put it in one function and call it everywhere HTML leaves.
Make the build catch it
Link checkers crawl your site, where relative URLs are correct, so they will never flag this. You need a check pointed at the output that leaves.
A feed is a single file. Grep it after the build:
// scripts/check-feed-urls.mjs
import { readFileSync } from 'node:fs'
const feed = readFileSync('dist/feed.xml', 'utf8')
// Match href/src/poster values that do not start with a scheme.
// In a feed, attributes inside content are entity-escaped, so check both forms.
const pattern = /(?:href|src|poster)=(?:"|")(?!(?:[a-z][a-z0-9+.-]*:))([^"&]+)/gi
const offenders = [...feed.matchAll(pattern)].map((m) => m[1])
if (offenders.length > 0) {
console.error(`Feed contains ${offenders.length} non-absolute URL(s):`)
for (const url of new Set(offenders)) console.error(` ${url}`)
process.exit(1)
}
console.log('Feed URLs are all absolute.')
{
"scripts": {
"build": "astro build && node scripts/check-feed-urls.mjs"
}
}
It is a regex over XML, and it is crude on purpose. It does not need to parse the feed correctly; it needs to fail the build the first time someone adds a relative image to a post, and on any site with more than one author, someone will.
Then do the manual check once, for real. Subscribe to your own feed in a reader you did not build, and forward one feed item to an email address. If both render the images and the links go where they should, the boundary is holding.
The takeaway
Relative URLs are a contract between a document and the address it is served from. Your feed breaks that contract by design: it ships your document to addresses you do not control.
Keep writing relative links. They are the right choice for your site. But treat every path out of your origin, whether a feed, a card, a cross-post, or an email, as a serialization boundary, and absolutize at that boundary against the post’s own URL. Then add a ten-line check so the next relative image fails the build instead of failing silently in a few hundred inboxes.

