Your blog template ships <link rel="alternate" type="application/rss+xml" href="/rss.xml">, a subscriber clicks it, and the URL 404s. This is the second-most-common RSS bug (the first is “subscribers report they aren’t getting new posts”). Almost never is the RSS library at fault — usually the route file isn’t actually in the build, the filename doesn’t match the link tag, or the host’s rewrite rules treat /rss.xml as an unmatched path and route it through the SPA fallback.
This article covers Astro / Next.js / Nuxt / Hugo specifically, with working fixes per stack.
Common causes
Ordered by hit rate, highest first.
1. No RSS endpoint actually exists
Many templates hardcode the <link rel="alternate"> tag but no one wires up the integration. Astro needs @astrojs/rss + src/pages/rss.xml.ts; Next.js App Router needs app/rss.xml/route.ts; Hugo needs output formats configured in config.toml.
How to spot it:
find . -path ./node_modules -prune -o -type f \( -name "rss*" -o -name "feed*" \) -print
If you only find the link tag in templates and no generating endpoint, that’s it.
2. Filename / path mismatch with the link tag
The HTML says /rss.xml but you created src/pages/feed.xml.ts, or stashed it at src/pages/blog/rss.xml.ts. A browser hitting /rss.xml gets a clean 404.
How to spot it: Copy the href from your HTML source, then look for the matching file in dist/:
ls dist/rss.xml dist/feed.xml dist/blog/rss.xml 2>/dev/null
Anything that doesn’t line up is the bug.
3. Host rewrite / SPA fallback intercepting
A Vercel vercel.json with "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] will swallow /rss.xml if the build didn’t actually produce that file (static files are checked first, but a missing one falls through to the rewrite). Netlify _redirects with /* /index.html 200 does the same.
How to spot it:
curl -I https://yourdomain.com/rss.xml
Status 200 with content-type: text/html is almost certainly the SPA fallback.
4. SSR route not registered at runtime
If your Astro / Next project runs in SSR / hybrid mode, the RSS endpoint must be picked up by the runtime, not just the build. In Astro, add export const prerender = true; so the feed is emitted as a static file at build time — some edge runtimes (Vercel Edge, Cloudflare Workers) won’t execute the heavier XML serialization paths.
How to spot it: Local npm run preview works, production 404s or 500s → usually an SSR runtime mismatch.
5. Cloudflare Pages / Workers serving with no content-type
In Cloudflare Pages Functions mode, a functions/rss.xml.js that forgets to set the content-type header will return 200 with the right bytes — but readers reject it as “feed format invalid.”
How to spot it: curl -I shows 200 but no content-type: application/xml or application/rss+xml. The feed loads in a browser but fails in any RSS client.
Shortest path to fix
Ordered by ROI. The first three usually solve 80% of cases.
Step 1: Make sure the route file exists and emits a valid feed
Per framework. Astro:
// src/pages/rss.xml.ts
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
export const prerender = true;
export async function GET(context) {
const posts = await getCollection('articles');
return rss({
title: 'Your Site',
description: 'Latest articles',
site: context.site,
items: posts.map((p) => ({
title: p.data.title,
pubDate: p.data.publishedAt,
description: p.data.description,
link: `/articles/${p.slug}/`,
})),
});
}
Next.js App Router:
// app/rss.xml/route.ts
export const dynamic = 'force-static';
export async function GET() {
const items = await fetchPosts();
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<title>Your Site</title>
<link>https://yourdomain.com</link>
${items.map(i => `<item><title>${i.title}</title><link>${i.url}</link></item>`).join('')}
</channel></rss>`;
return new Response(xml, {
headers: { 'content-type': 'application/xml; charset=utf-8' },
});
}
Step 2: Build + preview locally and verify
npm run build
npm run preview
curl -I http://localhost:4321/rss.xml
curl -s http://localhost:4321/rss.xml | head -5
Pass criteria:
- Status 200
content-type: application/xmlorapplication/rss+xml- Body begins with
<?xml version="1.0"
Preview passes but production fails → deploy or rewrite issue (Step 3). Preview also fails → the route file itself is wrong, back to Step 1.
Step 3: Remove any rewrite intercepting /rss.xml
Open vercel.json / netlify.toml / _redirects and explicitly allow /rss.xml before any catch-all:
| Platform | Fix |
|---|---|
| Vercel | In vercel.json rewrites, add { "source": "/rss.xml", "destination": "/rss.xml" } before the catch-all |
| Netlify | In _redirects, put /rss.xml /rss.xml 200! before the SPA fallback line |
| Cloudflare Pages | Same as Netlify, or confirm there’s no conflicting functions/rss.xml.js |
After redeploy:
curl -I "https://yourdomain.com/rss.xml?cb=$(date +%s)"
Must return 200 + xml content-type.
Step 4: Wire RSS validation into CI
#!/usr/bin/env bash
set -e
URL="https://yourdomain.com/rss.xml"
ct=$(curl -sI "$URL" | grep -i "^content-type:" | tr -d '\r')
if [[ ! "$ct" =~ xml ]]; then echo "BAD content-type: $ct"; exit 1; fi
curl -s "$URL" | head -1 | grep -q "<?xml" || { echo "Body is not XML"; exit 1; }
echo "RSS OK"
Hook it into GitHub Actions’ deployment_status event. Then run W3C Feed Validator once to confirm readers can parse it.
Prevention
- Keep a single source of truth: the
<link rel="alternate">href and the actual route filename must always match - Post-deploy, curl
/rss.xmland assert 200 + xml content-type - Run W3C Feed Validator once before announcing the feed
- In SSR projects, explicitly mark the RSS route
prerender = trueto dodge edge-runtime quirks - Never put
/rss.xmlbehind a SPA fallback — declare it (or pin its rewrite) above the catch-all
Related
Tags: #Hosting #Debug #Troubleshooting