Skip to content
An RSS Feed in the Next.js App Router, in About Twenty Lines

21 July 2026

An RSS Feed in the Next.js App Router, in About Twenty Lines

nextjs

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.