10/4 那篇講 Next.js 沒給開關的三件事,其中一件是同一個網址會回兩種東西,當時只用一段帶過。這篇把它講完:CDN 什麼情況下會把兩種搞混、怎麼驗、驗出來有問題要怎麼處理。

會想講完,是因為 9/7 那篇最後把快取全部交給 CDN。快取都在 CDN 那層,CDN 拿什麼當 cache key(判斷兩個請求算不算同一份的依據),就直接決定使用者拿到哪一種。

一個網址,兩種回應

App Router 底下,/article/123 至少有兩種回法:

# 直接在網址列打開,或搜尋引擎爬進來
curl -sI https://example.com/article/123
# → content-type: text/html
 
# 在頁面上點連結換頁(client-side navigation)
curl -sIL 'https://example.com/article/123?_rsc=abc12' -H 'RSC: 1'
# → content-type: text/x-component

第二種是 RSC payload,也就是 React Server Components 的輸出結果,給已經載入的頁面拿來局部更新畫面。它不是 HTML,瀏覽器直接打開只會看到一堆看不懂的文字。

第二個請求的網址後面多了 ?_rsc=abc12。這是 Next.js 自己加的,值是用幾個 request header 算出來的 hash。Next.js 會不會核對這個值,看版本:16.3 以前隨便填一個也拿得到 payload;16.3 起預設會檢查(設定是 experimental.validateRSCRequestHeaders),值對不上就回 307,轉到帶正確 hash 的網址。上面的 curl 加 -L 是為了跟著轉,這樣新舊版本都看得到 text/x-component。

本以為一定會混在一起

我本以為 CDN 只認網址,這兩種回應遲早會被當成同一份存起來。翻了 Next.js 的 CDN 快取文件才知道,它早就處理過了,而且處理了兩層:

  • response 上帶 Vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch。Vary 的意思是「這幾個 request header 不一樣時,請分開存」。Vary 後面列哪幾個 header,每個版本不太一樣:15 起多一個 Next-Router-Segment-Prefetch,用到攔截路由(intercepting routes)的頁面再多一個 Next-Url
  • 很多 CDN 不看 Vary,所以它另外在網址加上 _rsc。CDN 就算只認網址,兩種請求的網址本來就不一樣

自己用 curl 看,Vary 裡可能還有一個 Accept-Encoding,這跟 RSC 無關。瀏覽器發出請求時,會用 Accept-Encoding 說它看得懂哪幾種壓縮格式(gzip、brotli),伺服器就先把 HTML 壓小再回傳,省流量。壓過和沒壓的內容不一樣,所以要加這一項,請 CDN 分開存。做壓縮的可能是 Next.js 內建的 gzip(next.config 的 compress,預設開著),也可能是前面的 nginx 或 CDN。

Vary 被 CDN 拿掉也不稀奇。我在 9/7 那篇就遇過 stale-while-revalidate 被 CDN 自己用掉,沒有傳到瀏覽器。

什麼時候還是會存錯

最常見的是兩層同時失效:CDN 不看 Vary,而且 cache key 不帶 query string。這時一般使用者正常瀏覽就會存錯。

第二個條件很容易在不知情的情況下成立。很多 CDN 有「忽略 query string」的選項,常被拿來擋 utm_source 這類追蹤參數,不然同一篇文章會因為來源不同被存成好幾份。打開它,_rsc 也一起被丟掉了。

CDN 先存到哪一種,看那個網址第一個訪客怎麼進來:

兩種順序。先存到 HTML:使用者 A 直接打開,CDN 存下 HTML;使用者 B 點連結換頁,CDN 拿掉 ?_rsc= 之後找到同一份,回了 HTML,Next.js 改成整頁重新載入。先存到 RSC payload:使用者 A 點連結換頁,CDN 把 RSC payload 存在 /article/123 底下;使用者 B 直接打開,拿到一整頁看不懂的文字
兩種順序。先存到 HTML:使用者 A 直接打開,CDN 存下 HTML;使用者 B 點連結換頁,CDN 拿掉 ?_rsc= 之後找到同一份,回了 HTML,Next.js 改成整頁重新載入。先存到 RSC payload:使用者 A 點連結換頁,CDN 把 RSC payload 存在 /article/123 底下;使用者 B 直接打開,拿到一整頁看不懂的文字

