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.