- Published on
我用不到 Next.js 的大半功能,它還是決定了我的架構
- Authors
-
-
- Name
- Alex Yu
-
// next.config.js
output: 'export'
// app/article/page.tsx
export const dynamic = 'force-static'九月的時候我在 Threads 上說 Next.js 不適合拿來當純前端框架,有人回了我這兩行。
他說得對。這兩行下去,Next.js 就會產出一包純靜態的檔案,不需要 Node 伺服器。但我自己的部落格今年五月從 Next.js 搬到 Astro,搬完之後幾乎不送 JavaScript 給瀏覽器,靜態輸出的 Next.js 頁面做不到這件事。
那一串留言底下有人分享了他們公司的多國系統,Next.js 配 Java 後端,用得很順。也有人說他們從 COBOL 換成 Nuxt,主要理由是招得到人。
我接手的是一個內容網站,後端有專門的人負責,API 都是後端給的,網站自己架,沒有放在託管平台上。接手之後我才發現,沒用到的功能關掉就好,比較麻煩的是幾個關不掉的行為。
先講結論:如果讓我選,像這樣有獨立後端、又自己架的內容網站,我不會選 Next.js。
它幫我解掉的
伺服器先把 HTML 產好,爬蟲一抓就是完整的內容。本以為 Googlebot 會執行 JavaScript 就沒事了。但它是先抓 HTML,JavaScript 要晚一點才執行。Vercel 和 MERJ 在 2024 年 7 月量過,render 的延遲中位數是 10 秒,最慢的 10% 接近 3 小時。伺服器先產好 HTML 就沒有這一段。
更硬的理由是那些根本不跑 JavaScript 的東西。他們同年 12 月的另一份量測裡,GPTBot、ClaudeBot、PerplexityBot 這些 AI 爬蟲都只抓 HTML,不執行 JS。社群的預覽卡也有自己的限制,Meta 的文件寫 Open Graph 標籤要出現在前 1 MB 以內。
這點要說公道話:Astro、Nuxt、SvelteKit 也都做得到,這是 SSR(server-side rendering,伺服器先把 HTML 產好再送出去)給的。Next.js 的好處是,團隊本來就在用 React 的話,不用換 tech stack 就有 SSR。
另一個好處是,API 的 token 這類不能讓瀏覽器看到的東西,可以只放在伺服器上。
// app/article/[id]/page.tsx ← Server Component
export default async function Page({ params }) {
const res = await fetch(`${process.env.API_BASE}/article/${params.id}`, {
headers: { Authorization: `Bearer ${process.env.API_TOKEN}` }, // ← 只存在 server
cache: 'no-store',
})
return <Article data={await res.json()} />
}API 的位置、token、內部欄位,瀏覽器都看不到。它只拿到畫面。登入那條路徑也一樣,cookie 由 server 設成 httpOnly,token 不會進到 JavaScript 裡。上個月那篇 Set-Cookie 被 CDN 吃掉講的就是這條路徑。
我用不到的那些
先講圖片。next/image 本來會幫你做掉一大串事:
<Image
src="/cover.jpg"
alt=""
width={1200}
height={630}
sizes="(max-width: 768px) 100vw, 800px" // ← 剩下的交給瀏覽器挑
/>它會產好一整排不同寬度的檔案,寫進 srcset,瀏覽器依照螢幕大小和 DPR(裝置像素比,手機通常是 2 或 3)自己挑一張。轉成 WebP 或 AVIF、預設 lazy load、先佔好位置避免版面跳動,這幾件 next/image 也一起做了。
只是我們沒有用到它:
// next.config.js
images: { unoptimized: true },因為圖片在進來之前就轉過格式了。編輯在後台上傳圖片時,後台會先在編輯自己的瀏覽器裡把它轉成 WebP,再存起來,前端不用再轉一次。
但這樣少掉一半:轉檔只換了格式,寬高照原圖,沒有準備不同尺寸的版本,手機和桌機下載到的是同一張大圖。
Server Actions(在 React 元件裡直接寫 server 端的函式,表單送出就呼叫它)我們一個都沒寫。內建的快取最後也關掉,交給 CDN 一層處理。現在是兩個地方各關一次:
// 打 API 的那一層
fetch(url, { cache: 'no-store' }) // ← 資料不存,用到它的頁面也跟著每次重新產生
// next.config.js
experimental: {
staleTimes: { dynamic: 0, static: 0 }, // ← 瀏覽器端那層也不存
},Next.js 的快取有四層,這樣關掉三層。剩下的那層只在同一次請求裡有效,不會跨請求留下舊資料。頁面能存多久,交給回應上的 Cache-Control,由 CDN 讀。
這是 Next.js 跟其他 SSR 框架差最多的地方。SvelteKit、React Router 也能在 server 先把 HTML 產好,但它們沒有自己的資料快取,頁面要存多久,就看你在回應上設的 Cache-Control。Next.js 自己多做了好幾層,而且每一版的預設值還不太一樣,15 版就把 fetch 和瀏覽器端那層改成預設不存。
前後端分離的架構裡,前面通常已經有 CDN,後端也可能有自己的快取。框架再多一套,出問題的時候就多一個要排除的地方。這些雖然都關得掉,但要先花時間搞懂才知道怎麼關。後端團隊完整的話,我會選一套一開始就沒有這幾層的框架,把這段排查的時間省下來。
關不掉的那些
下面三件,Next.js 沒有給開關。要嘛接受,要嘛換框架。
同一份內容會送兩次
App Router 的頁面除了 HTML,還會把 RSC payload(React Server Components 產出的那份資料)塞在同一份文件裡,給瀏覽器接手時用。paper clover 在 2025 年 10 月的文章裡量過,Next.js 官網首頁大約 750kB,內容在裡面出現兩次。這是 React Server Components 的設計。
而且同一個網址會回兩種東西:一般的請求回 HTML,請求帶著 RSC: 1 這個 header 的話,回的是 RSC payload。所以前面掛 CDN 的時候,回應上的這個 Vary 一定要留著,CDN 才會把兩種分開存:
Vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Accept-EncodingAmple 在 2025 年 3 月寫過這個情況:CDN 預設把這個 Vary 拿掉,使用者按上一頁,看到的是一坨原始的 RSC payload。
response header 會被合併
登入壞掉那次踩的就是它。middleware 設了一次 Cache-Control,route 裡再設一次,後面那次不會蓋掉前面那次:
// middleware.ts
res.headers.set('Cache-Control', 'public, max-age=60')
// app/api/auth/route.ts
res.headers.set('Cache-Control', 'no-store') // ← 不會蓋掉上面那行,兩邊會合併root layout 在換頁時不會重新 mount
root layout 是最外層的 app/layout.tsx,每一頁都包在它裡面。點站內連結換頁的時候,瀏覽器不會整頁重新載入,只換掉內容,這叫 client 端換頁。這時候 app/article/page.tsx 這種頁面層會重新 mount(拆掉再建一次),root layout 會一直留著。
沒用到的功能,資安公告一樣要處理
這是我最沒預期到的成本。
| 日期 | 發生什麼 |
|---|---|
| 2025-12-03 | React2Shell,嚴重度 10 分滿分,美國 CISA 列進「已知被利用」清單 |
| 2025-12-11 | 兩個新的:DoS 與原始碼曝露 |
| 2025-12-12 | 前一個修補不完整,已經升過的要再升一次 |
| 2026-01-28 | RSC 的 DoS 修補又不完整 |
| 2026-04-10 | RSC DoS |
| 2026-05-11 | 一次 13 個,最高 8.6,包含只打自架的 SSRF |
| 2026-07-22 | 一次 9 個,Server Actions 的 CPU DoS、rewrites 的 SSRF |
| 2026-09-08 | 一次兩個 Critical |
九個月,八批。2025 年 12 月那次,九天內升了三次。
我們沒寫過一個 Server Action,但其中好幾個漏洞的位置就在 RSC 與 Server Actions 的處理流程上。你沒用到那個功能,它還是跟著你的 node_modules 一起上線。
RSC 協定本身的漏洞不是 Next.js 獨有,React 官方點名的清單裡也有 React Router 的 RSC API 和其他框架。有幾個漏洞的官方說明也寫明託管平台不受影響,自己架的才要處理。
代價最大的是其中一批:我們用的 v14 沒有出修補版。
- "next": "^14",
- "react": "^18",
+ "next": "^15",
+ "react": "^19",只能跨大版本升到 v15,順便把 React 從 18 升到 19。最後那個 PR 改了 38 個檔案、一千兩百多行:async params、React 19 的型別、測試對齊新的 ARIA 行為,還有 tsconfig 的 target。
廣告和 PV 的做法,也一起被決定了
這是我進媒體業之後才知道的事。8/30 那篇貼到 FB 社團的時候,有人留言問:廣告版位為什麼要做 SSR,交給 client 端不就好了?我回頭查了程式碼,這個問題問得對。有沒有素材可以投、版位之間會不會互相擋,本來就是廣告 SDK(瀏覽器裡負責把廣告抓回來、畫進版位的那段 JavaScript)在處理,那篇後來也照這個改了一次。
但廣告交給 client 端,換頁的方式一變,還是有事要自己做。傳統的網站每次換頁都是整頁重新載入,廣告自然會重新請求一次。client 端換頁不會。頁面沒有重載,廣告的版位還在記憶體裡,沒有人會替你重新要一次廣告。
這件事 Google 其實有官方範例,叫〈GPT and React〉,用的就是 Next.js 加 client 端換頁:gpt.js 只載一次,版位跟著頁面元件建立,元件拆掉時再清掉。文件裡講得最重的是清版位這句:
Failure to call this function before removing a slot’s div from the page will result in undefined behavior.
所以每一頁要自己做這一段,掛在頁面層:
// 全站只做一次:googletag.pubads().disableInitialLoad()
useEffect(() => {
let slots: googletag.Slot[] = []
googletag.cmd.push(() => {
slots = defineSlotsForThisPage() // ← 用這一頁的版位宣告
slots.forEach((s) => googletag.display(s)) // ← 先登記,這時還不會要廣告
googletag.pubads().refresh(slots) // ← 只要這一頁的廣告
})
return () => {
googletag.cmd.push(() => googletag.destroySlots(slots)) // ← 離開這頁時清掉
}
}, [])destroySlots() 和 refresh() 都要帶這一頁的版位。不帶的話,會把整頁的版位全部清掉、全部重新要一次,連跨頁一直留著的那幾個也算在內。refresh() 之前也一定要先 display(),官方文件寫明了少這一步行為會不正常。
有些變現平台的 SDK 會自己偵測換頁,像 Raptive 會盯著網址變化、重新載入廣告。我們用的 SDK 要在每一頁自己呼叫清掉再重建。自己接 GPT 的話,就是上面這段。
我們站上真正走 client 端換頁的只有幾個元件,其中一個是文章底下的延伸閱讀。那剛好是文章跳文章、廣告最值錢的一條動線。
前面那個「root layout 不會重新 mount」,在這裡出事。版位會先照最大的尺寸留空間,廣告回來之後,再把多留的空白收掉,而且只收畫面下方還看不到的那些,免得畫面跳一下。這段程式原本掛在 root layout,使用者先落在沒有廣告的頁面、再點進文章頁時,它一次都不會被觸發,多留的空白就一直在那裡。修法是把它搬到頁面層。
更麻煩的是,同一次換頁牽動三套獨立的計數器:廣告平台算曝光、GA4 算 page_view,有些廣告商自己還有一套(Freestar 預設會自己監聽網址變化,關掉的話就要在換頁時呼叫 freestar.trackPageview())。GA4 那套預設會監聽瀏覽器的歷史紀錄變化,官方文件寫得很直接:
If Enhanced Measurement is enabled, Google Analytics will send page_view events based on browser history changes even if you set send_page_view: false
gtag('config', 'G-XXXXXXX', { send_page_view: false })
// ⚠️ enhanced measurement 還開著的話,換頁一樣會自己送一次 page_view這個選項可以在 GA4 後台關掉。Google 的建議是兩種挑一種:交給它自動抓,或關掉自己送。用 GTM 的話官方直接說不要開,會重複算。
Hina Chen(hinablue)2016 年就寫過這件事的另一面,作者後來還做了一個 Vue 用的 DFP 套件。重新要了廣告,廣告也不一定出得來:Google 只回一句沒抓到廣告,事件算出來的數量跟畫面上真的有廣告的版位對不起來。最後的做法是自己掃畫面,檢查版位裡有沒有被塞進 Google 的標記,沒有就再要一次。廣告沒出來的時候不會噴錯誤,只會少賺。
還有一個日期:Google 在 2027 年 2 月 17 日要把顯示型廣告的曝光計算方式改掉,從「開始下載就算」變成「開始畫出來才算」,官方自己寫了 “you may notice a decrease in total display impressions”。
什麼情況我會選它
這張表只看團隊和架構,效能不算在內。外面那幾份 benchmark 我讀完反而不敢拿來當理由,那一段下週另外寫。
| 情況 | 我會怎麼選 | 依據 |
|---|---|---|
| 沒有獨立後端的小專案,要 SSR 又要處理表單 | Next.js 或 React Router 都行 | 這種情況 Server Actions 真的用得上 |
| 團隊已經在 React,需要 SEO,而且放在託管平台 | Next.js | 快取和幾個只打自架的漏洞不用自己顧 |
| 有獨立後端,但要多語系、要長期有人接手 | Next.js 也合理 | 生態系和招募是真的理由 |
| 內容網站 | Astro | 我自己的部落格就是這樣搬的 |
| 內網工具,不需要 SEO | Vite 加一套路由 | 我的 side project 是這樣 |
| 自己架,又沒有人固定看資安公告 | 不要選 | 九個月八批 |
Astro 也有它的代價
表格裡我把內容站寫成 Astro,但那不是免費的。Astro 把頁面上需要互動的區塊叫做「島」,每個島各自載入自己的 JavaScript,其他地方就是純 HTML。我部落格搬家一天就做完,坑都在部署那天冒出來:島裡讀不到 NEXT_PUBLIC_ 開頭的環境變數、client:visible 永遠不觸發、第三方追蹤碼要加 is:inline 才會原樣輸出。前兩個寫在搬家那篇,追蹤碼那個在自架 analytics 那篇。
生態系也小很多,團隊要重新學一套語法。而且如果整站都要互動,每個元件最後都變成島,那就繞遠路做了一個 SPA(單頁應用,整個網站只載入一次,之後都在瀏覽器裡換畫面),倒不如用一套本來就為 app 設計的框架。
還沒處理的部分
公司的網站還跑在 Next.js 上,下次資安的漏洞如果公告出來的時候,一樣要升級。我推過把它搬到 Astro,做到一半被其他工作卡住,到現在沒有上線。
要再接著搬,也沒有想像中簡單。現在改程式有 AI 工具幫忙,但整個網站的測試和 integration test(把前端、API、登入這些串起來一起跑的測試)都要先補齊。不然框架換完以後,只能靠手動一頁一頁測,又要花不少時間。
延伸閱讀
- 有人說我的部落格很絲滑,因為它幾乎不送 JavaScript:同一個判斷,我在自己的部落格上做完了
- 刷了 CDN 還是舊資料:Next.js 的快取沒有清除按鈕:那四層快取當初為什麼決定關掉
- 登入有時候好、有時候壞:CDN 把 Set-Cookie 吃掉了:response header 合併踩到的坑
參考資料
- One Year with Next.js App Router
- Next.js 15:Caching Semantics
- SvelteKit:Loading data
- React Router:Route Module(headers)
- Freestar:Track Page Views
- The role of CDNs on RSC payloads in NextJS
- Next.js Security Update: December 11, 2025
- iThome:React 伺服器元件追補安全更新
- GHSA-83fc-fqcc-2hmg
- GHSA-p9j2-gv94-2wf4
- GHSA-89xv-2m56-2m9x
- GHSA-m99w-x7hq-7vfj
- CVE-2026-44578 的說明貼文
- Google Publisher Tag:GPT and React(官方 SPA 範例)
- Google Publisher Tag:destroySlots
- Raptive:Single-page Application Sites
- Google Ad Manager:Declare inventory that refreshes
- Google Ad Manager:Begin-to-render impression counting
- GA4:Measure single-page applications
- Google Search Central:Understand JavaScript SEO Basics
- Vercel × MERJ:How Google handles JavaScript throughout the indexing process
- Vercel × MERJ:AI 爬蟲不執行 JavaScript 的量測
- DFP 如果再給我一次機會(hinablue)