先存到的是 HTML

使用者 A 直接開 /article/123
  → CDN MISS → Next.js 回 HTML → CDN 存下這份 HTML
 
使用者 B 從其他頁點連結到 /article/123
  → 請求 /article/123?_rsc=abc12
  → CDN 丟掉 query string,當成 /article/123
  → CDN HIT → 拿到 HTML

Next.js 發現拿到的不是 RSC payload,會改成整頁重新載入。畫面沒壞,只是每次點連結都整頁重新載入。

先存到的是 RSC payload

使用者 A 從其他頁點連結到 /article/123
  → 請求 /article/123?_rsc=abc12
  → CDN 丟掉 query string → MISS → Next.js 回 RSC payload
  → CDN 把這份 payload 存在 /article/123 底下
 
使用者 B 新開分頁直接打開 /article/123
  → CDN HIT → 拿到 RSC payload

這個方向比較嚴重:直接打開的人看到的是一整頁看不懂的文字,搜尋引擎的爬蟲也可能剛好拿到這一份。

跟 Next.js 版本有關的兩種情況

只有第一層失效,也不一定安全。16.3 以前,Next.js 預設不檢查 _rsc,請求只要帶 RSC: 1,網址就算沒有 _rsc 也會回 RSC payload。CDN 不看 Vary 的話,這份 payload 就存在一般網址底下,跟上面第二張圖的結果一樣。這種請求可能是重新導向造成的:瀏覽器跟著轉址時會留著 RSC: 1,_rsc 卻不見了,Contentstack 的說明文件寫的就是這種。也可能是有人故意發的。Next.js 原始碼裡的註解寫得很直接:不檢查的話,有些 CDN 會被拿來做 cache poisoning(故意讓 CDN 存進錯的內容,之後的訪客都拿到錯的)。

16.3 起改成預設檢查,_rsc 對不上就回 307,上面那種請求就存不進去了。但多了另一種問題:CDN 把請求轉給 origin 時,如果連 query string 也拿掉,origin 收到帶 RSC: 1 卻沒有 _rsc 的請求,會回 307 要瀏覽器帶上正確的值;瀏覽器帶了又被拿掉,就這樣一直重新導向,到了上限 Chrome 會停下來,顯示 ERR_TOO_MANY_REDIRECTS 的錯誤頁。

怎麼確認你的 CDN 有沒有這個問題

需要兩個請求:一個一般的,一個跟瀏覽器點連結換頁時發出的一模一樣。第二個從 DevTools 的 Network 面板複製:在網站裡點一次連結,找網址帶 _rsc= 的那個請求,右鍵 Copy as cURL。要整串複製,不要只複製 _rsc 的值。16.3 起 Next.js 會核對 _rsc 跟 header 算出來的 hash 對不對,curl 只帶 RSC: 1 會拿到 307,看不出 CDN 有沒有問題。複製下來之後把網址的路徑換成要測的那一頁,_rsc 和 header 都不要動。hash 只跟 header 有關,跟路徑無關。

挑一個還沒人看過的頁面,或請管 CDN 的人先清掉那一頁,照這個順序打:

URL=https://example.com/article/123
 
# 1. 先讓 HTML 進快取
curl -sI "$URL" | grep -iE '^HTTP|content-type|vary|cache'
 
# 2. 再打點連結換頁的請求:貼上從 DevTools 複製的那串,結尾加 -sI 和 grep
curl "$URL?_rsc=複製的值" -H 'RSC: 1' -H 'Next-Router-State-Tree: 複製的值' ... \
  -sI | grep -iE '^HTTP|content-type|vary|cache'

第二個請求要回 text/x-component。如果回的是 text/html,而且快取狀態是 HIT,代表 CDN 把 query string 丟掉、也沒看 Vary,拿第一個請求存下來的 HTML 回給你了。如果看到 307,是 _rsc 跟 header 對不上,回去確認 Copy as cURL 有沒有整串複製,這不是 CDN 的問題。

再換一個沒人看過的頁面,順序反過來打一次(先打 2 再打 1)。這次看的是直接訪問那個請求,它如果拿到 text/x-component,就是比較嚴重的那個方向。

