Your blog has a feed at /blog/rss.xml. It renders fine in a browser. Then a
reader subscribes and reports that nothing shows up, or the W3C feed validator
tells you:
line 14, column 42: XML parsing error: <unknown>:14:42: not well-formed (invalid token)
Three problems account for almost every broken feed, and all three are invisible until someone else's parser sees them.
Problem 1: unescaped characters in titles
XML has five characters that cannot appear raw in text: &, <, >, " and
'. A post titled "Postgres & the 30-connection wall" produces:
<title>Postgres & the 30-connection wall</title>
A browser is forgiving. A strict XML parser (which is what every feed reader
uses) stops at the &, decides the document is malformed, and discards the
entire feed. Not the item: the feed. One post breaks all of them.
The fix is a four-line function applied to every piece of text you interpolate:
function escapeXml(value: string): string {
return value
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
Replace & first. If you do it last you double-escape the ampersands the other
replacements just introduced, and < becomes &lt;.
CDATA is the other common answer, and it works, until a title contains the
literal sequence ]]>, at which point you are back where you started. Escaping
is simpler and has no edge case.
Problem 2: the wrong date format
RSS 2.0 requires RFC 822 dates. ISO 8601 is not RFC 822:
<!-- rejected or silently ignored -->
<pubDate>2026-04-18</pubDate>
<!-- correct -->
<pubDate>Sat, 18 Apr 2026 00:00:00 GMT</pubDate>
Date.prototype.toUTCString() produces exactly the right format, so the
conversion is one line:
function rfc822(iso: string): string {
return new Date(`${iso}T00:00:00Z`).toUTCString();
}
Note the explicit T00:00:00Z. new Date("2026-04-18") is parsed as UTC while
new Date("2026-04-18 00:00") is parsed as local time, so without the Z your
feed dates shift by a day for readers west of Greenwich, and the shift depends
on the timezone of the machine that ran the build.
Problem 3: the feed does not say where it lives
Validators flag a feed with no self-reference, and some aggregators use it to de-duplicate subscriptions after a domain change:
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<atom:link href="https://example.com/blog/rss.xml" rel="self" type="application/rss+xml" />
The xmlns:atom declaration on the <rss> element is required for that tag to
be legal. Adding the <atom:link> without the namespace makes things worse, not
better.
Putting it together as a static route
// src/app/blog/rss.xml/route.ts
export const dynamic = "force-static";
export function GET(): Response {
const site = appUrl();
const posts = publishedPosts();
const items = posts
.map((post) => {
const url = `${site}/blog/${post.slug}`;
return [
" <item>",
` <title>${escapeXml(post.title)}</title>`,
` <link>${escapeXml(url)}</link>`,
` <guid isPermaLink="true">${escapeXml(url)}</guid>`,
` <pubDate>${rfc822(post.date)}</pubDate>`,
` <description>${escapeXml(post.description)}</description>`,
" </item>",
].join("\n");
})
.join("\n");
return new Response(xml, {
headers: {
"content-type": "application/rss+xml; charset=utf-8",
"cache-control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",
},
});
}
export const dynamic = "force-static" matters: without it, a route handler
that reads anything request-shaped becomes dynamic and your feed is rebuilt on
every poll. Feed readers poll hard: hourly, from every subscriber. Prerender
it.
The content-type must be application/rss+xml. Serve text/xml and some
readers refuse it; serve text/html (which is what you get if you forget the
header entirely) and every one of them does.
<guid> is an identity, not a link
<guid> is how a reader decides whether an item is new. Two rules follow:
- It must be stable. If you regenerate guids, or include a build id or a timestamp in them, every subscriber sees every post as unread on every deploy. That is how a blog gets unsubscribed from.
- With
isPermaLink="true"it must be a real URL. Since slugs never change, the post URL is the natural choice. If your slugs are not permanent, setisPermaLink="false"and use an id you control.
Verifying
bun run build && bun run start
curl -s http://localhost:3000/blog/rss.xml | head -40
Then check three things:
- Well-formed. Pipe it through a parser:
curl -s ... | xmllint --noout -exits non-zero on malformed XML. - Valid. Paste the deployed URL into the W3C Feed Validation Service. It
catches missing
<description>, bad dates and the missing self link. - Actually readable. Subscribe with a real client. It is the only way to
find out that your
<description>is empty because you interpolated the wrong field.
Finally, link it from the page so people can find it, and add it to your metadata so browsers and readers can discover it automatically:
alternates: {
canonical: "/blog",
types: { "application/rss+xml": "/blog/rss.xml" },
}