Your blog template ships <link rel="alternate" type="application/rss+xml" href="/rss.xml">, a subscriber clicks it, and the URL 404s. Almost never is the RSS library at fault. In nearly every case one of three things is true: the route file never made it into the build output, the filename doesn’t match the href in the link tag, or a host rewrite rule is shadowing the feed.
Fastest fix (90 seconds): run curl -I https://yourdomain.com/rss.xml. The status code tells you exactly which bucket you’re in — see the table below, then jump to the matching fix.
This article covers Astro, Next.js (App Router), and the Vercel / Netlify / Cloudflare rewrite layer specifically, with working fixes per stack, verified as of June 2026.
Read the response first — which bucket are you in?
curl -I https://yourdomain.com/rss.xml
What curl -I returns | Most likely cause | Go to |
|---|---|---|
404 Not Found | Endpoint not in build output, or filename mismatch | Cause 1 / 2 |
200 + content-type: text/html | File missing, so a SPA/catch-all rewrite is answering | Cause 3 |
200 + content-type: text/plain (or none) | Endpoint exists but forgot the XML content-type header | Cause 5 |
500 in prod, fine locally | SSR endpoint not prerendered for the edge runtime | Cause 4 |
404 only in dev, fine in prod | trailingSlash: "always" quirk (Astro v4.1.0+) | Cause 6 |
A 200 with content-type: text/html is the most misread case. On Vercel and Netlify, a real static file is served before any rewrite runs — the filesystem wins. So if the SPA fallback is answering /rss.xml, it’s because the file genuinely isn’t there. The fix is to get the feed into the build, not to add a rewrite exception.
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 plus src/pages/rss.xml.ts; Next.js App Router needs app/rss.xml/route.ts; Hugo needs an rss entry under outputs in hugo.toml (formerly 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. Whatever file does exist is the URL your link tag should point at.
3. Host rewrite / SPA fallback intercepting
A Vercel vercel.json with "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }], or a Netlify _redirects line /* /index.html 200, will answer /rss.xml with your app shell — but only when rss.xml is not in the deployed output. Per Vercel’s docs, the filesystem takes precedence over rewrites, so an existing file is never swallowed by a catch-all. Netlify behaves the same way unless you force the rule.
The trap on Netlify is the ! force flag: /* /index.html 200! overrides file shadowing and will hide a real rss.xml. If you have a forced splat, that alone can cause the 404.
How to spot it:
curl -I https://yourdomain.com/rss.xml
Status 200 with content-type: text/html means the rewrite is answering — which means the file is missing (go fix Cause 1/2), unless you have a forced Netlify splat (drop the !).
4. SSR endpoint not prerendered for the runtime
In Astro, endpoints are static by default in output: 'static' builds, but the moment you switch to output: 'server' (or 'hybrid'), every endpoint becomes on-demand and runs at request time. If your adapter’s edge runtime (Vercel Edge, Cloudflare Workers) trips over the XML serialization, you get a 500 or a 404. Pin the feed to build time with export const prerender = true; so it ships as a real static file.
How to spot it: local npm run preview works, production 404s or 500s. That gap is the signature of an SSR runtime mismatch.
5. Endpoint returns 200 but no XML content-type
In Cloudflare Pages Functions mode, a functions/rss.xml.js that forgets to set the content-type header returns 200 with the right bytes — but RSS readers reject it as “feed format invalid.” The same happens with a hand-rolled Next.js route handler that omits the header.
How to spot it: curl -I shows 200 but no content-type: application/rss+xml (or application/xml). The feed loads in a browser yet fails in every RSS client.
6. Astro trailingSlash: "always" 404s the feed in dev
Since Astro v4.1.0, file-extension endpoints (rss.xml, sitemap.xml) return 404 in the dev server when trailingSlash: "always" is set — /rss.xml/ works but /rss.xml 404s. Endpoints with a file extension are only addressable without a trailing slash regardless of your build config, so the two disagree.
Fix: if your astro.config.mjs uses trailingSlash: "never", pass trailingSlash: false to the rss() helper so the emitted feed matches your routing. This is dev-only noise; production usually emits /rss.xml correctly either way.
Shortest path to fix
Ordered by ROI. The first three solve roughly 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; // emit at build time, even in SSR mode
export async function GET(context) {
const posts = await getCollection('articles');
return rss({
title: 'Your Site',
description: 'Latest articles',
site: context.site, // requires `site` set in astro.config.mjs
items: posts.map((p) => ({
title: p.data.title,
pubDate: p.data.publishedAt,
description: p.data.description,
link: `/articles/${p.slug}/`,
})),
});
}
The rss() helper sets content-type: application/rss+xml for you. If context.site is undefined, you forgot to set site in astro.config.mjs and item links will be relative — set it.
Next.js App Router:
// app/rss.xml/route.ts
export const dynamic = 'force-static'; // build a static file, not a runtime route
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>
<description>Latest articles</description>
${items.map(i => `<item><title>${i.title}</title><link>${i.url}</link></item>`).join('')}
</channel></rss>`;
return new Response(xml, {
headers: { 'content-type': 'application/rss+xml; charset=utf-8' },
});
}
RSS 2.0 requires three child elements inside <channel>: title, link, and description. Omitting description is the single most common reason a feed that loads in a browser still fails the W3C validator.
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/rss+xml(orapplication/xml)- Body begins with
<?xml version="1.0"
Then confirm the file is physically in the build:
ls -la dist/rss.xml # Astro
ls -la out/rss.xml # Next.js static export
Preview passes but production fails → deploy or rewrite issue (Step 3). Preview also fails → the route file itself is wrong, back to Step 1. No file in dist/ even though preview “worked” → your endpoint is running on-demand, not prerendered (Cause 4).
Step 3: Stop a rewrite from shadowing /rss.xml
First, confirm the file is in the deployed output (Step 2). If it is, the filesystem serves it and no rewrite change is needed. If you still see the SPA shell, fix the rule:
| Platform | Fix |
|---|---|
| Vercel | Do not add a rewrite whose source is /rss.xml — Vercel rejects file-path sources and serves real files automatically. Just make sure the build emits rss.xml (Step 1/2). |
| Netlify | Drop the ! force flag from the SPA splat: use /* /index.html 200, not 200!. A forced splat overrides file shadowing and hides the feed. |
| Cloudflare Pages | Confirm there’s no conflicting functions/rss.xml.js. A Function at that path overrides the static file and may return the wrong content-type. |
After redeploy, bust the CDN cache and recheck:
curl -I "https://yourdomain.com/rss.xml?cb=$(date +%s)"
Must return 200 plus an 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 a GitHub Actions job triggered on the deployment_status event. Run the W3C Feed Validator once after the first deploy to confirm readers can actually parse it.
How to confirm it’s fixed
curl -I https://yourdomain.com/rss.xmlreturns200andcontent-type: application/rss+xml(orapplication/xml).- The body starts with
<?xmland contains one<channel>with<title>,<link>, and<description>. - W3C Feed Validator reports a valid feed.
- Paste the URL into a real reader (Feedly, NetNewsWire, Inoreader) — it should subscribe and show posts, not error.
FAQ
Why does /rss.xml work in npm run preview but 404 in production?
Almost always an SSR/edge mismatch (Cause 4) or a missing file the SPA fallback is masking (Cause 3). Add export const prerender = true; to the Astro endpoint, rebuild, and confirm dist/rss.xml physically exists before redeploying.
Should my feed be application/rss+xml or application/xml?
Either is accepted by readers. application/rss+xml is the canonical RSS 2.0 type; application/xml (or text/xml) also validates. What you must avoid is text/html (the SPA fallback) and text/plain (a header you forgot to set).
The feed loads in my browser but Feedly says it’s invalid. Why?
The browser renders any well-formed XML; readers enforce the RSS spec. The usual culprit is a missing <description> inside <channel>, an unescaped &/</> in a title, or a wrong content-type. Run it through the W3C Feed Validator to get the exact line.
Do I need a rewrite rule on Vercel to expose /rss.xml?
No. Vercel serves static files from the build output before any rewrite is evaluated, and it won’t accept a rewrite whose source is a file path. If rss.xml is in your output, it’s reachable. If it 404s, the file is missing — fix the build, not the config.
My Astro feed 404s only on the dev server. That’s the trailingSlash: "always" quirk (Cause 6, Astro v4.1.0+). /rss.xml/ works in dev; /rss.xml 404s; production is fine. Switch to trailingSlash: "never" and pass trailingSlash: false to the rss() helper.
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 plus an XML content-type. - Run the W3C Feed Validator once before announcing the feed, and keep
<title>,<link>, and<description>in every<channel>. - In SSR/hybrid projects, explicitly mark the RSS route
prerender = trueto dodge edge-runtime quirks. - Never rely on a rewrite to expose the feed — let the build emit a real file and let the filesystem serve it. On Netlify, keep the SPA splat unforced (
200, not200!).
Related
Tags: #Hosting #Debug #Troubleshooting