afaqrashid.com  ·  portfolio · 2026 ed.

Next.js RSS Feed in the App Router: What I Tested

Next.js has no feed file convention like sitemap.ts, so an RSS feed is a route handler at app/feed.xml/route.ts that returns XML as a string. Written the obvious way, that handler runs on every request, is served as text/plain, and breaks on the first post title with an & in it.

I tested this on Next.js 16.4.4 with next build and next start, reading the build output, the response headers with curl, and the XML with xmllint. Then I added the working version to this site, which now has a feed at /feed.xml.

The obvious version

// app/feed.xml/route.ts
export async function GET() {
  const items = posts
    .map((p) => `<item><title>${p.title}</title><link>https://example.com/${p.slug}</link></item>`)
    .join("");
  return new Response(`<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel><title>Blog</title>${items}</channel></rss>`);
}

It returns 200 and the XML looks right in a browser. Three things are wrong with it.

1. It runs on every request

The build output marked it ƒ (Dynamic), server-rendered on demand. That was true for a handler that used nothing dynamic at all, one that returned a fixed string. GET route handlers aren't cached by default, so every feed reader poll runs your code and whatever data fetching is inside it. The response had no Cache-Control header.

Route (app)           Revalidate  Expire
├ ƒ /feed.xml                          no config
├ ƒ /plain.xml                         fixed string, no config
├ ○ /reval.xml                1h      1y   revalidate = 3600
└ ○ /static-feed.xml                   dynamic = "force-static"

Either segment option makes it static. With dynamic = "force-static" the feed was built once at deploy and served with cache-control: s-maxage=31536000 and x-nextjs-cache: HIT. With revalidate = 3600 it was also built at deploy, served with s-maxage=3600, and rebuilt in the background after an hour. Use force-static if the posts live in your repo, since a deploy changes them anyway. Use revalidate if they come from a CMS.

2. It is served as text/plain

new Response() with a string body sets the Content-Type to text/plain;charset=UTF-8. That is the Fetch API's default, and Next.js doesn't change it for a route named feed.xml. Set the header yourself:

return new Response(xml, {
  headers: { "Content-Type": "application/rss+xml; charset=utf-8" },
});

3. One & makes it invalid XML

A post titled Tom & Jerry <test> went into the XML as it was. xmllint stopped at the first character:

f.xml:1: parser error : xmlParseEntityRef: no name
...<item><title>Tom & Jerry <test></title>
f.xml:1: parser error : Opening and ending tag mismatch: test line 1 and title

A feed reader can reject the whole feed over one bad character, not just that item. Escape every value you put in the XML: titles, descriptions, and URLs too, since query strings carry & characters.

const text = (value: string) =>
  value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");

Descriptions are HTML, so escape them twice

Escaping once makes valid XML, but feed readers treat <description> as HTML. When I ran this site's feed through the W3C Feed Validator, one post description that mentioned a plain <a> tag came back as Invalid HTML: the reader decodes &lt;a&gt; back to <a> and sees an unclosed link. To show the characters as text, escape for HTML first, then for the XML around it:

const html = (value: string) => text(text(value));
// "<a>" becomes "&amp;lt;a&amp;gt;" in the file and shows as <a> in the reader

The same run flagged my dates as Implausible date. I had set each pubDate to noon Pakistan time on the day it was published, which was still hours in the future for a post published that morning. Midnight on the publish day fixed it.

The RSS link disappears from your pages

Feed readers and browser extensions find a feed through a link tag in the page head. Next.js writes one from alternates.types:

// app/layout.tsx
export const metadata: Metadata = {
  metadataBase: new URL("https://example.com"),
  alternates: {
    types: { "application/rss+xml": [{ url: "/feed.xml", title: "Blog" }] },
  },
};
<link rel="alternate" type="application/rss+xml" href="https://example.com/feed.xml" title="Blog"/>

That tag was on the home page. On a page that set alternates: { canonical: "/post" }, it was gone. Next.js merges metadata one top-level key at a time, so a page's alternates object replaces the layout's whole alternates object, types included. Most sites set a canonical on every page, so the feed link ends up on almost none of them.

The fix is a helper that each page uses for its alternates:

// lib/site.ts
export const FEED_TYPES = {
  "application/rss+xml": [{ url: "/feed.xml", title: "Blog" }],
};

export const pageAlternates = (path: string) => ({ canonical: path, types: FEED_TYPES });

// app/blog/[slug]/page.tsx
alternates: pageAlternates(`/blog/${slug}`),

trailingSlash doesn't touch it

With trailingSlash: true, /feed.xml still returned 200 with no redirect, and the link tag stayed /feed.xml. Next.js didn't redirect the path with a file extension. A feed route without one, like /blog-feed, returned 308 to /blog-feed/. Name the folder feed.xml and the setting doesn't matter.

The full route

This is close to what this site runs. The posts come from a file in the repo, so it uses force-static.

// app/feed.xml/route.ts
import { posts } from "@/data/posts";

export const dynamic = "force-static";

const BASE = "https://example.com";
const text = (value: string) =>
  value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const html = (value: string) => text(text(value));

export function GET() {
  const items = posts
    .map((post) => {
      const url = `${BASE}/blog/${post.slug}`;
      return `<item>
  <title>${text(post.title)}</title>
  <link>${text(url)}</link>
  <guid isPermaLink="true">${text(url)}</guid>
  <description>${html(post.description)}</description>
  <pubDate>${new Date(post.published).toUTCString()}</pubDate>
</item>`;
    })
    .join("\n");

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
  <title>Blog</title>
  <link>${BASE}/blog</link>
  <description>Posts from example.com</description>
  <atom:link href="${BASE}/feed.xml" rel="self" type="application/rss+xml" />
${items}
</channel>
</rss>`;

  return new Response(xml, {
    headers: { "Content-Type": "application/rss+xml; charset=utf-8" },
  });
}

pubDate has to be an RFC 822 date, which toUTCString() gives you. The atom:link with rel="self" isn't required by RSS 2.0, but the W3C Feed Validator recommends it and warns when it's missing.

Check your own feed

curl -s -D - -o feed.xml https://your-site.com/feed.xml | grep -i content-type
xmllint --noout feed.xml && echo valid
open "https://validator.w3.org/feed/check.cgi?url=https://your-site.com/feed.xml"
curl -s https://your-site.com/any-page | grep -o '<link rel="alternate"[^>]*>'

You want application/rss+xml, valid, no warnings from the W3C Feed Validator, and the alternate link on an inner page, not just the home page.

Related

Sources