How to Build an RSS Feed in Astro with @astrojs/rss
Add an RSS feed to your Astro site using @astrojs/rss — covers content collections, rssSchema, auto-discovery, full-post content, and W3C validation.
By Ethan
1,416 words · 8 min read
RSS is not retro. Substack has passed 5 million paid subscriptions — the newsletter ecosystem runs on subscription infrastructure RSS helped build. Readers who use Feedly, NetNewsWire, Feedbin, or Inoreader are opting out of algorithmic feeds on purpose. An RSS feed on your Astro site gives them a direct subscription path with no intermediary.
Adding the feed takes about 20 minutes. The @astrojs/rss package handles the RSS 2.0 XML generation. You write a single endpoint file and you are done.
Who this is for
Developers running Astro 5 with content collections who want a /rss.xml endpoint. The code works equally well for Astro 4, but the post.id vs. post.slug distinction applies only to Astro 5’s content layer. If you are on Astro 4, stick with post.slug.
Install
Add the official package:
pnpm add @astrojs/rss
Current stable is v4.0.18. It requires Node.js v22.12.0 or higher. Older versions that used pagesGlobToRssItems() without the content collections API still work, but the content collections approach gives you compile-time frontmatter validation — the better default.
Configure: set site in astro.config.mjs
@astrojs/rss reads your site URL from astro.config.mjs. This is required. Without it, the package cannot generate absolute URLs for feed items, and most feed readers will reject the feed.
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
site: 'https://yourdomain.com',
});
Inside the endpoint, access this value as context.site. Do not hard-code the domain directly in the rss() call and do not reach for import.meta.env.SITE — that variable only works in client-side code. In a server-side route handler, it is undefined. context.site is always correct.
Create the RSS feed endpoint
Create src/pages/rss.xml.js. The filename maps directly to the URL: this file becomes /rss.xml.
// src/pages/rss.xml.js
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
export async function GET(context) {
const blog = await getCollection('blog');
const posts = blog.sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
return rss({
title: 'Your Site — Tagline',
description: 'A short description of your site.',
site: context.site,
customData: `<language>en-us</language>`,
items: posts.map((post) => ({
title: post.data.title,
pubDate: post.data.pubDate,
description: post.data.description,
link: `/blog/${post.id}/`,
})),
});
}
Three things to get right in Astro 5:
post.id not post.slug. The content layer API in Astro 5 dropped .slug. If you are migrating Astro 4 code, this is the most common breakage point. post.id returns the same value — the filename without the extension — so your URLs look identical.
Sort before you map. Astro 5’s content loader does not guarantee order. Skip the sort and your feed items appear in an unpredictable sequence. Readers typically display items in the order they appear in the XML, so sort descending by pubDate before passing to rss().
customData for language. This field accepts raw XML strings. Use it to set the <language> element from the RSS 2.0 spec. For a bilingual site, the English feed gets en-us and a Vietnamese feed gets vi. Feed aggregators use this for locale-aware routing and categorization. If you are building a bilingual Astro site, see How to build a multilingual Astro site (EN + VI) for the full i18n routing setup.
Enforce frontmatter shape with rssSchema
The @astrojs/rss package ships a Zod schema that validates your content collection frontmatter at build time. This is worth wiring up.
// src/content/config.ts
import { defineCollection } from 'astro:content';
import { rssSchema } from '@astrojs/rss';
const blog = defineCollection({
schema: rssSchema,
});
export const collections = { blog };
rssSchema enforces that every post has title, pubDate, and description. Without it, missing fields produce malformed items in the feed — you discover the problem in a feed reader, not at build time. The older pagesGlobToRssItems() helper does not provide this guarantee: the docs explicitly state it assumes but does not verify that all necessary feed properties are present.
Add auto-discovery
Feed readers and browsers detect RSS via a <link> tag in the page <head>. Without it, subscribers have to know your feed URL in advance.
<!-- src/layouts/BaseLayout.astro -->
<link
rel="alternate"
type="application/rss+xml"
title="Your Site RSS Feed"
href={new URL("rss.xml", Astro.site)}
/>
Astro.site pulls from astro.config.mjs. The new URL() call produces an absolute URL — required by the RSS spec. Add this to the <head> of your base layout and every page on your site will advertise the feed.
When the <link> tag is present, the URL bar in browsers like Safari shows a feed indicator. More practically, feed readers that auto-discover feeds from a page URL will find it without users having to locate the feed URL manually.
Test locally
Start the dev server and open http://localhost:4321/rss.xml.
You should see raw XML. If you see a rendered page instead, the file is not being treated as an endpoint. Check two things: the file exports a GET function (not a default export), and the filename ends in .js not .astro.
Common early errors:
- Blank page or 404:
siteis missing fromastro.config.mjs. - Relative URLs in
linkfields: you hard-coded a path withoutcontext.site. Feed readers expect absolute URLs. - All posts same date or wrong order: the sort is missing or using
pubDateas a string instead of calling.valueOf().
Validate with W3C
Once deployed, run your feed URL through the W3C Feed Validation Service. It checks conformance against RSS 2.0 and reports specific problems.
Common issues it catches:
- Missing
<link>in the channel element - Invalid date format — RSS 2.0 requires RFC 822 (
Mon, 25 May 2026 00:00:00 GMT), not ISO 8601 - Items with neither
<title>nor<description>
For a real-reader test, paste the URL into NetNewsWire (free, macOS and iOS) via File → New Feed. NetNewsWire renders feed items with images and formatted descriptions. If items look correct there, they will work in every major reader.
Add full post content (optional)
The default feed includes titles, descriptions, and links. If you want the full article body inside the feed — so readers can read without leaving their reader app — install two more packages:
pnpm add markdown-it sanitize-html
pnpm add -D @types/sanitize-html
Then extend the endpoint to render and sanitize each post’s body:
// src/pages/rss.xml.js
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import sanitizeHtml from 'sanitize-html';
import MarkdownIt from 'markdown-it';
const parser = new MarkdownIt();
export async function GET(context) {
const blog = await getCollection('blog');
const posts = blog.sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
return rss({
title: 'Your Site — Tagline',
description: 'A short description of your site.',
site: context.site,
customData: `<language>en-us</language>`,
items: posts.map((post) => ({
link: `/blog/${post.id}/`,
content: sanitizeHtml(parser.render(post.body), {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
}),
...post.data,
})),
});
}
sanitizeHtml strips any potentially dangerous HTML before it goes into the feed. The concat(['img']) line adds image tags back to the allowlist — the defaults are conservative and exclude them.
One hard limit: markdown-it processes raw Markdown only. It does not handle Astro components or JSX inside .mdx files. If your posts are plain .md files, this works correctly. For .mdx posts with components, you need a more involved render pipeline using the unified ecosystem — George Song’s approach covers this in detail.
Style the feed in a browser (optional)
Raw XML in a browser is not reader-friendly. An XSL stylesheet renders it as a styled HTML page. Add the stylesheet field:
return rss({
stylesheet: '/rss/styles.xsl',
// ... rest of config
});
Place the stylesheet at public/rss/styles.xsl. A widely-used starting point is pretty-feed-v3.xsl — search for it on GitHub and it will be the first result. Most Astro blog starter themes already include a version of it.
What you have now
A /rss.xml endpoint that:
- Serves posts sorted newest-first
- Includes
<language>for bilingual-site compatibility - Validates frontmatter at build time via
rssSchema - Broadcasts the feed URL via auto-discovery
<link>tags on every page
Every post you publish appears in the feed automatically. Feed readers polling your URL will pick it up on the next sync cycle — typically within the hour for active subscribers.
The spec compliance catches most issues in a few minutes of validation. Run the W3C validator after your first deploy and fix anything it flags before subscribers add the feed. Fixing malformed XML after people have subscribed is harder, since readers cache the feed state and re-sync behavior varies.
Once your feed validates cleanly, deploying on Cloudflare Pages is the fastest path to making it live — free tier, unlimited bandwidth, and edge delivery out of the box.