Next.js BFF Cache Isolation and SSR Rendering Timing: A Complete Analysis
Next.js BFF 快取隔離與 SSR 渲染時機完全解析
You build a sleek BFF layer with Next.js Route Handlers. It proxies your backend API, shapes data for the frontend, and caches responses for performance. Everything works beautifully — until you realize User A is seeing User B's dashboard. Or worse: an attacker has figured out how to enumerate cache keys and is quietly harvesting personal data from your cache layer.
Meanwhile, your teammate asks a seemingly simple question: "Why doesn't Next.js just generate all pages at build time? Wouldn't that be faster?" The answer is more nuanced than you'd expect, and it reveals a fundamental tension in web architecture between performance, personalization, and security.
This article presents the results of a technical roundtable — five senior engineers debating, proposing solutions, and voting on resolutions for two critical Next.js architecture questions:
- How to design per-user cache isolation in a Route Handler BFF, and how to prevent attackers from accessing other users' cached data
- Why SSR doesn't generate at build time, and how Partial Prerendering (PPR) resolves the SSG vs. SSR dichotomy
Every disputed point was resolved by proposal and vote. No hand-waving. No "it depends" without specifying on what.
你用 Next.js Route Handler 建了一層漂亮的 BFF。它代理後端 API、為前端塑形資料、快取回應以提升效能。一切運作完美——直到你發現 User A 看到了 User B 的儀表板。或者更糟:攻擊者已經搞懂如何枚舉快取鍵,正在悄悄從你的快取層收割個人資料。
同時,你的隊友問了一個看似簡單的問題:「為什麼 Next.js 不直接在 build 時生成所有頁面?那不是更快嗎?」答案比你預期的更有層次,而且它揭示了 Web 架構中效能、個人化與安全之間的根本張力。
本文呈現一場技術圓桌會議的成果——五位資深工程師針對兩個關鍵的 Next.js 架構問題進行辯論、提案與投票表決:
- 如何在 Route Handler BFF 中設計 per-user 快取隔離,以及如何防止攻擊者存取他人的快取資料
- SSR 為何不在 build 時生成,以及 Partial Prerendering(PPR)如何終結 SSG vs. SSR 的二元對立
每一個爭議點都透過提案和投票解決。沒有模糊帶過,沒有不說明「取決於什麼」的「看情況」。
The Panel
圓桌成員
| Expert | Domain | Thinking Style |
|---|---|---|
| Framework Architect | Next.js internals, App Router, Cache Components, PPR | Framework purist — insists on using framework primitives, allergic to "reinventing the wheel" |
| Security Engineer | OWASP Top 10, cache poisoning, IDOR, authentication | Paranoid by profession — vetoes any "ship it first, fix later" proposal |
| Distributed Systems Engineer | Redis, caching strategies, horizontal scaling | Scale-first thinker — first question is always "what if there are 100K concurrent users?" |
| Performance Engineer | Core Web Vitals, Streaming SSR, rendering optimization | Millisecond-obsessed — frowns at anything that adds TTFB |
| DevOps / Platform Engineer | CDN configuration, edge computing, deployment architecture | Infrastructure realist — focused on "will this explode in production?" |
| 專家 | 領域 | 思維特質 |
|---|---|---|
| 框架架構師 | Next.js 內部機制、App Router、Cache Components、PPR | 框架原教旨主義者,堅持用框架 primitive 解決問題,厭惡「自己造輪子」 |
| 安全工程師 | OWASP Top 10、Cache Poisoning、IDOR、認證授權 | 職業偏執狂,對任何「先上線再說」的提案都會投反對票 |
| 分散式系統工程師 | Redis、快取策略、CAP 定理、水平擴展 | 規模化思維,第一句話永遠是「如果有 10 萬個併發用戶呢?」 |
| 效能工程師 | Core Web Vitals、Streaming SSR、渲染策略最佳化 | 毫秒計較者,對任何增加 TTFB 的方案都會皺眉 |
| DevOps / 平台工程師 | CDN 配置、邊緣計算、部署架構 | 基礎設施實踐者,關注「這個方案在生產環境會不會爆炸」 |
Part I: Route Handler BFF — Per-User Cache Isolation and Security
第一部分:Route Handler BFF——Per-User 快取隔離與安全防護
1. Background: How Route Handler Caching Works in Next.js 15+
1. 背景:Next.js 15+ 的 Route Handler 快取機制
Before diving into per-user isolation, we need to understand the current state of caching in Next.js Route Handlers.
Breaking change from Next.js 14 → 15: Route Handlers are no longer cached by default. In Next.js 14, GET Route Handlers were cached automatically, which caused widespread confusion and accidental data leakage. The team reversed this — you now opt in to caching explicitly.
The modern caching primitive: use cache
The use cache directive (replacing the older unstable_cache) is a compiler-level feature that:
- Caches the return value of an async function
- Automatically generates cache keys from: Build ID + Function ID + all serialized arguments + closure variables
- Requires no manual key management — the compiler statically analyzes dependencies
async function getProductData(productId: string) {
'use cache'
cacheLife('hours')
// Cache key automatically includes productId
const res = await fetch(`https://api.example.com/products/${productId}`)
return res.json()
}Key constraint: You cannot call Dynamic APIs (cookies(), headers(), searchParams) inside a use cache scope — they would make the result request-dependent, defeating the purpose of caching.
Available cache variants:
| Variant | Storage | Access to cookies/headers | Cross-request | Use Case |
|---|---|---|---|---|
use cache | Server (Data Cache) | No — pass values as arguments | Yes | Default: shared server cache |
use cache: private | Browser memory | Yes — direct access | No (same session only) | Personalized, client-side caching |
use cache: remote | External store (Redis, KV) | No — pass values as arguments | Yes (across instances) | Multi-instance deployments |
Revalidation tools:
cacheTag('tag-name')— Tags cached entries for targeted invalidationcacheLife('profile')— Controls TTL (stale, revalidate, expire timings)revalidateTag('tag-name')— Invalidates entries by tag (stale-while-revalidate)revalidatePath('/path')— Invalidates an entire route's cache
在深入 per-user 隔離之前,需要先理解 Next.js Route Handler 目前的快取狀態。
從 Next.js 14 → 15 的破壞性變更: Route Handler 不再預設快取。在 Next.js 14 中,GET Route Handler 會自動快取,這造成了廣泛的困惑和意外的資料洩漏。團隊因此反轉了預設值——你現在必須明確 opt-in 快取。
現代快取原語:use cache
use cache directive(取代了舊的 unstable_cache)是一個編譯器層級的功能:
- 快取非同步函式的回傳值
- 自動生成快取鍵,來源包含:Build ID + Function ID + 所有序列化的參數 + 閉包變數
- 不需要手動管理鍵值——編譯器會靜態分析依賴
async function getProductData(productId: string) {
'use cache'
cacheLife('hours')
// 快取鍵自動包含 productId
const res = await fetch(`https://api.example.com/products/${productId}`)
return res.json()
}關鍵限制: 你不能在 use cache 範圍內呼叫 Dynamic API(cookies()、headers()、searchParams)——它們會讓結果依賴於請求,使快取失去意義。
可用的快取變體:
| 變體 | 儲存位置 | 可存取 cookies/headers | 跨請求 | 使用場景 |
|---|---|---|---|---|
use cache | 伺服器(Data Cache) | 否——需作為參數傳入 | 是 | 預設:共享的伺服器快取 |
use cache: private | 瀏覽器記憶體 | 是——可直接存取 | 否(僅同一 session) | 個人化的客戶端快取 |
use cache: remote | 外部儲存(Redis, KV) | 否——需作為參數傳入 | 是(跨 instance) | 多 instance 部署 |
快取失效工具:
cacheTag('tag-name')— 為快取條目加標籤以支援精準失效cacheLife('profile')— 控制 TTL(stale、revalidate、expire 時間)revalidateTag('tag-name')— 依標籤失效條目(stale-while-revalidate)revalidatePath('/path')— 失效整個路由的快取
2. Debate: What Should the User Identifier in Cache Keys Be?
2. 辯論:快取鍵中的使用者識別應該用什麼?
Three options were proposed:
| Option | Description | Risk Profile |
|---|---|---|
| (A) Raw session token | Use the session cookie value directly as part of the cache key | Token rotation invalidates all cache; token is sensitive data appearing in cache keys |
| (B) Server-validated userId | Extract cookie → validate token server-side → use the resulting userId | Stable across token rotations; derived from trusted source |
| (C) HMAC(userId + secret) | Hash the userId with a server secret before using as cache key | Prevents enumeration and cross-service key collision in shared Redis |
The Security Engineer's argument for (B) over (A):
"If you use the session token as the cache key, every token rotation (which should happen regularly for security) invalidates the user's entire cache. And the token itself is sensitive — if it appears in logs, monitoring dashboards, or cache inspection tools, you've created a new attack surface. The userId is stable and non-sensitive."
The Distributed Systems Engineer's challenge — why not (C)?
"In a shared Redis cluster where multiple services write to the same instance, plain userIds risk key collisions. Service A's
user:123:dashboardcould collide with Service B'suser:123:dashboard."
The counterargument from Performance and Framework Engineers:
"For 99% of Next.js applications, the cache is either in-process (filesystem) or Vercel's managed Data Cache. Cross-service collision isn't a real risk. HMAC adds computation per request for no practical benefit. And with
use cache, the compiler generates cache keys that include the Build ID and Function ID — collision is already prevented."
Vote Result:
| Option | Votes | Result |
|---|---|---|
| (A) Raw session token | 0/5 | Rejected |
| (B) Server-validated userId | 4/5 | Adopted |
| (C) HMAC(userId + secret) | 1/5 | Rejected (recommended only for shared Redis with multiple services) |
三個方案被提出:
| 方案 | 描述 | 風險特徵 |
|---|---|---|
| (A) 原始 session token | 直接用 session cookie 值作為快取鍵的一部分 | Token 輪替會使所有快取失效;token 是敏感資訊出現在快取鍵中 |
| (B) 伺服器端驗證後的 userId | 提取 cookie → 伺服器端驗證 token → 使用結果 userId | 跨 token 輪替穩定;來源可信 |
| (C) HMAC(userId + secret) | 用伺服器密鑰對 userId 雜湊後再作為快取鍵 | 防止枚舉和共享 Redis 中的跨服務鍵值碰撞 |
安全工程師支持 (B) 反對 (A) 的論點:
「如果你用 session token 當快取鍵,每次 token 輪替(為了安全應該定期執行)都會使使用者的整個快取失效。而且 token 本身是敏感資訊——如果它出現在日誌、監控面板或快取檢查工具中,你就創造了一個新的攻擊面。userId 是穩定且非敏感的。」
分散式系統工程師的挑戰——為什麼不用 (C)?
「在多個服務寫入同一個 Redis 叢集的環境中,純 userId 有鍵值碰撞的風險。Service A 的
user:123:dashboard可能與 Service B 的user:123:dashboard碰撞。」
效能工程師和框架架構師的反駁:
「99% 的 Next.js 應用,快取要麼在 process 內(filesystem),要麼在 Vercel 的託管 Data Cache 裡。跨服務碰撞不是真實風險。HMAC 在每次請求時增加計算量但沒有實際效益。而且
use cache的編譯器生成的快取鍵已經包含了 Build ID 和 Function ID——碰撞已經被預防了。」
投票結果:
| 方案 | 票數 | 結果 |
|---|---|---|
| (A) 原始 session token | 0/5 | 否決 |
| (B) 伺服器端驗證後的 userId | 4/5 | 通過 |
| (C) HMAC(userId + secret) | 1/5 | 否決(僅建議在多服務共享 Redis 時使用) |
3. Debate: Where Should Authentication Happen Relative to the Cache Boundary?
3. 辯論:認證應該在快取邊界的哪一側執行?
This is the most architecturally critical decision. Two patterns were proposed:
Pattern A: Authenticate Outside, Cache Inside (Unanimous Winner)
// app/api/user/dashboard/route.ts
import { cookies } from 'next/headers'
import { cacheTag, cacheLife } from 'next/cache'
export async function GET() {
// ① OUTSIDE use cache: read cookie and validate
const token = (await cookies()).get('auth')?.value
if (!token) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = await validateToken(token)
if (!userId) {
return Response.json({ error: 'Forbidden' }, { status: 403 })
}
// ② Pass validated userId INTO the cached function
const data = await getCachedDashboard(userId)
return Response.json(data, {
headers: {
'Cache-Control': 'private, no-store, no-cache, must-revalidate',
'Vary': 'Cookie',
},
})
}
// ③ INSIDE use cache: userId is an argument → automatic cache key isolation
async function getCachedDashboard(userId: string) {
'use cache'
cacheTag(`dashboard-${userId}`)
cacheLife({ stale: 60, revalidate: 120, expire: 300 })
const res = await fetch(
`${process.env.BACKEND_API_URL}/dashboard/${userId}`,
{
headers: {
'Authorization': `Bearer ${process.env.INTERNAL_SERVICE_TOKEN}`,
},
}
)
if (!res.ok) throw new Error(`Backend error: ${res.status}`)
return res.json()
}Why this pattern wins:
The Framework Architect explains:
"The
use cachedirective cannot containcookies()orheaders()calls — the compiler will error. This isn't a limitation; it's a security feature. It forces you to separate authentication (request-dependent) from data fetching (cacheable). The userId becomes a function argument, so it's automatically part of the cache key. Different userId → different cache entry. No cross-user leakage is architecturally possible."
Pattern B: use cache: private with Direct Cookie Access (Rejected as Primary)
async function getUserDashboard() {
'use cache: private'
cacheLife({ stale: 60 })
const token = (await cookies()).get('auth')?.value
// ... validate and fetch
}The Performance Engineer's objection:
"
use cache: privatestores in browser memory only. It doesn't persist across page loads, doesn't share across tabs, and doesn't survive navigation. For a BFF layer that's supposed to reduce backend load across requests, it's nearly useless. It's appropriate for UI components that re-render within a single page session — not for API-level caching."
Vote: Pattern A adopted unanimously (5/0). Pattern B noted as a supplementary option for specific UI components, not for BFF architecture.
這是架構上最關鍵的決策。兩種模式被提出:
模式 A:在外部認證,在內部快取(全票通過)
// app/api/user/dashboard/route.ts
import { cookies } from 'next/headers'
import { cacheTag, cacheLife } from 'next/cache'
export async function GET() {
// ① 在 use cache 外部:讀取 cookie 並驗證
const token = (await cookies()).get('auth')?.value
if (!token) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = await validateToken(token)
if (!userId) {
return Response.json({ error: 'Forbidden' }, { status: 403 })
}
// ② 將驗證後的 userId 傳入快取函式
const data = await getCachedDashboard(userId)
return Response.json(data, {
headers: {
'Cache-Control': 'private, no-store, no-cache, must-revalidate',
'Vary': 'Cookie',
},
})
}
// ③ 在 use cache 內部:userId 是參數 → 自動快取鍵隔離
async function getCachedDashboard(userId: string) {
'use cache'
cacheTag(`dashboard-${userId}`)
cacheLife({ stale: 60, revalidate: 120, expire: 300 })
const res = await fetch(
`${process.env.BACKEND_API_URL}/dashboard/${userId}`,
{
headers: {
'Authorization': `Bearer ${process.env.INTERNAL_SERVICE_TOKEN}`,
},
}
)
if (!res.ok) throw new Error(`Backend error: ${res.status}`)
return res.json()
}為什麼這個模式勝出:
框架架構師解釋:
「
use cachedirective 不能包含cookies()或headers()呼叫——編譯器會報錯。這不是限制,這是一個安全特性。它強制你將認證(依賴請求)與資料獲取(可快取)分離。userId 成為函式參數,所以它自動成為快取鍵的一部分。不同的 userId → 不同的快取條目。跨使用者洩漏在架構上是不可能的。」
模式 B:use cache: private 直接存取 Cookie(被否決為主要方案)
async function getUserDashboard() {
'use cache: private'
cacheLife({ stale: 60 })
const token = (await cookies()).get('auth')?.value
// ... 驗證並取得資料
}效能工程師的反對意見:
「
use cache: private只儲存在瀏覽器記憶體裡。它不會跨頁面載入持久化、不會跨分頁共享、不會在導航後存活。對於一個應該跨請求減少後端負載的 BFF 層來說,它幾乎沒有用處。它適合在單一頁面 session 內重新渲染的 UI 組件——不適合 API 層級的快取。」
投票:模式 A 全票通過(5/0)。 模式 B 被記錄為特定 UI 組件的輔助方案,不適用於 BFF 架構。
4. Debate: How to Prevent Attackers from Accessing Other Users' Cache
4. 辯論:如何防止攻擊者存取他人的快取
The Security Engineer identified four attack vectors:
Attack Vector 1: IDOR (Insecure Direct Object Reference)
The Attack: Attacker sends a request with a manipulated userId parameter (e.g., ?userId=victim-id) and the server uses it as the cache key.
The Defense (already resolved): The vote in Section 2 mandates that userId is always derived from server-side token validation, never from client input. With Pattern A from Section 3, the Route Handler reads the cookie, validates it server-side, and extracts the userId. The attacker cannot inject a different userId because they don't control the server-side validation result.
// WRONG — vulnerable to IDOR
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId') // ← Attacker controls this!
const data = await getCachedDashboard(userId!)
return Response.json(data)
}
// CORRECT — userId derived from authenticated session
export async function GET() {
const token = (await cookies()).get('auth')?.value
const userId = await validateToken(token!) // ← Server controls this
const data = await getCachedDashboard(userId!)
return Response.json(data)
}Attack Vector 2: Cache Poisoning
The Attack: Attacker crafts HTTP requests that cause the server to cache malicious data, which is then served to other users.
Known CVEs:
- CVE-2024-46982 (CVSS 7.5): Affected Next.js 13.5.1–14.2.9. Crafted requests could poison the cache of non-dynamic SSR routes in the Pages Router
- CVE-2025-49826: Affected Next.js 15.1.0–15.1.8. ISR routes in
next startor standalone mode could be poisoned
The Defense:
- Keep Next.js updated — both CVEs have been patched
- Use
use cachewith explicit arguments — the compiler generates cache keys from function arguments and closure variables, not from raw HTTP request properties. Attacker-controlled headers or query parameters don't enter the cache key unless you explicitly pass them as arguments - Never cache raw request data — always validate and sanitize before passing to
use cache
Attack Vector 3: CDN Cache Confusion
The Attack: A CDN in front of Next.js caches a personalized response (containing User A's data) and serves it to User B.
The Defense: The DevOps Engineer's mandatory rule:
"Every Route Handler that returns per-user data must include these response headers:"
return Response.json(data, {
headers: {
'Cache-Control': 'private, no-store, no-cache, must-revalidate',
'Vary': 'Cookie',
},
})Cache-Control: privatetells CDNs not to cache this response in shared cachesno-storetells CDNs not to store any versionVary: Cookietells CDNs that responses vary by cookie — even if they do cache, different cookies produce different cache entriesmust-revalidateprevents serving stale cached content
Attack Vector 4: Session Hijacking
The Attack: Attacker steals a user's session token (via XSS, network sniffing, etc.) and uses it to access the victim's cached data.
The Defense: This is not a cache-specific vulnerability — it's a session security issue. Standard mitigations apply:
HttpOnlycookies (prevents JavaScript access)Secureflag (HTTPS only)SameSite=StrictorLax(prevents CSRF)- Short token TTL with rotation
- IP/device binding for sensitive operations
The Security Engineer's summary:
"Cache security is a layer, not a complete solution. If your session management is broken, no amount of cache isolation will save you."
The Six-Layer Defense Model (Voted 5/0)
Layer Defense Purpose
───────────────────────────────────────────────────────────────
1 Auth Gate Verify user identity (outside use cache)
2 Cache Key Isolation userId as argument → per-user cache entries
3 Response Headers Prevent CDN from caching personal data
4 Cache Tags Enable targeted per-user invalidation
5 TTL Limit exposure window if leakage occurs
6 Framework Updates Patch known cache poisoning CVEsVote: Adopted unanimously (5/0).
安全工程師識別出四個攻擊向量:
攻擊向量 1:IDOR(不安全的直接物件引用)
攻擊方式: 攻擊者發送帶有竄改的 userId 參數的請求(例如 ?userId=victim-id),伺服器用它作為快取鍵。
防禦(已在前述決議中解決): 第 2 節的投票決定 userId 永遠從伺服器端 token 驗證衍生,絕不來自客戶端輸入。在第 3 節的模式 A 中,Route Handler 讀取 cookie、在伺服器端驗證、然後提取 userId。攻擊者無法注入不同的 userId,因為他們無法控制伺服器端的驗證結果。
// 錯誤——容易受到 IDOR 攻擊
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId') // ← 攻擊者控制這個!
const data = await getCachedDashboard(userId!)
return Response.json(data)
}
// 正確——userId 從已認證的 session 衍生
export async function GET() {
const token = (await cookies()).get('auth')?.value
const userId = await validateToken(token!) // ← 伺服器控制這個
const data = await getCachedDashboard(userId!)
return Response.json(data)
}攻擊向量 2:Cache Poisoning(快取投毒)
攻擊方式: 攻擊者製造特殊的 HTTP 請求,導致伺服器快取惡意資料,然後這些資料被提供給其他使用者。
已知 CVE:
- CVE-2024-46982(CVSS 7.5):影響 Next.js 13.5.1–14.2.9。精心製作的請求可以在 Pages Router 中毒化非動態 SSR 路由的快取
- CVE-2025-49826:影響 Next.js 15.1.0–15.1.8。
next start或 standalone 模式下的 ISR 路由可被毒化
防禦方案:
- 保持 Next.js 更新——兩個 CVE 都已修補
- 使用帶有明確參數的
use cache——編譯器從函式參數和閉包變數生成快取鍵,而非從原始 HTTP 請求屬性。攻擊者控制的 header 或 query parameter 不會進入快取鍵,除非你明確地將它們作為參數傳入 - 永遠不要快取原始請求資料——在傳入
use cache之前,必須先驗證和淨化
攻擊向量 3:CDN 快取混淆
攻擊方式: Next.js 前方的 CDN 快取了個人化回應(包含 User A 的資料),然後提供給 User B。
防禦方案: DevOps 工程師的強制規則:
「每個回傳 per-user 資料的 Route Handler 必須包含這些回應 header:」
return Response.json(data, {
headers: {
'Cache-Control': 'private, no-store, no-cache, must-revalidate',
'Vary': 'Cookie',
},
})Cache-Control: private告訴 CDN 不要在共享快取中快取這個回應no-store告訴 CDN 不要儲存任何版本Vary: Cookie告訴 CDN 回應會因 cookie 而異——即使快取,不同的 cookie 產生不同的快取條目must-revalidate防止提供過期的快取內容
攻擊向量 4:Session Hijacking(工作階段劫持)
攻擊方式: 攻擊者竊取使用者的 session token(透過 XSS、網路嗅探等),用它存取受害者的快取資料。
防禦方案: 這不是快取特有的漏洞——這是 session 安全問題。標準緩解措施適用:
HttpOnlycookie(防止 JavaScript 存取)Secure旗標(僅 HTTPS)SameSite=Strict或Lax(防止 CSRF)- 短 token TTL 搭配輪替
- 敏感操作的 IP/裝置綁定
安全工程師的總結:
「快取安全是一個層級,不是完整的解決方案。如果你的 session 管理有漏洞,再多的快取隔離都救不了你。」
六層防禦模型(5/0 全票通過)
層級 防禦 目的
───────────────────────────────────────────────────────────────
1 認證閘門 驗證使用者身分(在 use cache 外部)
2 快取鍵隔離 userId 作為參數 → per-user 快取條目
3 回應 Header 阻止 CDN 快取個人化資料
4 快取標籤 支援精準的 per-user 快取失效
5 TTL 限制洩漏發生時的曝露時間窗口
6 框架更新 修補已知的 cache poisoning CVE投票:全票通過(5/0)。
5. The Complete Reference Architecture
5. 完整參考架構
Synthesizing all votes and decisions, here is the production-ready BFF Route Handler pattern:
// ─── lib/auth.ts ─── Authentication utility ───
import { cookies } from 'next/headers'
import { jwtVerify } from 'jose'
export async function getAuthenticatedUserId(): Promise<string | null> {
const token = (await cookies()).get('auth')?.value
if (!token) return null
try {
const { payload } = await jwtVerify(
token,
new TextEncoder().encode(process.env.JWT_SECRET!)
)
return payload.sub as string
} catch {
return null
}
}// ─── app/api/user/dashboard/route.ts ─── BFF Route Handler ───
import { getAuthenticatedUserId } from '@/lib/auth'
import { cacheTag, cacheLife } from 'next/cache'
export async function GET() {
// Layer 1: Auth gate — outside cache boundary
const userId = await getAuthenticatedUserId()
if (!userId) {
return Response.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
// Layer 2: userId as argument — automatic cache key isolation
const data = await getCachedDashboard(userId)
// Layer 3: Response headers — prevent CDN caching
return Response.json(data, {
headers: {
'Cache-Control': 'private, no-store, no-cache, must-revalidate',
'Vary': 'Cookie',
},
})
}
async function getCachedDashboard(userId: string) {
'use cache'
cacheTag(`dashboard-${userId}`) // Layer 4
cacheLife({ stale: 60, revalidate: 120, expire: 300 }) // Layer 5
const res = await fetch(
`${process.env.BACKEND_API_URL}/dashboard/${userId}`,
{
headers: {
'Authorization': `Bearer ${process.env.INTERNAL_SERVICE_TOKEN}`,
'X-Request-User': userId,
},
}
)
if (!res.ok) throw new Error(`Backend API error: ${res.status}`)
return res.json()
}// ─── app/api/user/dashboard/invalidate/route.ts ─── Cache invalidation ───
import { revalidateTag } from 'next/cache'
import { getAuthenticatedUserId } from '@/lib/auth'
export async function POST() {
const userId = await getAuthenticatedUserId()
if (!userId) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
// Only invalidate the requesting user's own cache
revalidateTag(`dashboard-${userId}`)
return Response.json({ revalidated: true })
}Multi-Instance Deployment Note (from the Distributed Systems Engineer):
If deploying across multiple Node.js instances (e.g., Kubernetes, ECS), the default filesystem-based Data Cache does not synchronize across instances. Options:
- Use
use cache: remoteto delegate to a shared store - Implement a custom cache handler pointing to Redis
- Deploy on Vercel, which manages the Data Cache as a shared service
Defense-in-Depth Note (from the Security Engineer):
The BFF is a middleware layer, not a security endpoint. The backend API behind BACKEND_API_URL should independently validate the INTERNAL_SERVICE_TOKEN and verify that the X-Request-User matches the service's access control rules. Every layer should validate independently.
綜合所有投票和決議,以下是生產環境就緒的 BFF Route Handler 模式:
// ─── lib/auth.ts ─── 認證工具 ───
import { cookies } from 'next/headers'
import { jwtVerify } from 'jose'
export async function getAuthenticatedUserId(): Promise<string | null> {
const token = (await cookies()).get('auth')?.value
if (!token) return null
try {
const { payload } = await jwtVerify(
token,
new TextEncoder().encode(process.env.JWT_SECRET!)
)
return payload.sub as string
} catch {
return null
}
}// ─── app/api/user/dashboard/route.ts ─── BFF Route Handler ───
import { getAuthenticatedUserId } from '@/lib/auth'
import { cacheTag, cacheLife } from 'next/cache'
export async function GET() {
// Layer 1:認證閘門——在快取邊界外部
const userId = await getAuthenticatedUserId()
if (!userId) {
return Response.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
// Layer 2:userId 作為參數——自動快取鍵隔離
const data = await getCachedDashboard(userId)
// Layer 3:回應 Header——阻止 CDN 快取
return Response.json(data, {
headers: {
'Cache-Control': 'private, no-store, no-cache, must-revalidate',
'Vary': 'Cookie',
},
})
}
async function getCachedDashboard(userId: string) {
'use cache'
cacheTag(`dashboard-${userId}`) // Layer 4
cacheLife({ stale: 60, revalidate: 120, expire: 300 }) // Layer 5
const res = await fetch(
`${process.env.BACKEND_API_URL}/dashboard/${userId}`,
{
headers: {
'Authorization': `Bearer ${process.env.INTERNAL_SERVICE_TOKEN}`,
'X-Request-User': userId,
},
}
)
if (!res.ok) throw new Error(`Backend API error: ${res.status}`)
return res.json()
}// ─── app/api/user/dashboard/invalidate/route.ts ─── 快取失效 ───
import { revalidateTag } from 'next/cache'
import { getAuthenticatedUserId } from '@/lib/auth'
export async function POST() {
const userId = await getAuthenticatedUserId()
if (!userId) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
// 只失效請求使用者自己的快取
revalidateTag(`dashboard-${userId}`)
return Response.json({ revalidated: true })
}多 Instance 部署注意事項(分散式系統工程師):
如果部署在多個 Node.js instance 上(例如 Kubernetes、ECS),預設的 filesystem Data Cache 不會跨 instance 同步。選項包括:
- 使用
use cache: remote委派到共享儲存 - 實作自訂 cache handler 指向 Redis
- 部署在 Vercel 上,它將 Data Cache 作為共享服務管理
縱深防禦注意事項(安全工程師):
BFF 是中介層,不是安全終點。BACKEND_API_URL 背後的後端 API 應該獨立驗證 INTERNAL_SERVICE_TOKEN,並確認 X-Request-User 符合服務的存取控制規則。每一層都應該獨立驗證。
Part II: Why Doesn't SSR Generate at Build Time?
第二部分:SSR 為何不在 Build 時生成?
6. The Misconception: Next.js Actually Does Generate at Build Time — By Default
6. 常見誤解:Next.js 其實預設就在 Build 時生成
The Performance Engineer opened this section with a correction:
"The question assumes SSR doesn't generate at build time. But in the App Router, the default behavior IS static generation at build time. Every page is pre-rendered during
next buildunless something forces it to be dynamic."
This is a critical distinction. Next.js doesn't choose SSR by default — it falls back to SSR only when static generation is impossible.
What triggers the fallback to SSR (dynamic rendering)?
| Trigger | Why It Prevents Build-Time Generation |
|---|---|
cookies() | Cookie values aren't known until a request arrives |
headers() | Header values aren't known until a request arrives |
searchParams prop | Query parameters aren't known until a request arrives |
connection() | Explicitly defers to request time |
noStore() / unstable_noStore() | Opts out of caching entirely |
fetch(..., { cache: 'no-store' }) | Prevents the fetch result from being cached |
export const dynamic = 'force-dynamic' | Explicit opt-in to SSR |
export const revalidate = 0 | Zero revalidation = always fresh = always SSR |
Dynamic route segments without generateStaticParams | Unknown URL paths at build time |
When Next.js detects any of these during build, it marks the page as dynamic and serves it with Cache-Control: private, no-store.
效能工程師以一個澄清開場:
「這個問題假設 SSR 不在 build 時生成。但在 App Router 中,預設行為就是在 build 時靜態生成。每個頁面在
next build期間都會被預渲染,除非有某些東西強制它變成動態的。」
這是一個關鍵的區分。Next.js 不是預設選擇 SSR——它只在靜態生成不可能時才退回 SSR。
什麼會觸發退回到 SSR(動態渲染)?
| 觸發條件 | 為什麼它阻止 Build 時生成 |
|---|---|
cookies() | Cookie 的值在請求到達之前不可知 |
headers() | Header 的值在請求到達之前不可知 |
searchParams prop | Query parameter 在請求到達之前不可知 |
connection() | 明確地延遲到請求時 |
noStore() / unstable_noStore() | 完全退出快取 |
fetch(..., { cache: 'no-store' }) | 阻止 fetch 結果被快取 |
export const dynamic = 'force-dynamic' | 明確 opt-in SSR |
export const revalidate = 0 | 零 revalidation = 總是新鮮 = 總是 SSR |
沒有 generateStaticParams 的動態路由段 | Build 時不知道 URL 路徑 |
當 Next.js 在 build 期間偵測到這些,它會將頁面標記為動態,並以 Cache-Control: private, no-store 回應。
7. The Four Fundamental Reasons SSR Cannot Be Build-Time
7. SSR 不能在 Build 時生成的四個根本原因
The panel identified four categories, voted unanimously (5/0) on all four:
Reason 1: Request-Dependent Data
The most fundamental reason. Some data simply doesn't exist until a request arrives:
- Who is requesting? (authentication cookie)
- Where are they? (geolocation header,
Accept-Language) - What are they searching for? (
searchParams) - What A/B test variant? (experiment cookie)
You cannot generate a "personalized dashboard for User #12345" at build time because you don't know User #12345 will visit until they actually do. And you certainly can't pre-generate dashboards for all possible users.
Reason 2: Combinatorial Explosion
The Distributed Systems Engineer's math:
"Suppose you have 10,000 users, 50 possible search filter combinations, and 5 locales. To pre-generate every possible page state at build time, you'd need: 10,000 × 50 × 5 = 2,500,000 pages. At 500ms per page, that's 347 hours of build time. And every time any user's data changes, you'd need to regenerate their pages. This is mathematically intractable."
Even with generateStaticParams, you can only pre-generate pages for known parameter values. You cannot anticipate all possible search queries or dynamic user states.
Reason 3: Data Freshness Requirements
Some data has a temporal validity measured in seconds, not hours or days:
- Stock prices, cryptocurrency rates
- Inventory counts ("3 items left")
- Live scores, real-time dashboards
- Chat messages, notifications
For these cases, any cached version is wrong by definition. The data must be fetched at the moment of the request. ISR with a 30-second revalidation window might work for some (the DevOps Engineer's position), but for true real-time requirements, only SSR satisfies the contract.
Reason 4: Security — The CDN Leakage Guard
The Security Engineer's argument:
"This is the reason nobody talks about but everybody should. When a page is statically generated, it's typically cached by CDNs and served to anyone who requests that URL. If personalized data accidentally ends up in a statically generated page, it's now cached in edge nodes worldwide, being served to any visitor."
"Next.js's decision to force SSR when
cookies()is detected is a security guardrail, not a performance limitation. It ensures that per-user data is never accidentally cached in shared infrastructure."
This is why the framework treats Dynamic API usage as a hard boundary — the moment you read a cookie, the page exits the static generation pipeline entirely. Better to be slower and secure than fast and leaking.
圓桌成員識別出四個類別,全部以 5/0 全票通過:
原因 1:資料依賴於請求
最根本的原因。有些資料在請求到達之前根本不存在:
- 誰在請求?(認證 cookie)
- 他們在哪裡?(地理位置 header、
Accept-Language) - 他們在搜尋什麼?(
searchParams) - 什麼 A/B 測試變體?(實驗 cookie)
你不可能在 build 時生成「User #12345 的個人化儀表板」,因為你不知道 User #12345 會來訪——直到他們真的來了。你當然也不可能為所有可能的使用者預先生成儀表板。
原因 2:組合爆炸
分散式系統工程師的算數:
「假設你有 10,000 個使用者、50 種可能的搜尋篩選組合和 5 個語系。要在 build 時預生成每一種可能的頁面狀態,你需要:10,000 × 50 × 5 = 2,500,000 頁。以每頁 500ms 計算,那是 347 小時的 build 時間。而且每次任何使用者的資料更新,你都需要重新生成他們的頁面。這在數學上不可行。」
即使用 generateStaticParams,你也只能為已知的參數值預先生成頁面。你無法預測所有可能的搜尋查詢或動態使用者狀態。
原因 3:資料時效性要求
有些資料的時間有效性以秒計算,而非小時或天:
- 股價、加密貨幣匯率
- 庫存數量(「剩餘 3 件」)
- 即時比分、即時儀表板
- 聊天訊息、通知
對這些場景,任何快取版本就定義而言都是錯的。資料必須在請求的當下獲取。ISR 搭配 30 秒 revalidation 窗口或許能處理部分情況(DevOps 工程師的立場),但對真正的即時需求,只有 SSR 能滿足。
原因 4:安全——CDN 洩漏防護
安全工程師的論點:
「這是沒人談論但每個人都應該關注的原因。當一個頁面被靜態生成,它通常被 CDN 快取並提供給任何請求該 URL 的人。如果個人化資料意外出現在靜態生成的頁面裡,它現在被快取在全球的邊緣節點中,被提供給任何訪客。」
「Next.js 在偵測到
cookies()時強制 SSR 的決策是一個安全護欄,不是效能限制。它確保 per-user 資料永遠不會被意外快取在共享基礎設施中。」
這就是為什麼框架將 Dynamic API 的使用視為硬邊界——你讀取 cookie 的那一刻,頁面就完全退出靜態生成管線。寧可更慢但安全,也不要更快但洩漏。
8. The Rendering Spectrum — From SSG to SSR and Everything Between
8. 渲染光譜——從 SSG 到 SSR 及其之間的一切
The five strategies aren't alternatives — they're points on a spectrum:
← Faster TTFB, less dynamic More dynamic, slower TTFB →
┌─────────┐ ┌─────────┐ ┌──────────────┐ ┌─────────┐ ┌─────────┐
│ SSG │ │ ISR │ │ PPR │ │Streaming│ │ SSR │
│ │ │ │ │ │ │ SSR │ │ │
│Build │ │Build + │ │Build shell + │ │Request │ │Request │
│time │ │background│ │stream holes │ │time │ │time │
│only │ │regen │ │at request │ │(chunks) │ │(full) │
└─────────┘ └─────────┘ └──────────────┘ └─────────┘ └─────────┘
Same for Same for Shell: same Per-user Per-user
all users all users Holes: per-user Per-request Per-request
CDN: yes CDN: yes CDN: shell only CDN: no CDN: no| Strategy | When Generated | Per-User? | Data Freshness | CDN Cacheable |
|---|---|---|---|---|
| SSG | Build time | No | Build time only | Yes |
| ISR | Build + background revalidation | No | Configurable (seconds to hours) | Yes (stale-while-revalidate) |
| PPR | Build (shell) + Request (holes) | Partially | Shell: cached / Holes: real-time | Shell: yes / Holes: no |
| Streaming SSR | Every request (progressive chunks) | Yes | Real-time | No |
| SSR | Every request (full page) | Yes | Real-time | No |
The key insight: You don't pick one strategy per application — you pick the right strategy per page, or even per component (with PPR).
五種策略不是替代品——它們是光譜上的點:
← 更快的 TTFB、較少動態 更多動態、較慢的 TTFB →
┌─────────┐ ┌─────────┐ ┌──────────────┐ ┌─────────┐ ┌─────────┐
│ SSG │ │ ISR │ │ PPR │ │Streaming│ │ SSR │
│ │ │ │ │ │ │ SSR │ │ │
│Build │ │Build + │ │Build shell + │ │Request │ │Request │
│time │ │背景重新 │ │請求時串流 │ │time │ │time │
│only │ │生成 │ │填入動態洞 │ │(chunks) │ │(full) │
└─────────┘ └─────────┘ └──────────────┘ └─────────┘ └─────────┘
所有使用者 所有使用者 Shell:相同 Per-user Per-user
相同 相同 Holes:per-user Per-request Per-request
CDN:是 CDN:是 CDN:僅 shell CDN:否 CDN:否| 策略 | 何時生成 | Per-User? | 資料新鮮度 | CDN 可快取 |
|---|---|---|---|---|
| SSG | Build 時 | 否 | 僅 Build 時 | 是 |
| ISR | Build + 背景 revalidation | 否 | 可配置(秒到小時) | 是(stale-while-revalidate) |
| PPR | Build(shell)+ 請求(holes) | 部分 | Shell:快取 / Holes:即時 | Shell:是 / Holes:否 |
| Streaming SSR | 每次請求(漸進式區塊) | 是 | 即時 | 否 |
| SSR | 每次請求(整頁) | 是 | 即時 | 否 |
核心洞見: 你不需要為整個應用選擇一種策略——你應該每個頁面,甚至每個組件(透過 PPR)選擇正確的策略。
9. PPR: The Resolution of the SSG vs. SSR Dichotomy
9. PPR:SSG vs. SSR 二元對立的解決
The panel voted 3:2 (with caveats) that PPR should be the default strategy for new projects. Here's why.
The Problem PPR Solves
Traditional Next.js forces an all-or-nothing rendering decision at the page level: if any component needs cookies(), the entire page becomes SSR. A dashboard with a static sidebar, cached announcements, and a personalized feed? The whole page pays the SSR cost because of the personalized feed.
How PPR Works
PPR splits a single page into three rendering tiers:
export default function DashboardPage() {
return (
<>
{/* TIER 1: Static — generated at build time, CDN cached */}
<Sidebar />
<Header />
{/* TIER 2: Cached — generated at build time, revalidated periodically */}
<Announcements />
{/* TIER 3: Dynamic — streamed at request time */}
<Suspense fallback={<DashboardSkeleton />}>
<PersonalizedDashboard />
</Suspense>
<Suspense fallback={<NotificationsSkeleton />}>
<UserNotifications />
</Suspense>
</>
)
}// Tier 1: Static — no I/O, no data fetching
function Sidebar() {
return <nav>{/* ... */}</nav>
}
// Tier 2: Cached — fetches data but caches it
async function Announcements() {
'use cache'
cacheLife('hours')
const data = await fetch('https://api.example.com/announcements')
return <div>{/* ... */}</div>
}
// Tier 3: Dynamic — requires request-time data
async function PersonalizedDashboard() {
const userId = await getAuthenticatedUserId()
const data = await getCachedDashboard(userId!)
return <div>{/* ... */}</div>
}At build time, Next.js generates a static HTML shell containing Tier 1 and Tier 2 content, with <Suspense> fallback placeholders for Tier 3 "holes."
At request time:
- The static shell is sent immediately from CDN edge → near-instant TTFB
- Dynamic holes are resolved in parallel on the origin server
- Results are streamed into the corresponding
<Suspense>boundaries - The user sees the static shell first, then dynamic content fills in progressively
The Architectural Diagram
Build Time Request Time
────────── ────────────
┌─────────────────────┐ ┌─────────────────────┐
│ Static Shell (HTML) │ ── CDN ──→│ Sent immediately │
│ ┌─────────────────┐ │ │ ┌─────────────────┐ │
│ │ Sidebar (static) │ │ │ │ Sidebar ✓ │ │
│ │ Header (static) │ │ │ │ Header ✓ │ │
│ │ Announcements ✓ │ │ │ │ Announcements ✓ │ │
│ │ │ │ │ │ │ │
│ │ ┌───────────────┐│ │ │ │ ┌───────────────┐│ │
│ │ │ Skeleton... ││ │ │ │ │ Loading... ││ │
│ │ └───────────────┘│ │ │ │ └──────┬────────┘│ │
│ │ ┌───────────────┐│ │ │ │ │ Stream │ │
│ │ │ Skeleton... ││ │ │ │ ▼ │ │
│ │ └───────────────┘│ │ │ │ ┌───────────────┐│ │
│ └─────────────────┘ │ │ │ │ Dashboard ✓ ││ │
└─────────────────────┘ │ │ │ Notifications ✓││ │
│ │ └───────────────┘│ │
│ └─────────────────┘ │
└─────────────────────┘Why the Panel (Mostly) Agrees
- Performance Engineer: "PPR gives you static-page TTFB with dynamic-page functionality. It's the best of both worlds."
- Framework Architect: "PPR is the natural evolution. It replaces the page-level SSG/SSR choice with a component-level choice."
- Security Engineer: "PPR inherently separates public content (static shell) from private content (dynamic holes). The architecture itself is a security boundary."
The Dissent
- Distributed Systems Engineer: "For purely static sites — blogs, documentation — PPR adds conceptual complexity with zero benefit. SSG is simpler and sufficient."
- Security Engineer (second point): "Agreed. Don't add complexity where it's not needed. Use the simplest strategy that meets your requirements."
Vote: PPR as default for new projects with mixed static/dynamic content — 5/5.Vote: PPR for purely static content sites — 0/5. Use SSG instead.
圓桌成員以 3:2(附帶條件)投票通過 PPR 應為新專案的預設策略。以下是原因。
PPR 解決的問題
傳統的 Next.js 在頁面層級強制一個全有或全無的渲染決策:如果任何組件需要 cookies(),整個頁面都變成 SSR。一個包含靜態側邊欄、快取的公告和個人化動態牆的儀表板?整個頁面因為個人化動態牆而付出 SSR 的成本。
PPR 如何運作
PPR 將單一頁面拆成三個渲染層級:
export default function DashboardPage() {
return (
<>
{/* 層級 1:靜態——Build 時生成,CDN 快取 */}
<Sidebar />
<Header />
{/* 層級 2:快取——Build 時生成,定期 revalidate */}
<Announcements />
{/* 層級 3:動態——請求時 streaming */}
<Suspense fallback={<DashboardSkeleton />}>
<PersonalizedDashboard />
</Suspense>
<Suspense fallback={<NotificationsSkeleton />}>
<UserNotifications />
</Suspense>
</>
)
}// 層級 1:靜態——無 I/O、無資料取得
function Sidebar() {
return <nav>{/* ... */}</nav>
}
// 層級 2:快取——取得資料但快取起來
async function Announcements() {
'use cache'
cacheLife('hours')
const data = await fetch('https://api.example.com/announcements')
return <div>{/* ... */}</div>
}
// 層級 3:動態——需要請求時的資料
async function PersonalizedDashboard() {
const userId = await getAuthenticatedUserId()
const data = await getCachedDashboard(userId!)
return <div>{/* ... */}</div>
}在 Build 時,Next.js 生成一個包含層級 1 和層級 2 內容的靜態 HTML shell,並以 <Suspense> fallback 佔位符標記層級 3 的「洞」。
在請求時:
- 靜態 shell 從 CDN 邊緣節點立即發送 → 近乎即時的 TTFB
- 動態洞在 origin server 上並行解析
- 結果串流進入對應的
<Suspense>邊界 - 使用者先看到靜態 shell,然後動態內容漸進式填入
架構示意圖
Build 時 請求時
────── ────────
┌─────────────────────┐ ┌─────────────────────┐
│ Static Shell (HTML) │ ── CDN ──→│ 立即發送 │
│ ┌─────────────────┐ │ │ ┌─────────────────┐ │
│ │ Sidebar (靜態) │ │ │ │ Sidebar ✓ │ │
│ │ Header (靜態) │ │ │ │ Header ✓ │ │
│ │ Announcements ✓ │ │ │ │ Announcements ✓ │ │
│ │ │ │ │ │ │ │
│ │ ┌───────────────┐│ │ │ │ ┌───────────────┐│ │
│ │ │ Skeleton... ││ │ │ │ │ Loading... ││ │
│ │ └───────────────┘│ │ │ │ └──────┬────────┘│ │
│ │ ┌───────────────┐│ │ │ │ │ Stream │ │
│ │ │ Skeleton... ││ │ │ │ ▼ │ │
│ │ └───────────────┘│ │ │ │ ┌───────────────┐│ │
│ └─────────────────┘ │ │ │ │ Dashboard ✓ ││ │
└─────────────────────┘ │ │ │ Notifications ✓││ │
│ │ └───────────────┘│ │
│ └─────────────────┘ │
└─────────────────────┘為什麼圓桌成員(大部分)同意
- 效能工程師:「PPR 給你靜態頁面的 TTFB 加上動態頁面的功能。兩全其美。」
- 框架架構師:「PPR 是自然的演化。它把頁面層級的 SSG/SSR 選擇替換為組件層級的選擇。」
- 安全工程師:「PPR 天然地將公開內容(靜態 shell)和私有內容(動態洞)分離。架構本身就是安全邊界。」
不同意見
- 分散式系統工程師:「對純靜態網站——部落格、文件——PPR 增加了概念複雜度但零收益。SSG 更簡單也足夠。」
- 安全工程師(第二點):「同意。不要在不需要的地方增加複雜度。使用符合你需求的最簡單策略。」
投票:PPR 作為包含混合靜態/動態內容的新專案預設——5/5。投票:PPR 用於純靜態內容網站——0/5。 改用 SSG。
10. Decision Flowchart: Choosing Your Rendering Strategy
10. 決策流程圖:選擇你的渲染策略
Does the page contain ANY per-user or per-request data?
│
├── No → Does the data change after build?
│ ├── No → SSG (pure static)
│ └── Yes → ISR (static + background revalidation)
│
└── Yes → Is the ENTIRE page per-user?
│
├── Yes → Does it need real-time data (< 30s freshness)?
│ ├── Yes → Streaming SSR
│ └── No → SSR + use cache per-user (cached SSR)
│
└── No (mixed static + dynamic) → PPR
│
├── Static parts → build-time shell
├── Cacheable parts → use cache
└── Dynamic parts → Suspense boundaries (streamed)The Framework Architect's closing note:
"With Cache Components and PPR in Next.js 16, the
export const dynamicroute config is being phased out. Instead of declaring a page as 'force-dynamic' or 'force-static' at the top level, you declare rendering behavior per component usinguse cacheand<Suspense>. This is a fundamental shift from page-level to component-level rendering decisions."
| Old Approach | New Approach |
|---|---|
export const dynamic = 'force-dynamic' | Remove (pages are dynamic by default with Cache Components) |
export const dynamic = 'force-static' | Add 'use cache' with cacheLife('max') at component level |
export const revalidate = 3600 | cacheLife({ revalidate: 3600 }) per cached function |
unstable_cache(fn, keys, opts) | 'use cache' directive with automatic key generation |
頁面是否包含任何 per-user 或 per-request 的資料?
│
├── 否 → 資料在 build 後會變嗎?
│ ├── 否 → SSG(純靜態)
│ └── 是 → ISR(靜態 + 背景 revalidation)
│
└── 是 → 整個頁面都是 per-user 的嗎?
│
├── 是 → 需要即時資料(< 30 秒新鮮度)嗎?
│ ├── 是 → Streaming SSR
│ └── 否 → SSR + use cache per-user(快取的 SSR)
│
└── 否(混合靜態 + 動態) → PPR
│
├── 靜態部分 → build 時的 shell
├── 可快取部分 → use cache
└── 動態部分 → Suspense 邊界(串流)框架架構師的結語:
「隨著 Next.js 16 的 Cache Components 和 PPR,
export const dynamic路由配置正在被淘汰。你不再需要在頂層宣告頁面是 'force-dynamic' 還是 'force-static',而是每個組件使用use cache和<Suspense>來宣告渲染行為。這是從頁面層級到組件層級渲染決策的根本轉變。」
| 舊做法 | 新做法 |
|---|---|
export const dynamic = 'force-dynamic' | 移除(Cache Components 下頁面預設為動態) |
export const dynamic = 'force-static' | 在組件層級加上 'use cache' 搭配 cacheLife('max') |
export const revalidate = 3600 | 每個快取函式用 cacheLife({ revalidate: 3600 }) |
unstable_cache(fn, keys, opts) | 'use cache' directive 搭配自動鍵值生成 |
Summary: All Resolutions
Topic 1: Route Handler BFF Cache Isolation
| Resolution | Content | Vote |
|---|---|---|
| User identifier | Server-validated userId (not session token, not client input) | 4/5 |
| Auth boundary | Authenticate outside use cache; pass userId as argument into it | 5/5 |
| Cache mechanism | use cache + cacheTag + cacheLife; use cache: remote for multi-instance | 5/5 |
| Security model | Six-layer defense: Auth Gate → Key Isolation → Response Headers → Tags → TTL → Updates | 5/5 |
| CDN protection | Personal responses must include Cache-Control: private, no-store + Vary: Cookie | 5/5 |
Topic 2: Why SSR Doesn't Generate at Build Time
| Resolution | Content | Vote |
|---|---|---|
| Core reason | Pages depend on request-time data (identity, search params, real-time data) | 5/5 |
| Framework behavior | App Router defaults to static generation; SSR is a fallback, not the default | 5/5 |
| Security rationale | Forced SSR on Dynamic API usage prevents personalized data CDN leakage | 5/5 |
| Recommended strategy | PPR for mixed static/dynamic; SSG for purely static; Streaming SSR for fully dynamic | 5/5 |
| Architecture shift | Page-level rendering config → Component-level use cache + <Suspense> | 5/5 |
總結:所有決議
議題一:Route Handler BFF 快取隔離
| 決議 | 內容 | 投票 |
|---|---|---|
| 使用者識別 | 伺服器端驗證後的 userId(非 session token、非客戶端輸入) | 4/5 |
| 認證邊界 | 在 use cache 外部認證;將 userId 作為參數傳入 | 5/5 |
| 快取機制 | use cache + cacheTag + cacheLife;多 instance 用 use cache: remote | 5/5 |
| 安全模型 | 六層防禦:認證閘門 → 鍵值隔離 → 回應 Header → 標籤 → TTL → 框架更新 | 5/5 |
| CDN 防護 | 個人化回應必須包含 Cache-Control: private, no-store + Vary: Cookie | 5/5 |
議題二:SSR 為何不在 Build 時生成
| 決議 | 內容 | 投票 |
|---|---|---|
| 核心原因 | 頁面依賴請求時才有的資料(身分、搜尋參數、即時資料) | 5/5 |
| 框架行為 | App Router 預設靜態生成;SSR 是退回方案,非預設 | 5/5 |
| 安全考量 | Dynamic API 使用時強制 SSR 防止個人化資料透過 CDN 洩漏 | 5/5 |
| 推薦策略 | 混合靜態/動態用 PPR;純靜態用 SSG;全動態用 Streaming SSR | 5/5 |
| 架構轉變 | 頁面層級渲染配置 → 組件層級 use cache + <Suspense> | 5/5 |
References
- Next.js: Route Handlers
- Next.js: Caching and Revalidating
- Next.js:
use cacheDirective - Next.js:
use cache: privateDirective - Next.js: Cache Components
- Next.js: Route Segment Config
- Next.js: Static and Dynamic Rendering
- Next.js:
cacheLifeFunction - Next.js:
cacheTagFunction - Next.js:
revalidateTagFunction - Next.js: Incremental Static Regeneration Guide
- Next.js Blog: Composable Caching
- Vercel Blog: Partial Prerendering
- CVE-2024-46982: Cache Poisoning in Next.js
- CVE-2025-49826: Cache Poisoning via ISR Routes
- GHSA-g5qg-72qw-gw5v: Image Optimization Cache Key Confusion
參考資料
- Next.js: Route Handlers
- Next.js: Caching and Revalidating
- Next.js:
use cacheDirective - Next.js:
use cache: privateDirective - Next.js: Cache Components
- Next.js: Route Segment Config
- Next.js: Static and Dynamic Rendering
- Next.js:
cacheLifeFunction - Next.js:
cacheTagFunction - Next.js:
revalidateTagFunction - Next.js: Incremental Static Regeneration Guide
- Next.js Blog: Composable Caching
- Vercel Blog: Partial Prerendering
- CVE-2024-46982: Next.js 快取投毒漏洞
- CVE-2025-49826: ISR 路由快取投毒
- GHSA-g5qg-72qw-gw5v: Image Optimization 快取鍵混淆