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 titleA 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, "&").replace(/</g, "<").replace(/>/g, ">");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 <a> 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 "&lt;a&gt;" in the file and shows as <a> in the readerThe 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, "&").replace(/</g, "<").replace(/>/g, ">");
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
- Next.js image sitemap: the images field and the & that breaks it
- Next.js canonical URL: 4 traps I tested and the fix
- Next.js trailingSlash: why your sitemap URLs redirect
- Next.js metadata not working? 7 causes I tested
- Next.js metadata checker