TidyURLs
Go to Tools
BLOG
The TidyURLsJournal

Practical guides, tutorials, and insights about URLs, SEO, web development, and online marketing. Learn, troubleshoot, and get the most of the web.

Back to the Journal
TidyURLs Journal

How to Create an RSS Feed for a Next.js Blog

Practical insights, clear explanations, and useful ideas for understanding the web.

October 25, 20267 min read6 topics
How to Create an RSS Feed for a Next.js Blog
Explore the details
#Next.js#RSS#Web Development#App Router#XML#Technical SEO
October 25, 20267 min read

A blog can publish excellent posts and still be hard for readers to follow if there is no feed to subscribe to. An RSS feed gives readers and feed readers a predictable, machine-readable way to discover new posts. In this tutorial, you’ll create an RSS 2.0 feed at /rss.xml with a Next.js App Router route handler, including stable item identifiers, publication dates, XML escaping, caching, and feed discovery.

Key takeaways

  • An RSS feed is XML with required channel details and entries for your posts.
  • Serve the feed from a stable URL, such as /rss.xml, with the application/rss+xml content type.
  • Use absolute canonical URLs and stable GUIDs so items remain identifiable over time.
  • Format publication dates consistently and escape data before inserting it into XML.
  • Add an HTML discovery link and validate both the XML syntax and RSS structure.

What an RSS feed needs

RSS 2.0 describes a feed as a <channel> containing feed-level information and zero or more <item> elements. The channel needs a title, a link to the site, and a description. Each item should have a title, a link, and a description; a publication date is useful for ordering, while a GUID helps readers recognize an item across feed updates.

A minimal item might look like this:

<item>
  <title>Building a Better Blog</title>
  <link>https://blog.example.com/blog/building-a-better-blog</link>
  <description>Notes on improving a blog workflow.</description>
  <pubDate>Mon, 06 Jan 2025 10:30:00 GMT</pubDate>
  <guid isPermaLink="false">post-123</guid>
</item>

The example domain and post identifier are illustrative. In your feed, use your real canonical domain and an identifier that stays attached to the post.

Create an RSS route in Next.js

This example uses the App Router. Create app/rss.xml/route.ts. The route reads published posts from your own data layer, converts them to RSS items, and returns an XML response. Adjust the import, post fields, and blog path to match your project.

// app/rss.xml/route.ts
import { getPublishedPosts } from "@/lib/posts";

export const revalidate = 3600;

function escapeXml(value: string): string {
  // XML 1.0 does not allow every Unicode code point. Remove invalid ones
  // before escaping markup characters.
  const validXmlCharacters = Array.from(value)
    .filter((character) => {
      const codePoint = character.codePointAt(0)!;
      return (
        codePoint === 0x09 ||
        codePoint === 0x0a ||
        codePoint === 0x0d ||
        (codePoint >= 0x20 && codePoint <= 0xd7ff) ||
        (codePoint >= 0xe000 && codePoint <= 0xfffd) ||
        (codePoint >= 0x10000 && codePoint <= 0x10ffff)
      );
    })
    .join("");

  return validXmlCharacters
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&apos;");
}

function getSiteOrigin(): string {
  const configuredUrl = process.env.SITE_URL;
  if (!configuredUrl) {
    throw new Error("Set SITE_URL to your blog's canonical origin.");
  }

  const url = new URL(configuredUrl);
  if (url.protocol !== "https:" && url.protocol !== "http:") {
    throw new Error("SITE_URL must use HTTP or HTTPS.");
  }

  return url.origin;
}

export async function GET() {
  const siteOrigin = getSiteOrigin();
  const feedUrl = new URL("/rss.xml", siteOrigin).toString();
  const posts = await getPublishedPosts();

  const items = posts
    .filter((post) => post.publishedAt)
    .sort(
      (a, b) =>
        new Date(b.publishedAt!).getTime() -
        new Date(a.publishedAt!).getTime(),
    )
    .map((post) => {
      const publishedAt = new Date(post.publishedAt!);
      if (Number.isNaN(publishedAt.getTime())) {
        throw new Error(`Invalid publication date for post ${post.id}.`);
      }

      const postUrl = new URL(
        `/blog/${encodeURIComponent(post.slug)}`,
        siteOrigin,
      ).toString();

      return `
        <item>
          <title>${escapeXml(post.title)}</title>
          <link>${escapeXml(postUrl)}</link>
          <description>${escapeXml(post.summary)}</description>
          <pubDate>${publishedAt.toUTCString()}</pubDate>
          <guid isPermaLink="false">${escapeXml(post.id)}</guid>
        </item>`;
    })
    .join("");

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>${escapeXml("Example Blog")}</title>
    <link>${escapeXml(siteOrigin)}</link>
    <description>${escapeXml("Articles from Example Blog")}</description>
    <language>en</language>
    <atom:link href="${escapeXml(feedUrl)}" 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, s-maxage=3600, stale-while-revalidate=86400",
    },
  });
}

