RSS feed 返回 404:定位三大原因 + 修复 /rss.xml

/rss.xml 或 /feed.xml 返回 404:端点没进 build、文件名和 link 标签不一致,或宿主 rewrite 把它挡住了。针对 Astro、Next.js、Netlify、Vercel 的分栈修复。

你在博客模板里看到 <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
只有 dev404、生产正常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 标签,没有任何生成端点的文件,就是这种情况。

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.xmlsitemap.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.siteundefined,说明你没在 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> 里必须有三个子元素:titlelinkdescription。漏掉 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 跑一次,确认阅读器真的能解析。

如何确认已修好

  1. curl -I https://yourdomain.com/rss.xml 返回 200content-type: application/rss+xml(或 application/xml)。
  2. 内容以 <?xml 开头,含一个 <channel>,里面有 <title><link><description>
  3. W3C Feed Validator 报告 feed 合法。
  4. 把 URL 粘到真实阅读器(Feedly、NetNewsWire、Inoreader)里——应该能订阅并显示文章,而不是报错。

常见问题

为什么 /rss.xmlnpm 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!)。

相关阅读

标签: #部署 / 托管 #排查 #排查