Skip to content

An RSS feed that actually validates

Unescaped ampersands, ISO dates and a missing atom:link are why feed readers reject your feed. Generate it as a static route with escaped text.

MDX blog3 min readships at docs/solutions/blog-mdx/rss-that-validates.md

Tags: rss · feeds · seo · route-handlers · nextjs

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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&apos;");
}

Replace & first. If you do it last you double-escape the ampersands the other replacements just introduced, and &lt; becomes &amp;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 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, set isPermaLink="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:

  1. Well-formed. Pipe it through a parser: curl -s ... | xmllint --noout - exits non-zero on malformed XML.
  2. Valid. Paste the deployed URL into the W3C Feed Validation Service. It catches missing <description>, bad dates and the missing self link.
  3. 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" },
}