你在博客模板里看到 <link rel="alternate" type="application/rss+xml" href="/rss.xml">,订阅者一点就 404。问题几乎从来不在 RSS 库本身。绝大多数情况下,原因只有三种之一:路由文件根本没进 build 产物、文件名和 link 标签里的 href 对不上,或者宿主的某条 rewrite 规则把 feed 挡住了。
最快的排查(90 秒): 跑一句 curl -I https://yourdomain.com/rss.xml。状态码会直接告诉你属于哪一类——对照下面的表,再跳到对应修复。
本文专门针对 Astro、Next.js(App Router)以及 Vercel / Netlify / Cloudflare 的 rewrite 层,给出能跑通的分栈修复,内容截至 2026 年 6 月核对。
先看响应——你属于哪一类?
curl -I https://yourdomain.com/rss.xml
curl -I 返回 | 最可能的原因 | 跳到 |
|---|---|---|
404 Not Found | 端点没进 build 产物,或文件名不一致 | 原因 1 / 2 |
200 + content-type: text/html | 文件不存在,被 SPA / catch-all rewrite 接管了 | 原因 3 |
200 + content-type: text/plain(或没有) | 端点存在但漏设了 XML content-type 头 | 原因 5 |
生产 500、本地正常 | SSR 端点没在边缘 runtime 预渲染 | 原因 4 |
只有 dev 下 404、生产正常 | trailingSlash: "always" 的怪癖(Astro v4.1.0 起) | 原因 6 |
最容易被误读的就是 200 + content-type: text/html 这一类。在 Vercel 和 Netlify 上,真实存在的静态文件会在任何 rewrite 执行之前就被直接 serve——文件系统优先。所以如果 /rss.xml 是被 SPA fallback 接管的,那只能说明文件确实不在产物里。正确做法是让 feed 进到 build 里,而不是去加一条 rewrite 例外。
常见原因
按命中率从高到低:
1. 根本没生成 RSS 端点
很多模板的 <link rel="alternate"> 标签是默认硬编码的,但实际上没人加 RSS 集成。Astro 需要 @astrojs/rss 加上 src/pages/rss.xml.ts;Next.js App Router 需要 app/rss.xml/route.ts;Hugo 则要在 hugo.toml(旧版叫 config.toml)的 outputs 里加一项 rss。
如何判断:
find . -path ./node_modules -prune -o -type f \( -name "rss*" -o -name "feed*" \) -print
如果只搜到模板里的 link 标签,没有任何生成端点的文件,就是这种情况。
2. 文件名 / 路径与 link 标签不一致
HTML 里写的是 /rss.xml,但你建的是 src/pages/feed.xml.ts,或者放在了 src/pages/blog/rss.xml.ts——浏览器请求 /rss.xml 直接 404。
如何判断: 从 HTML 源码里复制那个 href,再到 dist/ 里找对应文件:
ls dist/rss.xml dist/feed.xml dist/blog/rss.xml 2>/dev/null
任何对不上的就是问题所在。实际存在的那个文件,才是你的 link 标签该指向的 URL。
3. 宿主 rewrite / SPA fallback 拦截
Vercel vercel.json 里的 "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }],或者 Netlify _redirects 里的 /* /index.html 200,确实会用 app 外壳来回应 /rss.xml——但仅当 rss.xml 不在部署产物里时才会这样。按 Vercel 官方文档,文件系统优先于 rewrite,所以已存在的文件永远不会被 catch-all 吞掉。Netlify 行为一致,除非你强制了规则。
Netlify 上的陷阱是 ! 强制标记:/* /index.html 200! 会覆盖文件遮蔽(file shadowing),真的会把存在的 rss.xml 挡住。如果你有一条强制的 splat 规则,仅凭这一点就能造成 404。
如何判断:
curl -I https://yourdomain.com/rss.xml
返回 200 但 content-type: text/html,说明是 rewrite 在回应——也就意味着文件缺失(去修原因 1/2),除非你在 Netlify 上有一条强制 splat(把 ! 去掉)。
4. SSR 模式下端点没预渲染
在 Astro 中,output: 'static' 构建下端点默认就是静态的;但只要切到 output: 'server'(或 'hybrid'),所有端点就变成按需的、在请求时才运行。如果适配器的边缘 runtime(Vercel Edge、Cloudflare Workers)在 XML 序列化时出问题,你就会拿到 500 或 404。用 export const prerender = true; 把 feed 钉在 build 时生成,让它作为真正的静态文件发布。
如何判断: 本地 npm run preview 正常,生产 404 / 500。这个差异就是 SSR runtime 不匹配的典型信号。
5. 端点返回 200 但没有 XML content-type
在 Cloudflare Pages Functions 模式下,functions/rss.xml.js 如果漏设 content-type 头,会返回 200 加上正确的字节内容——但 RSS 阅读器会以”feed format invalid”拒绝解析。手写的 Next.js route handler 漏掉这个头时也一样。
如何判断: curl -I 看到 200 但没有 content-type: application/rss+xml(或 application/xml)。feed 在浏览器里能打开,却在所有 RSS 客户端里失败。
6. Astro trailingSlash: "always" 让 feed 在 dev 下 404
从 Astro v4.1.0 起,当配置了 trailingSlash: "always" 时,带文件扩展名的端点(rss.xml、sitemap.xml)会在 dev server 下返回 404——/rss.xml/ 能访问,但 /rss.xml 404。带扩展名的端点不管 build 配置如何,都只能用不带尾斜杠访问,于是两边对不上。
修复: 如果 astro.config.mjs 用的是 trailingSlash: "never",就给 rss() 帮助函数传 trailingSlash: false,让生成的 feed 与路由保持一致。这只是 dev 下的噪音;生产通常无论如何都能正常产出 /rss.xml。
最短修复路径
按收益从高到低,前 3 步能解决大约 80% 的问题。
Step 1:确认路由文件确实存在并能生成合法 feed
按框架选模板。Astro:
// src/pages/rss.xml.ts
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
export const prerender = true; // 即便在 SSR 模式也在 build 时生成
export async function GET(context) {
const posts = await getCollection('articles');
return rss({
title: 'Your Site',
description: 'Latest articles',
site: context.site, // 需要在 astro.config.mjs 里设置 `site`
items: posts.map((p) => ({
title: p.data.title,
pubDate: p.data.publishedAt,
description: p.data.description,
link: `/articles/${p.slug}/`,
})),
});
}
rss() 帮助函数会自动设好 content-type: application/rss+xml。如果 context.site 是 undefined,说明你没在 astro.config.mjs 里设 site,条目链接会变成相对路径——把它设上。
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>
<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 要求 <channel> 里必须有三个子元素:title、link、description。漏掉 description 是 feed 在浏览器里能打开、却过不了 W3C 校验器的头号原因。
Step 2:本地构建 + preview 验证
npm run build
npm run preview
curl -I http://localhost:4321/rss.xml
curl -s http://localhost:4321/rss.xml | head -5
通过标准:
- 状态
200 content-type: application/rss+xml(或application/xml)- 内容以
<?xml version="1.0"开头
再确认文件确实在 build 产物里:
ls -la dist/rss.xml # Astro
ls -la out/rss.xml # Next.js 静态导出
preview 通过、生产不通过 → 部署或 rewrite 问题,走 Step 3。preview 也不通过 → 路由文件本身有问题,回 Step 1。dist/ 里没文件但 preview”看起来正常” → 你的端点是按需运行而没预渲染(原因 4)。
Step 3:别让 rewrite 遮住 /rss.xml
先确认文件在部署产物里(Step 2)。如果在,文件系统会直接 serve,不需要动 rewrite。如果还是看到 SPA 外壳,再去修规则:
| 平台 | 修复 |
|---|---|
| Vercel | 不要加 source 为 /rss.xml 的 rewrite——Vercel 不接受文件路径作为 source,且会自动 serve 真实文件。只要确保 build 产出了 rss.xml(Step 1/2)即可。 |
| Netlify | 把 SPA splat 的 ! 强制标记去掉:用 /* /index.html 200,而不是 200!。强制 splat 会覆盖文件遮蔽,把 feed 挡住。 |
| Cloudflare Pages | 确认 functions/ 里没有冲突的 rss.xml.js。该路径下的 Function 会覆盖静态文件,还可能返回错误的 content-type。 |
重部署后,绕过 CDN 缓存再查一次:
curl -I "https://yourdomain.com/rss.xml?cb=$(date +%s)"
必须返回 200 加上 XML content-type。
Step 4:把 RSS 验证写进 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"
挂在 GitHub Actions 的 deployment_status 事件上。首次部署后用 W3C Feed Validator 跑一次,确认阅读器真的能解析。
如何确认已修好
curl -I https://yourdomain.com/rss.xml返回200和content-type: application/rss+xml(或application/xml)。- 内容以
<?xml开头,含一个<channel>,里面有<title>、<link>、<description>。 - W3C Feed Validator 报告 feed 合法。
- 把 URL 粘到真实阅读器(Feedly、NetNewsWire、Inoreader)里——应该能订阅并显示文章,而不是报错。
常见问题
为什么 /rss.xml 在 npm run preview 下正常、生产却 404?
几乎都是 SSR / 边缘不匹配(原因 4),或者文件缺失被 SPA fallback 掩盖了(原因 3)。给 Astro 端点加 export const prerender = true;,重新构建,并在重部署前确认 dist/rss.xml 物理上确实存在。
feed 该用 application/rss+xml 还是 application/xml?
两种阅读器都接受。application/rss+xml 是 RSS 2.0 的规范类型;application/xml(或 text/xml)也能过校验。要避免的是 text/html(SPA fallback)和 text/plain(你漏设了头)。
feed 在浏览器里能打开,但 Feedly 说它不合法,为什么?
浏览器会渲染任何格式正确的 XML,而阅读器会按 RSS 规范校验。常见元凶是 <channel> 里缺 <description>、标题里有未转义的 & / < / >,或 content-type 不对。用 W3C Feed Validator 跑一遍就能定位到具体行。
在 Vercel 上需要加 rewrite 规则才能暴露 /rss.xml 吗?
不需要。Vercel 会在任何 rewrite 被评估之前,先从 build 产物里 serve 静态文件,而且它不接受 source 为文件路径的 rewrite。只要 rss.xml 在产物里,它就能访问;如果 404,说明文件缺失——去修 build,而不是改配置。
我的 Astro feed 只在 dev server 下 404。 这是 trailingSlash: "always" 的怪癖(原因 6,Astro v4.1.0 起)。dev 下 /rss.xml/ 能访问、/rss.xml 404,而生产正常。改用 trailingSlash: "never",并给 rss() 帮助函数传 trailingSlash: false。
预防建议
- 保持唯一来源:HTML 里
<link rel="alternate">的 href 和实际路由文件名必须始终一致。 - 部署后
curl /rss.xml一次,断言 200 加 XML content-type。 - 上线前用 W3C Feed Validator 跑一次,并保证每个
<channel>里都有<title>、<link>、<description>。 - SSR / hybrid 项目里给 RSS 路由显式标
prerender = true,避开边缘 runtime 的怪癖。 - 别靠 rewrite 来暴露 feed——让 build 产出真实文件、让文件系统来 serve。在 Netlify 上,SPA splat 保持不强制(用
200,别用200!)。