The data layer in this example is expected to return published posts with id, slug, title, summary, and publishedAt fields. Keep only public posts in that query. If your posts use another canonical path, change the URL construction accordingly. Set SITE_URL to the canonical origin of your production site; do not derive it from an arbitrary incoming request host.

The escapeXml function handles text and attribute values, including ampersands and angle brackets. For example, a title such as Design & Development becomes Design &amp; Development in XML. Escaping prevents post content from accidentally changing the XML structure. Keep feed summaries plain text, or convert rich content to safe text before passing it to this function; do not assume that escaping turns arbitrary HTML into safe, reader-friendly markup.

Dates, URLs, and stable identifiers

Publication dates

Store a publication timestamp with an explicit timezone, such as an ISO 8601 value ending in Z or an offset. The route converts it to a UTC date string with toUTCString(), which is suitable for the RSS pubDate field. Do not use the current time as a substitute when a post has no publication date: that makes old entries appear newly published whenever the feed is generated. In the example, posts without a date are omitted.

Canonical links

Feed item links should be absolute URLs that resolve to the canonical post pages. Make sure the path you generate matches your actual routing scheme and redirects consistently if a post URL changes. A relative URL may work in some readers but is less dependable across feed clients.

Stable GUIDs

A GUID should remain the same for the same post on every feed refresh. The example uses a database or content-system ID and sets isPermaLink="false" to indicate that it is an identifier rather than a URL. Avoid using a newly generated value, the current timestamp, or a changing URL as a GUID; readers may then treat an existing post as a new item.

Help readers discover the feed

Publishing /rss.xml does not automatically tell every reader that the feed exists. Add a link in the document head so browsers and feed readers can discover it. In an App Router root layout, the relevant markup can be placed in the <head>:

<head>
  <link
    rel="alternate"
    type="application/rss+xml"
    title="Example Blog RSS Feed"
    href="/rss.xml"
  />
</head>

You can also add a visible RSS link in your site navigation or footer. Keep the discovery URL aligned with the route you actually serve. If your site is hosted under a path prefix, check that the link resolves correctly in the deployed environment.

Caching and updates

The example uses export const revalidate = 3600 and a cache header to avoid rebuilding the feed for every request. This is a one-hour freshness target, not a guarantee that every deployment platform will refresh at exactly that interval. Next.js version, route configuration, data access, and hosting cache behavior can affect when a response is regenerated or served from cache.

Choose a cache duration that fits how often you publish. A longer duration can reduce repeated work but may delay the appearance of new posts; a shorter duration can make updates visible sooner but may increase requests to your data source. Test the route on your production hosting platform, and purge or revalidate its cache when publishing if your workflow requires faster updates. Do not cache draft or private content in a public feed.

Validate the feed

Check the endpoint and response headers first:

curl -i https://blog.example.com/rss.xml

Confirm that the response is successful, the body is XML, and the content type is application/rss+xml; charset=utf-8. Then check that the document is well-formed XML. For example, if xmllint is installed:

curl -fsS https://blog.example.com/rss.xml | xmllint --noout -

A successful XML parse only verifies syntax; it does not confirm that all RSS fields or feed-reader expectations are correct. Also test the URL with an RSS feed validator or a feed reader. Inspect a post with characters such as & or < in its title or summary, verify that publication dates are sensible, and check that the same post keeps the same GUID after multiple requests.

Common mistakes

  • Returning HTML instead of XML: Set the response content type explicitly and make sure the route does not render a page or redirect unexpectedly.
  • Skipping XML escaping: Unescaped text can make the document invalid or alter its structure. Escape every value inserted into XML.
  • Using unstable identifiers: A changing GUID can make readers display duplicate items.
  • Generating links from the request host: Use a configured canonical origin so proxy headers or unexpected hosts do not produce incorrect feed URLs.
  • Using inconsistent dates: Parse stored publication timestamps, reject invalid values, and output dates consistently in UTC.
  • Assuming XML validity means feed validity: Run an RSS-aware validation check and test the feed in a reader.
  • Ignoring cache behavior: Verify freshness after publishing on the actual deployment platform rather than relying only on local development behavior.

Conclusion

A useful Next.js RSS feed is more than a list of post links. It needs valid RSS structure, canonical URLs, stable identifiers, correctly formatted publication dates, escaped XML values, an appropriate response type, and a discoverable endpoint. Start with a route such as /rss.xml, validate its output, and tune caching to match your publishing workflow.