Type something to search...
How to generate an RSS feed in Astro?

How to generate an RSS feed in Astro?

To generate an RSS feed in Astro, install the official @astrojs/rss package, set site in your Astro config, and create an endpoint such as src/pages/rss.xml.ts that exports a GET function returning rss({ title, description, site, items }). The items array usually comes from a content collection via getCollection(), with a title, description, publication date and link for each post. At build time Astro runs the endpoint and writes a valid RSS 2.0 file to dist/rss.xml, with absolute links generated from your site URL.

RSS is still how many readers, aggregators and newsletter tools follow a blog, and it takes only a few minutes to add to an Astro site. In this article you'll learn how to install and configure @astrojs/rss, build a feed from a content collection, validate your frontmatter with rssSchema, include the full post content safely, add categories and custom XML, style the feed in the browser, match your trailing slash setting, create a feed from pages in src/pages/, and let browsers discover the feed automatically.

Installing @astrojs/rss

Install the package (version 4.0.19 at the time of writing):

npm install @astrojs/rss

Then make sure your config has a site value. The feed uses it to turn relative post links into the absolute URLs RSS requires:

// astro.config.mjs
import { defineConfig } from "astro/config";

export default defineConfig({
  site: "https://example.com",
});

Defining the Collection

The feed in this article is built from a blog content collection. @astrojs/rss exports rssSchema, a Zod schema with the standard feed fields (title, description, pubDate, categories, author and so on), which you can use as the collection's schema:

// src/content.config.ts
import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";
import { rssSchema } from "@astrojs/rss";

const blog = defineCollection({
  loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
  schema: rssSchema.extend({
    pubDate: z.coerce.date(),
    draft: z.boolean().default(false),
  }),
});

export const collections = { blog };

Two details here are specific to current Astro and Zod:

  • A loader is required: Astro 6 removed legacy collections, so every collection needs a loader such as glob(), even when you use rssSchema.
  • Use .extend(): Zod 4 objects are extended with .extend(). In rssSchema, pubDate is optional, so overriding it with z.coerce.date() makes it required and saves you from non-null assertions when sorting by date.

A post's frontmatter then looks like this:

---
title: "Hello & welcome"
description: "The first post on the new blog."
pubDate: 2026-09-01
categories: ["news"]
---

