
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 userssSchema. - Use .extend(): Zod 4 objects are extended with
.extend(). InrssSchema,pubDateis optional, so overriding it withz.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
sitevalue from your config, passed in through the endpoint context. The!tells TypeScript it's defined, which is true as long as you've setsite. - link: a path relative to your site. Use
post.id, which theglob()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'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 & 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:
| Field | Type | Notes |
|---|---|---|
title | string | Optional only if description is set |
description | string | A short summary; optional only if title is set |
link | string | Relative paths are resolved against site |
pubDate | Date | Publication date |
content | string | Full post HTML, output as content:encoded |
categories | string[] | One <category> per entry |
author | string | Author of the item |
commentsUrl | string | URL of a comments page |
source | object | The original source, for syndicated items |
enclosure | object | A media file, for podcasts |
customData | string | Extra 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><p>This is the <strong>first</strong> post. See <a href="https://docs.astro.build/">the docs</a>.</p>
<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>
</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.pngor../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
.mdxfiles 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:
- The file exists: visit
/rss.xmlafterastro buildandastro preview. - Links match real URLs: click a few item links and check for redirects or 404s.
- Dates are right: items should be sorted newest first with correct dates.
- 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.