還在 16.3 以前的版本,再多測一種:換一個沒人看過的頁面,先打一次只帶 RSC: 1、網址不帶 _rsc 的請求,再打一般請求。一般請求如果拿到 text/x-component,代表 CDN 沒看 Vary,就算 cache key 有帶 query string 也會存錯。這幾種測法都會在 CDN 留下存錯的那一份,測完記得請管 CDN 的人清掉。

快取狀態的 header 名稱每家 CDN 不一樣,x-cache、cache-status 都有,所以 grep 只抓 cache 這個字。

解法

不管選下面哪一種,Next.js 先升到 15.5.16、16.2.5 以上。2026 年 5 月公布的兩個漏洞都跟這件事有關,都在這兩版修好:CVE-2026-44576 是有人可以讓 CDN 在一般網址底下存進 RSC payload,CVE-2026-44582 是 _rsc 原本只有 5 個字元,不同的 header 可能算出一樣的值。升級之後 _rsc 變長了,但 16.2.x 預設還是不檢查,要到 16.3 才會。

讓 cache key 帶上 _rsc

最省事。cache key 本來就帶 query string 的話什麼都不用改;開了「忽略 query string」的,把 _rsc 設成例外留下來。Next.js 的文件直接要求 CDN 把 _rsc 放進 cache key,還特別提醒有些 CDN 預設就會把 query string 丟掉。

代價是命中率。_rsc 是用 Next-Router-State-Tree 這些 header 算出來的,從不同的頁面點進同一頁,_rsc 就不一樣,CDN 會把同一頁的 RSC payload 存成好幾份。有人在十萬個商品頁的電商網站上遇到,開了 issue,到現在還沒有解法。HTML 不受影響,只是點連結換頁比較常要回 origin 拿。

把 RSC header 加進 cache key

不靠網址,直接在 CDN 設定裡把 RSC 這個 request header 算進 cache key。這一招連 16.3 以前那種沒帶 _rsc 的請求也擋得住。

CDN 本來不支援的話,可以看看最近有沒有改。Cloudflare 就是一例:以前不看 Vary,2026 年 7 月起可以在 Cache Rules 裡設定照 Vary 分開存,所有方案都能用,但要自己去設。

Next-Router-State-Tree 先別加。它的值跟使用者當下在哪一頁有關,變化很多,加進去同一頁會被存成很多份,命中率會變差。

帶 RSC: 1 的請求一律不快取

在 CDN 設一條規則:request header 有 RSC: 1 就直接回 origin,其他照常快取 HTML。

點連結換頁會慢一點,但設定簡單,也不可能存錯,沒帶 _rsc 的請求一樣會被擋下來。對大部分流量從搜尋和外部連結進來的內容網站,在網站裡點連結換頁的人本來就是少數。

CDN 完全不快取頁面

Next.js 自己那層的快取還開著的話可以這樣做。但如果像 9/7 那篇一樣把 Next.js 的快取關掉了,等於整個網站都沒有快取,所有流量都直接打到 origin。

清快取的時候兩種都要清

不管用哪一種,CDN 裡存的都是兩種回應。呼叫 revalidatePath() 之後要另外打 CDN 的清除 API,Next.js 的文件特別寫了 HTML 和 RSC 兩種 key 都要清。只清 HTML,點連結換頁的人還是會拿到舊的。部署新版之後也一樣:上面那個 issue 裡有人遇到攔截路由在部署後壞掉,最後是每次部署都把整個 CDN 清一次。

為什麼要在設定 CDN 的那天就驗

這個 bug 不會穩定重現。哪一種先被存起來,看的是那個網址第一個訪客怎麼進來:直接打開,還是從其他頁點連結過去。不同節點的結果也可能不一樣。使用者回報「有時候會壞」,你自己怎麼點都正常。

「忽略 query string」通常是為了別的理由打開的,打開的人不一定知道 Next.js 靠它分辨兩種回應。Next.js 的文件寫了之後打算把會影響回應的東西都搬進網址路徑,_rsc 和自訂 header 的 Vary 都會拿掉,到時候丟掉 query string 就不會出事。在那之前,每次改 CDN 的 cache key 設定,上面兩個 curl 都要再跑一次。

延伸閱讀