21 July 2026
An RSS Feed in the Next.js App Router, in About Twenty Lines
RSS has a reputation for being legacy plumbing, which is exactly why it's worth adding while a blog only has a handful of posts instead of retrofitting it once there's an archive to backfill.
The route
The App Router turns a feed into a plain route handler that returns XML instead of a page:
// app/rss.xml/route.ts
export async function GET() {
const posts = await getPublishedPosts()
const items = posts
.map(
(post) => `
<item>
<title>${escapeXml(post.title)}</title>
<link>${SITE_URL}/blog/${post.slug}</link>
<description>${escapeXml(post.excerpt)}</description>
<pubDate>${new Date(post.publishedAt).toUTCString()}</pubDate>
<guid>${SITE_URL}/blog/${post.slug}</guid>
</item>`
)
.join('')
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<title>dev-hub blog</title>
<link>${SITE_URL}</link>
${items}
</channel></rss>`
return new Response(xml, { headers: { 'Content-Type': 'application/xml' } })
}
Why it reuses, not duplicates, the query
getPublishedPosts() is the same function /blog calls — the one with the publishedAt <= now() filter baked in. The feed and the page can never disagree about what's published, because there's exactly one place that decision is made.
The easy-to-forget part
XML-escaping the title, excerpt, and any user-entered string is the detail that's invisible in testing with clean sample data and very visible the first time a post title contains an ampersand or angle bracket and breaks every feed reader that tries to parse it. It's a five-minute function, but only if it's written before the first real post ships, not after a reader files a "your feed is broken" report.