Toby Allen

Adding an RSS Feed to a Next.js App Router Site

· Next.js, Web Development

A reader asked me to add an RSS feed to this blog. It seemed like a reasonable request - RSS is simple, well-understood, and plenty of people still read through feed readers. In this short post I will detail how I added RSS 2.0 support to this site using a Next.js App Router Route Handler with no additional dependencies.

The interesting part is how little work it turned out to be. Next.js handles more of this than you might expect. Sonnet and I managed to bang it out in less than ten minutes. BONKERS!

The steps we will cover in this post are as follows.

  1. Create a Route Handler to generate the RSS XML
  2. Wire up autodiscovery via metadata.alternates
  3. Add a visible link in the site header and footer

The Route Handler

The feed lives at /feed.xml. In App Router that means creating app/feed.xml/route.ts.

RSS 2.0 XML is straightforward enough to template by hand - no rss or feed package required. The only thing to be careful of is XML-escaping the content fields, since article titles and excerpts can contain characters like & and < that would break the document.

import { getAllArticles } from "@/lib/articles";
 
const SITE_URL = "https://www.tobytes.com";
 
function decodeHtmlEntities(str: string): string {
  return str
    .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)))
    .replace(/&#x([0-9a-fA-F]+);/g, (_, code) => String.fromCharCode(parseInt(code, 16)))
    .replace(/&amp;/g, "&")
    .replace(/&lt;/g, "<")
    .replace(/&gt;/g, ">")
    .replace(/&quot;/g, '"')
    .replace(/&apos;/g, "'");
}
 
function escapeXml(str: string): string {
  return decodeHtmlEntities(str)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&apos;");
}
 
export async function GET() {
  const articles = getAllArticles();
 
  const items = articles
    .map((article) => {
      const url = `${SITE_URL}/articles/${article.slug}`;
      const pubDate = new Date(article.date).toUTCString();
      const description = article.excerpt ? escapeXml(article.excerpt) : "";
      return `    <item>
      <title>${escapeXml(article.title)}</title>
      <link>${url}</link>
      <guid isPermaLink="true">${url}</guid>
      <pubDate>${pubDate}</pubDate>
      ${description ? `<description>${description}</description>` : ""}
    </item>`;
    })
    .join("\n");
 
  const lastBuildDate = articles[0]
    ? new Date(articles[0].date).toUTCString()
    : new Date().toUTCString();
 
  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Toby Allen</title>
    <link>${SITE_URL}</link>
    <description>Writing on identity, access management, and security by Toby Allen.</description>
    <language>en</language>
    <lastBuildDate>${lastBuildDate}</lastBuildDate>
    <atom:link href="${SITE_URL}/feed.xml" rel="self" type="application/rss+xml"/>
${items}
  </channel>
</rss>`;
 
  return new Response(xml, {
    headers: {
      "Content-Type": "application/rss+xml; charset=utf-8",
      "Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
    },
  });
}

The atom:link element with rel="self" is worth including - it declares the canonical URL of the feed itself, is part of the Atom namespace extension for RSS 2.0, and some feed validators flag its absence. The Cache-Control header tells Vercel's CDN to cache the response for an hour with a stale-while-revalidate window of a day, which is sensible for a feed that only changes when a new post is published.

Autodiscovery

Feed readers discover RSS feeds via a <link> tag in the page <head>:

<link rel="alternate" type="application/rss+xml" title="Toby Allen" href="/feed.xml" />

You could add this manually inside the <head> in your root layout.tsx, but Next.js handles it automatically through the metadata.alternates API. Adding this to the metadata export in app/layout.tsx is all it takes:

export const metadata: Metadata = {
  title: { default: "Toby Allen", template: "%s — Toby Allen" },
  description: "Writing on identity, access management, and security by Toby Allen.",
  metadataBase: new URL("https://www.tobytes.com"),
  alternates: {
    types: {
      "application/rss+xml": [{ url: "/feed.xml", title: "Toby Allen" }],
    },
  },
};

Next.js emits the correct <link> tag on every page without any manual <head> manipulation. This is the right approach rather than reaching for a custom <head> component.

Autodiscovery covers feed readers, but it is also worth adding a plain visible link so readers who want to subscribe can find the URL without inspecting the page source. I added "RSS" to the header nav and to the footer alongside the existing LinkedIn link.

The feed is now live at https://www.tobytes.com/feed.xml.

Conclusion

The whole implementation is about fifty lines of code across two files and introduced zero new dependencies. The main things worth knowing are the decodeHtmlEntities + escapeXml combination - if you have imported posts from WordPress or similar, the frontmatter may already contain HTML entities like &#038; that will double-encode without this step - the atom:link self-reference for validator compliance, and that metadata.alternates is the idiomatic App Router way to handle autodiscovery. If you are running an App Router blog and have been putting this off, it is a small afternoon's work.