This is the **first** post. See [the docs](https://docs.astro.build/).

If you're new to collections and schemas, see what content collections are in Astro and collection schemas with Zod.

Creating the Feed Endpoint

A feed is an ordinary Astro endpoint. Name the file after the URL you want, here rss.xml:

// src/pages/rss.xml.ts
import rss from "@astrojs/rss";
import type { APIContext } from "astro";
import { getCollection } from "astro:content";

export async function GET(context: APIContext) {
  const posts = (await getCollection("blog", ({ data }) => !data.draft)).sort(
    (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
  );

  return rss({
    title: "Sajjad's Blog",
    description: "Notes on web development and Astro",
    site: context.site!,
    items: posts.map((post) => ({
      title: post.data.title,
      description: post.data.description,
      pubDate: post.data.pubDate,
      categories: post.data.categories,
      link: `/blog/${post.id}/`,
    })),
    customData: `<language>en-gb</language>`,
  });
}

What each part does:

  • getCollection() with a filter: skips posts marked draft: true.
  • Sorting: newest first, which is what feed readers expect.
  • context.site: the site value from your config, passed in through the endpoint context. The ! tells TypeScript it's defined, which is true as long as you've set site.
  • link: a path relative to your site. Use post.id, which the glob() loader derives from the file name, and make sure it matches your real post URLs.
  • customData: raw XML inserted into the <channel>, here a <language> tag.

Running astro build produced dist/rss.xml:

<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>Sajjad&apos;s Blog</title><description>Notes on web development and Astro</description><link>https://example.com/</link><language>en-gb</language><item><title>Routing in Astro</title><link>https://example.com/blog/routing/</link><guid isPermaLink="true">https://example.com/blog/routing/</guid><description>How file-based routing works.</description><pubDate>Thu, 01 Oct 2026 00:00:00 GMT</pubDate><category>astro</category><category>routing</category></item><item><title>Hello &amp; welcome</title><link>https://example.com/blog/hello/</link><guid isPermaLink="true">https://example.com/blog/hello/</guid><description>The first post on the new blog.</description><pubDate>Tue, 01 Sep 2026 00:00:00 GMT</pubDate><category>news</category></item></channel></rss>

Notice what the package handled for you: special characters such as & and ' are escaped, dates are formatted as RFC 822 strings, links are absolute, each item gets a guid, and categories become multiple <category> elements. The draft post isn't in the feed.

Because the URL ends in .xml, Astro serves it only without a trailing slash: /rss.xml, not /rss.xml/. That's been the rule for endpoints with file extensions since Astro 6.

Item Fields You Can Use

These are the item properties documented in the @astrojs/rss README:

FieldTypeNotes
titlestringOptional only if description is set
descriptionstringA short summary; optional only if title is set
linkstringRelative paths are resolved against site
pubDateDatePublication date
contentstringFull post HTML, output as content:encoded
categoriesstring[]One <category> per entry
authorstringAuthor of the item
commentsUrlstringURL of a comments page
sourceobjectThe original source, for syndicated items
enclosureobjectA media file, for podcasts
customDatastringExtra XML inside the <item>

For podcast or platform-specific tags, combine customData with the top-level xmlns option, which adds namespace declarations to the <rss> element.

Including the Full Post Content

By default, readers see only the description and have to click through. To include the whole post, set content to HTML. A collection entry's body is the raw Markdown, so you need to render and sanitise it. The Astro docs recommend markdown-it and sanitize-html:

npm install markdown-it sanitize-html
npm install -D @types/markdown-it @types/sanitize-html
// src/pages/rss.xml.ts
import rss from "@astrojs/rss";
import type { APIContext } from "astro";
import { getCollection } from "astro:content";
import MarkdownIt from "markdown-it";
import sanitizeHtml from "sanitize-html";

const parser = new MarkdownIt();

export async function GET(context: APIContext) {
  const posts = (await getCollection("blog", ({ data }) => !data.draft)).sort(
    (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
  );

  return rss({
    title: "Sajjad's Blog",
    description: "Notes on web development and Astro",
    site: context.site!,
    stylesheet: "/rss/styles.xsl",
    items: posts.map((post) => ({
      title: post.data.title,
      description: post.data.description,
      pubDate: post.data.pubDate,
      categories: post.data.categories,
      link: `/blog/${post.id}/`,
      content: sanitizeHtml(parser.render(post.body ?? ""), {
        allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]),
      }),
    })),
    customData: `<language>en-gb</language>`,
  });
}

The package adds the content namespace and wraps each post in content:encoded. For my test post, which contained a <script> tag in the Markdown, the output was:

<content:encoded>&lt;p&gt;This is the &lt;strong&gt;first&lt;/strong&gt; post. See &lt;a href=&quot;https://docs.astro.build/&quot;&gt;the docs&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;&lt;/p&gt;
</content:encoded>

The script was turned into harmless text: markdown-it doesn't pass raw HTML through by default, and sanitize-html would strip it anyway. A few limitations to keep in mind:

  • Relative links and images: feed readers can't resolve /images/photo.png or ../other-post. Use absolute URLs in posts you syndicate, or rewrite them in the endpoint.
  • MDX components: this approach renders plain Markdown only. Components and JSX expressions in .mdx files won't be processed.
  • Your site's Markdown plugins: Astro 7 renders your pages with its own Sätteri Markdown pipeline, but the feed uses markdown-it, so plugin-driven features like custom heading IDs or syntax highlighting won't appear in the feed.

Styling the Feed with a Stylesheet

Opening a raw RSS file in a browser shows a wall of XML. The stylesheet option, used above, adds an xml-stylesheet processing instruction so browsers render the feed with an XSL stylesheet:

<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet href="/rss/styles.xsl" type="text/xsl"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">

Put the stylesheet at public/rss/styles.xsl so it's served at that path. You can write your own, or use a ready-made one such as the Pretty Feed stylesheet the Astro docs suggest.

Matching Your Trailing Slash Setting

@astrojs/rss adds a trailing slash to item links by default, whatever your trailingSlash config says. If your site uses trailingSlash: "never", pass trailingSlash: false to rss() so feed links match your real URLs and don't trigger redirects.

A Feed from Pages Instead of a Collection

If your posts are Markdown files in src/pages/ rather than a collection, use pagesGlobToRssItems() with import.meta.glob(). It reads each page's frontmatter and uses the page URL as the link:

// src/pages/notes.xml.ts
import rss, { pagesGlobToRssItems } from "@astrojs/rss";
import type { APIContext } from "astro";

export async function GET(context: APIContext) {
  return rss({
    title: "Notes",
    description: "Short notes",
    site: context.site!,
    trailingSlash: false,
    items: await pagesGlobToRssItems(import.meta.glob("./notes/*.md")),
  });
}

With one file at src/pages/notes/first-note.md, the build produced:

<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>Notes</title><description>Short notes</description><link>https://example.com</link><item><title>A note</title><link>https://example.com/notes/first-note</link><guid isPermaLink="true">https://example.com/notes/first-note</guid><description>Short note.</description><pubDate>Mon, 05 Oct 2026 00:00:00 GMT</pubDate></item></channel></rss>

The trailingSlash: false option removed the slash from both the channel link and the item link. The helper assumes every page has the required frontmatter and doesn't check it for you, so if the build fails, look at each page's frontmatter first. Astro.glob() was removed in Astro 6, so import.meta.glob() is the way to collect files like this.

Letting Browsers Find the Feed

Add an autodiscovery <link> to the <head> of your layout so feed readers can find the feed from any page URL:

---
// src/layouts/Base.astro
const { title } = Astro.props;
---
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>{title}</title>
    <link
      rel="alternate"
      type="application/rss+xml"
      title="Sajjad's Blog"
      href={new URL("rss.xml", Astro.site)}
    />
  </head>
  <body>
    <slot />
  </body>
</html>

That rendered as:

<link rel="alternate" type="application/rss+xml" title="Sajjad's Blog" href="https://example.com/rss.xml">

With this in place, readers can paste your home page URL into their feed reader and subscribe. A visible link or RSS icon in your footer helps people who don't know to look for it. For the other <head> tags worth adding, see SEO meta tags in Astro.

Checking the Feed

Before you publish, check that:

  1. The file exists: visit /rss.xml after astro build and astro preview.
  2. Links match real URLs: click a few item links and check for redirects or 404s.
  3. Dates are right: items should be sorted newest first with correct dates.
  4. It validates: run the feed through an RSS validator such as the W3C Feed Validation Service.

FAQ: RSS Feeds in Astro

No, an endpoint can return any XML you write by hand, but the official @astrojs/rss package handles escaping, date formatting, absolute links and the RSS structure for you, so it's the easiest option.

The feed builds absolute URLs from the site option in your Astro config, passed in as context.site. Make sure site is set and that each item's link matches the URL your post pages actually use.

Set the content field of each item to HTML. For a content collection, render post.body with a Markdown parser such as markdown-it and sanitise the result with sanitize-html before passing it in.

Either works, as long as you link to it. Name the endpoint after the URL you want, such as src/pages/rss.xml.ts or src/pages/feed.xml.ts, and use the same URL in your autodiscovery link.

Since Astro 6, endpoints with a file extension in the URL are only served without a trailing slash, regardless of your trailingSlash setting. Link to /rss.xml instead.

In a static build, the feed is generated at build time, so it updates whenever you rebuild and deploy. Rendering the endpoint on demand with an adapter would regenerate it on every request.


Conclusion

An RSS feed in Astro is one endpoint and one function call. Install @astrojs/rss, set site, and return rss() from src/pages/rss.xml.ts with items mapped from your content collection. Use rssSchema to keep frontmatter consistent, sort newest first, filter drafts, and the package takes care of escaping, dates and absolute links.

From there, add what your readers need: full content rendered with markdown-it and cleaned with sanitize-html, a stylesheet for people who open the feed in a browser, trailingSlash: false if your URLs don't end in a slash, and an autodiscovery link in your layout. Validate the result once, and your feed will quietly keep subscribers up to date with every build.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or

Dive Deeper