本文记录了我在一个 Next.js(App Router)项目部署到 K8s,并通过云厂商 CDN 加速时,遇到的一个生产问题:页面偶发性"变成一堆奇怪的代码",比如 $Sreact.fragment、OutletBoundary 等文本直接渲染到页面上。
最终确认为 CDN 的缓存策略与 RSC 响应变体不兼容所致。下面是完整的排查路径与修复方案,期望对类似场景有所参考。
背景与架构
- 前端框架:Next.js(App Router)
- 部署环境:Kubernetes(K8s)
- 加速:云厂商 CDN(仅支持"目录或后缀"规则配置,不支持按请求头维度的缓存键自定义)
项目包含:
- App Router 页面与静态资源;
现象与线索
页面"变成代码"
- 部分页面在刷新预热后短时间内正常,但过一段时间就会出现页面返回
text/x-component,正文类似:
bash
$Sreact.fragment
OutletBoundary
ClientPageRoot
...
- 响应头包含:
less
Content-Type: text/x-component
Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache: HIT
x-nextjs-prerender: 1
x-nextjs-stale-time: 300
这其实是 Next.js 的 RSC(React Server Components)流式响应,用于客户端路由和预取;不应该被 CDN 给普通页面请求复用。
快速自检(可复制粘贴):
bash
# 1) 看响应是否为 text/x-component(应为 text/html)
curl -s -I https://your-domain/zh-cn/company | grep -i content-type
bash
# 2) 看 CDN 是否命中(不同厂商字段略有差异)
curl -s -I https://your-domain/zh-cn/company | grep -iE 'age|x-cache|cdn-cache'
bash
# 3) 模拟 RSC 请求(Next.js 13+ 通用)
curl -s \
-H "rsc: 1" \
-H "accept: text/x-component" \
https://your-domain/zh-cn/company | head -c 200
根因分析
CDN 错误缓存了 RSC 响应变体
- App Router 下,Next.js 对页面和 RSC 流采用不同的响应形态;RSC 响应以
text/x-component返回,并通过Vary控制不同的请求头变体(如rsc、next-router-prefetch等)。 - 当 CDN 仅支持"目录或后缀"策略、无法将这些请求头并入缓存键时,就会发生"带预取/路由头的 RSC 响应被缓存",随后被误用于普通页面请求,导致页面显示 RSC 流文本。
修复方案
1. CDN 只缓存"静态白名单",页面路径不缓存
由于 CDN 不支持按请求头区分缓存键,采用"白名单缓存静态、黑名单不缓存页面"的策略最稳妥:
- 缓存后缀白名单(TTL 建议 7--30 天):
*.js,*.css,*.png,*.jpg,*.svg,*.webp,*.ico,*.woff,*.woff2
- 缓存目录白名单(TTL 建议 30 天):
/_next/static/*(Next 构建产物路径,带构建指纹,长 TTL 安全)
- 页面目录不缓存(或 TTL=0):
/zh-cn/*,/zh-hk/*,/en-us/*(项目页面均位于语言前缀路径下)
- 默认规则:不缓存(或遵循源站)。
同时,预热(prewarm)只预热静态资源;不要预热页面路径,避免将 RSC 变体推入 CDN 缓存。
2. 源站中间件为 RSC/预取请求设置 no-store
在 src/middleware.ts 中对带有 RSC/预取特征的请求头添加 Cache-Control: private, no-store,减少被 CDN 误缓存的几率。核心逻辑类似:
ts
import { NextRequest, NextResponse } from 'next/server'
export const config = {
matcher: ['/((?!api|_next/static|_next/image|assets|favicon.ico|sw.js).*)'],
}
export function middleware(req: NextRequest) {
const isRSCRequest =
req.headers.get('rsc') === '1' ||
req.headers.get('next-router-prefetch') === '1' ||
req.headers.get('next-router-segment-prefetch') === '1' ||
(req.headers.get('accept') || '').includes('text/x-component') ||
(req.headers.get('accept') || '').includes('application/vnd.nextjs.router+json')
const res = NextResponse.next()
if (isRSCRequest) res.headers.set('Cache-Control', 'private, no-store')
return res
}
注意:CDN 必须"遵循源站缓存指令",该策略才会生效;否则还是要靠规则层面的"页面不缓存"。
提醒:务必确认 CDN 控制台"缓存规则"→"遵循源站 Cache-Control"处于开启状态,否则中间件下发的 no-store 会被 CDN 直接忽略。
发布与验证
- 应用源站中间件变更
- 部署更新并滚动重启应用,使中间件的
Cache-Control: private, no-store生效。 - 确认源站响应已包含预期的
Cache-Control与Content-Type(页面为text/html)。
- 清理并预热 CDN
- 清理缓存后,只预热静态资源目录(如
/_next/static/*)。 - 不预热页面目录(如
/zh-cn/*),避免误缓存 RSC。
- 验证页面与静态资源
- 页面 URL(例如
/zh-cn/company):- 期望
Content-Type为text/html(而非text/x-component)。 - 观察
Age与缓存命中是否符合"不缓存页面"的预期。
- 期望
- 静态资源(如
/_next/static/...):- 期望大 TTL 且经常命中 CDN。
兼容性与权衡
-
ISR(增量静态再生成):
- 响应头可能含
x-nextjs-prerender、x-nextjs-stale-time等提示;若 CDN 不遵循源站策略且易误缓存,建议对容易出问题的页面关闭 ISR(在页面导出revalidate = 0),仅让源站享受缓存;将 CDN 缓存收益保留给静态资源。
- 响应头可能含
-
图片优化:
- 如果启用 Next 图片优化(
/_next/image/*),可以为该目录设置合适的 TTL;若项目配置了images.unoptimized: true,则直接走静态白名单即可。
- 如果启用 Next 图片优化(
-
中间件与国际化重定向:
- 中间件
matcher排除了api、_next/static等路径,不会影响内部 API 与静态资源; - 国际化按语言前缀(
/zh-cn,/zh-hk,/en-us)重定向的场景,仍应遵循"页面不缓存"的 CDN 规则。
- 中间件
最终清单(Checklist)
- CDN:只缓存静态后缀与
/_next/static/*,页面目录不缓存,默认不缓存。 - 源站:中间件为 RSC/预取请求设置
Cache-Control: private, no-store。 - 部署:更新配置并重启,清理 CDN 缓存并仅预热静态资源。
- 验证:确认页面返回 HTML、静态资源缓存命中,且不再出现 RSC 文本。
结语与提问
本文的解决方案基于"CDN 仅支持目录/后缀规则"的现实约束,通过白名单缓存静态与禁止页面缓存,辅以中间件 no-store 将问题最小成本化解决。如果你有更好的思路(比如在不支持请求头缓存键的 CDN 上,仍能优雅地区分 RSC 变体),或者遇到过类似但更复杂的场景,欢迎交流分享:
- 你是否也遇到过 RSC 响应被 CDN 误缓存导致页面渲染异常?
- 在仅支持目录/后缀规则的 CDN 上,你还有哪些更稳妥或更高效的做法?
(本文未包含任何真实域名或链接,示例路径仅用于说明)