# 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.

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:

```xml
<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:

```ts
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:

```xml
<!-- 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:

```ts
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:

```xml
<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

```ts
// 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, set
  `isPermaLink="false"` and use an id you control.

## Verifying

```bash
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:

```ts
alternates: {
  canonical: "/blog",
  types: { "application/rss+xml": "/blog/rss.xml" },
}
```

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
