MUI v7 vs Radix + Tailwind: A Complete Performance and DX Experiment Design
MUI v7 vs Radix + Tailwind:效能與 DX 實驗設計全紀錄
"MUI is slow because of Emotion." You've heard this. Maybe you've even said it. But how slow, exactly? Is it 5ms or 500ms? Does it matter for your specific use case? And is the alternative — Radix + Tailwind — actually faster, or just perceived as faster because of community narrative?
Opinions are cheap. Controlled experiments are not.
This article documents the complete design of a controlled experiment comparing MUI v7 + Emotion against Radix UI + Tailwind CSS — covering performance and developer experience (DX) — built on a real KPI Dashboard scenario. Every architecture decision, every measurement choice, and every statistical method was debated by a panel of specialists and resolved by vote.
This is not the experiment results. This is the experiment design — the rigorous foundation that makes the results trustworthy.
「MUI 因為 Emotion 所以很慢。」你聽過這句話。也許你自己也說過。但到底慢多少?是 5ms 還是 500ms?對你的具體場景重要嗎?而替代方案——Radix + Tailwind——真的比較快,還是只是因為社群敘事而感覺比較快?
觀點很廉價,控制實驗不是。
本文記錄了一個對照實驗的完整設計:比較 MUI v7 + Emotion 與 Radix UI + Tailwind CSS 的效能和開發者體驗(DX),建立在一個真實的 KPI Dashboard 場景上。每一個架構決策、每一個測量選擇、每一個統計方法都經過專家圓桌辯論並以投票決議。
這不是實驗結果。這是實驗設計——讓結果值得信賴的嚴謹基礎。
1. The Core Hypothesis
1. 核心命題
Hypothesis: MUI components carry runtime logic costs (Emotion serialization, theme lookup, CSS injection) that Radix + Tailwind components don't (static classNames).
Purpose: Measure exactly how large this runtime cost is, in which scenarios it manifests, and whether it's large enough to influence library selection.
Every architecture debate, Suspense boundary decision, and state management choice in this document serves one goal: control other variables so that this core hypothesis can be cleanly measured.
假設:MUI 的元件有 runtime 邏輯成本(Emotion 序列化、theme lookup、CSS injection),而 Radix + Tailwind 的元件沒有這些成本(靜態 className)。
目的:量測這個 runtime 邏輯成本到底是多少、在哪些場景下顯現、是否大到值得影響 library 選擇。
本文中所有的架構爭議、Suspense boundary 設計、state 管理方案選擇,都服務於一個目標:控制其他變因,讓這個核心命題能被乾淨地量測。
2. Technology Stack
2. 技術棧確認
| Item | Selection | Rationale |
|---|---|---|
| MUI Version | v7 (default Emotion) | Pigment CSS still in alpha, not practical |
| Radix Version | Radix Primitives + Tailwind CSS | The representative headless + utility-first combo |
| Groups | 2 | MUI v7 + Emotion vs Radix + Tailwind |
| Framework | Next.js 15 (App Router) | Modern React SSR standard |
| Data Fetching | TanStack Query v5 | Shared between both groups, eliminates data layer variance |
| Mock API | MSW + 50ms fixed delay | Fixed latency, eliminates API variance |
| 項目 | 選定方案 | 理由 |
|---|---|---|
| MUI 版本 | v7(預設 Emotion) | Pigment CSS 仍為 alpha,不實用 |
| Radix 版本 | Radix Primitives + Tailwind CSS | Headless + utility-first 的代表組合 |
| 實驗組數 | 2 組 | MUI v7 + Emotion vs Radix + Tailwind |
| 框架 | Next.js 15 (App Router) | 現代 React SSR 標準 |
| Data Fetching | TanStack Query v5 | 兩邊共用,消除 data layer 差異 |
| Mock API | MSW + 50ms fixed delay | 固定延遲,消除 API 變異 |
3. The Benchmark: KPI Dashboard Specification
3. 基準測試對象:KPI Dashboard 規格
3.1 UI Layout
┌─────────────────────────────────────────────────┐
│ Sidebar (static nav, excluded from perf test) │
│ ┌───────────────────────────────────────────┐ │
│ │ [Account A ▾] ◀ Jan 2026 ▶ │ │
│ ├───────────────────────────────────────────┤ │
│ │ ┌─────┐ ┌─────┐ ┌─────┐ │ │
│ │ │KPI 1│ │KPI 2│ │KPI 3│ │ │
│ │ └─────┘ └─────┘ └─────┘ │ │
│ │ ┌─────┐ ┌─────┐ │ │
│ │ │KPI 4│ │KPI 5│ │ │
│ │ └─────┘ └─────┘ │ │
│ └───────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘3.2 KPI Card Specification
Each KPI Card contains:
- Title (fixed text)
- Value (from API)
- Trend arrow icon (fixed representative color, not data-driven)
- Hover effect: subtle shadow elevation
- No ripple, no animation, no sparkline
3.3 Filter Controls
- AccountDropdown (select): Choose account (individual / all)
- MonthNavigator (◀ ▶ buttons): Navigate year/month
3.4 API Design
| API | Endpoint | Description |
|---|---|---|
| Account list | GET /api/accounts | For dropdown, fetched once then cached (staleTime: Infinity) |
| KPI data | GET /api/kpi?year=2026&month=1&account=all | Returns 5 KPI values, varies by year/month/account |
3.5 Critical Style Decision: All Fixed Styles
All KPI Card styles are fixed (pure representative colors). No CSS changes dynamically based on data. This means:
- Emotion cache hits 100% after first render
- Trend arrow colors are fixed representative colors (not data-driven)
- No continuous or arbitrary dynamic styles
This is a deliberate design choice that gives MUI its best-case scenario for Emotion caching. If MUI still shows measurable overhead here, the gap would only widen in more dynamic scenarios.
3.1 UI 規格
┌─────────────────────────────────────────────────┐
│ Sidebar(靜態導航列,不參與效能測試) │
│ ┌───────────────────────────────────────────┐ │
│ │ [帳號A ▾] ◀ 2026年1月 ▶ │ │
│ ├───────────────────────────────────────────┤ │
│ │ ┌─────┐ ┌─────┐ ┌─────┐ │ │
│ │ │KPI 1│ │KPI 2│ │KPI 3│ │ │
│ │ └─────┘ └─────┘ └─────┘ │ │
│ │ ┌─────┐ ┌─────┐ │ │
│ │ │KPI 4│ │KPI 5│ │ │
│ │ └─────┘ └─────┘ │ │
│ └───────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘3.2 KPI Card 規格
每張 KPI Card 包含:
- 標題(固定文字)
- 數值(來自 API)
- 趨勢箭頭 icon(固定代表色,非 data-driven)
- Hover 效果:subtle shadow elevation
- 無 ripple、無 animation、無 sparkline
3.3 篩選控制項
- AccountDropdown(下拉選單):選帳號(個別帳號 / 全部帳號)
- MonthNavigator(◀ ▶ 按鈕):切換年月
3.4 API 設計
| API | 端點 | 說明 |
|---|---|---|
| 帳號列表 | GET /api/accounts | 供下拉選單使用,fetch 一次後 cache(staleTime: Infinity) |
| KPI 資料 | GET /api/kpi?year=2026&month=1&account=all | 一次回傳 5 個 KPI 數據,依年/月/帳號變化 |
3.5 關鍵樣式決策:全固定樣式
所有 KPI Card 的樣式都是固定的(純代表色),沒有任何根據資料動態改變的 CSS。這意味著:
- Emotion cache 在首次 render 後 100% 命中
- 趨勢箭頭顏色是固定代表色(非 data-driven)
- 無連續值或任意值的動態樣式
這是刻意的設計選擇,給了 MUI 在 Emotion 快取上的最佳情境。如果 MUI 在這裡仍然顯示可量測的額外成本,差距在更動態的場景下只會更大。
4. Next.js Rendering Model: The Context That Shapes Architecture
4. Next.js 渲染模型:塑造架構的背景知識
The architecture decisions in Section 5 cannot be understood without first clarifying how Next.js rendering actually works. Several common misconceptions directly influenced the experiment's design evolution.
4.1 PPR Status in Next.js 15 / 16
PPR (Partial Prerendering) is not the default in either version:
| Aspect | Next.js 15 | Next.js 16 |
|---|---|---|
| PPR Status | Experimental | Stabilized via Cache Components |
| How to Enable | experimental: { ppr: 'incremental' } | cacheComponents: true |
| Default? | No | No |
| Default Rendering | Static / Dynamic (auto-switches based on Dynamic API usage) | Same (when cacheComponents not enabled) |
Without PPR enabled, the default logic is: no Dynamic API → Static Rendering (build time); uses Dynamic API → the entire route switches to Dynamic Rendering (request time). PPR solves exactly this "all-static or all-dynamic per route" limitation.
4.2 Component Types and Rendering Timing
The key factor determining when a component renders is not sync/async — it's whether the component can complete its work during the prerendering phase.
At build time, Next.js renders your route's component tree. As long as components don't access network resources, certain system APIs, or require an incoming request to render, their output is automatically added to the static shell. — Next.js Documentation
| Component Type | Initial HTML Timing | Notes |
|---|---|---|
| Sync Server Component (no dynamic API) | Build-time | No async, no dynamic API → automatically in static shell |
Async Server Component + use cache | Build-time (cached) | Result cached and included in static shell, updatable via revalidation |
Async Server Component + <Suspense> (no cache) | Request-time (streaming) | This is the true "dynamic hole" in PPR |
Client Component ("use client") | Follows route strategy (can be build-time) | "use client" only determines client hydration, does not affect when HTML is generated |
Common misconceptions debunked:
"use client"does NOT mean client-side rendering — in Next.js, Client Components still have their initial HTML generated by the server."use client"only marks "this component needs client-side hydration"use cacheasync components are NOT request-time rendered — the opposite:use cacheexists precisely to cache async results into the static shell- Sync does NOT guarantee build-time — the key is whether request-specific data (like
cookies(),headers()) is accessed
4.3 The Rendering Spectrum
Next.js decomposes the traditional "CSR vs SSR" binary into a finer-grained spectrum:
Fully Static (Build-time) Fully Dynamic (Request-time) Pure CSR
◄────────────────────────────────────────────────────────────────────────────►
Sync SC use cache Suspense-wrapped Traditional CRA
(static shell) (cached shell) Async SC (streaming) (blank HTML)
← Next.js scope: server generates HTML in ALL cases → ← Next.js
doesn't do thisThree time points exist in Next.js, not just two:
| Time Point | Who | What Happens | Legacy Equivalent |
|---|---|---|---|
| Build-time | Build Server | Pre-render static HTML, served directly from CDN | Like SSG |
| Request-time (Server) | Application Server | Server renders HTML per request | Like traditional SSR |
| Hydration-time (Client) | Browser | JavaScript activates, attaches event handlers, enables state | This is the interactive CSR layer |
4.4 What Does use cache Actually Cache?
use cache caches neither "raw data" nor "complete static HTML files" — it caches the React Server Component rendering result (RSC Payload):
Data Layer Component Render Layer Full Page Layer
(raw JSON) (RSC Payload) (Full HTML)
▲
│
What use cache cachesThe granularity is up to you:
Where use cache is placed | What's cached |
|---|---|
Function level async function getData() | Function return value (close to caching data) |
Component level async function MyComponent() | Component's RSC Payload (rendered component tree) |
| File level (top of page) | Entire page's RSC Payload (close to caching the full page) |
第 5 節的架構決策如果沒有先釐清 Next.js 渲染的實際運作方式,就無法被理解。幾個常見誤解直接影響了實驗的設計演進。
4.1 PPR 在 Next.js 15 / 16 的狀態
PPR(Partial Prerendering)在兩個版本中都不是預設:
| 面向 | Next.js 15 | Next.js 16 |
|---|---|---|
| PPR 狀態 | Experimental(實驗性) | 透過 Cache Components 穩定化 |
| 啟用方式 | experimental: { ppr: 'incremental' } | cacheComponents: true |
| 是否為預設 | 否 | 否 |
| 預設渲染模式 | Static / Dynamic(依據是否使用 Dynamic API 自動切換) | 同左(未開啟 cacheComponents 時) |
未啟用時,Next.js App Router 的預設邏輯是:沒有使用 Dynamic API → Static Rendering(build time);使用了 Dynamic API → 整個 route 切換為 Dynamic Rendering(request time)。PPR 要解決的正是這個「一個 route 只能全靜態或全動態」的痛點。
4.2 元件類型與渲染時機
決定元件何時渲染的關鍵不是 sync/async,而是元件在 prerendering 階段能不能完成它的工作。
At build time, Next.js renders your route's component tree. As long as components don't access network resources, certain system APIs, or require an incoming request to render, their output is automatically added to the static shell. — Next.js 官方文件
| 元件類型 | 初始 HTML 產生時機 | 備註 |
|---|---|---|
| Sync Server Component(無 dynamic API) | Build-time | 無 async、無 dynamic API,自動納入 static shell |
Async Server Component + use cache | Build-time(快取結果) | 結果被快取並納入 static shell,可透過 revalidation 更新 |
Async Server Component + <Suspense>(無 cache) | Request-time(streaming) | 這才是真正的 dynamic,PPR 的「洞」(dynamic hole) |
Client Component("use client") | 跟隨所在 route 的策略(可以是 build-time) | "use client" 只決定 hydration 在 client,不影響 HTML 何時產生 |
常見誤解澄清:
"use client"不等於 client-side rendering——在 Next.js 中,Client Component 的初始 HTML 仍由 server 產生,"use client"只是標記「這個元件需要 client-side hydration」use cache的 async component 不是 request-time 渲染——恰好相反,use cache的目的正是讓 async 元件的結果被快取並納入 static shell- Sync 不保證 build-time——關鍵不是 sync/async,而是是否存取了 request-specific 的資料(如
cookies()、headers())
4.3 Next.js 渲染光譜
Next.js 把傳統「CSR vs SSR」的二元對立,拆解成了更細粒度的光譜:
完全 Static (Build-time) 完全 Dynamic (Request-time) 純 CSR
◄──────────────────────────────────────────────────────────────────────────►
Sync SC use cache Suspense 包的 傳統 CRA
(static shell) (cached shell) Async SC (streaming) (空白HTML)
← Next.js 的範圍,所有情境 server 都會產生 HTML → ← Next.js
不做這個Next.js 裡有三個時間點,而不是只有 CSR 和 SSR 兩個:
| 時間點 | 誰在做 | 發生什麼 | 對應舊觀念 |
|---|---|---|---|
| Build-time | Build Server | 預渲染靜態 HTML,部署後直接從 CDN 送出 | 類似 SSG |
| Request-time (Server) | Application Server | 每次 request 到來時,server 才渲染 HTML | 類似傳統 SSR |
| Hydration-time (Client) | Browser | JavaScript 啟動,掛載 event handlers、啟用 state | 這才是 CSR 的互動層 |
4.4 use cache 快取的是什麼?
use cache 快取的不是「原始資料」也不是「完整 HTML 靜態檔」,而是 React Server Component 的渲染結果(RSC Payload):
資料層 元件渲染結果層 完整頁面層
(raw JSON) (RSC Payload) (Full HTML)
▲
│
use cache 快取的東西粒度是你決定的:
use cache 放的位置 | 快取的內容 |
|---|---|
函式層級 async function getData() | 該函式的回傳值(接近快取資料) |
元件層級 async function MyComponent() | 該元件的 RSC Payload(渲染好的元件樹) |
| 檔案層級(頁面最上方) | 整個頁面的 RSC Payload(接近快取整頁) |
5. Architecture Design: From Ideal to Pragmatic
5. 架構設計:從理想到務實
5.1 The Evolution
The design went through multiple rounds, from an extreme leaf-node architecture to a pragmatic single client boundary.
Initial Proposal: Extreme Leaf Node Architecture
DashboardPage (SC)
├── Sidebar (SC)
└── KPISection (SC)
├── <Suspense>
│ └── AccountDropdown (CC)
└── KPICard × 5 (SC)
├── CardTitle (SC)
├── <Suspense>
│ └── KPINumber (CC)
└── CardDescription (SC)Problem: How do multiple disconnected Client Components share filter state (year, month, accountId)?
State-sharing approaches considered:
| Approach | Description | Problem |
|---|---|---|
| URL Search Params | router.push('?account=newId') triggers server navigation | Account switch adds server round-trip, slower than MUI |
| Shared Client Context | Add Provider at top level | Client boundary gets pushed up, degrades to non-leaf |
| nuqs (URL state library) | URL as shared state | May still trigger server re-render |
| Zustand | Module-level store | Viable, but introduces additional dependency |
| TanStack Query Cache | setQueryData + invalidateQueries | Using server state manager for client state, design intent mismatch |
5.2 The Key Insight: Why Leaf Node Yields Near-Zero Benefit
Server Components save "component JavaScript runtime logic." But Tailwind KPI Cards have no JS runtime logic to save:
// Tailwind KPICard — just a div + className
function KPICard({ title, description, children }) {
return (
<div className="rounded-lg border p-4 hover:shadow-md transition-shadow">
<span className="text-sm text-gray-500">{title}</span>
{children}
<span className="text-xs text-gray-400">{description}</span>
</div>
)
}
// As SC: server executes → sends HTML → saves "calling this function on client"
// As CC: client calls this function → produces identical HTML
// Difference ≈ 0.01ms (calling a function that just returns JSX)Compare with MUI's Card, which has real runtime logic (read theme context → Emotion serialization → CSS hash → DOM injection), but MUI components cannot be Server Components due to Emotion's browser Context dependency.
| JS runtime cost saved as SC | Can it be SC? | |
|---|---|---|
MUI <Card> | High (Emotion serialization + theme lookup + DOM injection) | No (Emotion constraint) |
Tailwind <div className> | ≈ 0 (just a function returning JSX) | Yes, but nothing to save |
Meanwhile, the leaf-node costs are real:
- Each leaf needs its own
<Suspense>boundary → 7 loading skeletons, inconsistent UX - Using Query Cache for client state → design intent mismatch, code duplication
- Each leaf repeats 3
useQuery(['selectedXxx'])calls → maintenance burden
5.3 Final Architecture
MUI v7:
─────────────
DashboardPage ("use client") ← Emotion forces full-page client
├── Sidebar ← Pulled into client bundle (~10-15 KB)
└── KPIDashboard
├── FilterBar
│ ├── AccountDropdown
│ └── MonthNavigator (◀ ▶)
└── KPIGrid
└── KPICard × 5
Radix + Tailwind:
─────────────
DashboardPage (Server Component)
├── Sidebar (Server Component) ← Not in client bundle ✅
└── <Suspense>
└── KPIDashboard ("use client") ← Client boundary here
├── FilterBar
│ ├── AccountDropdown
│ └── MonthNavigator (◀ ▶)
└── KPIGrid
└── KPICard × 5The internal logic of KPIDashboard is 100% identical on both sides (same useState, same useQuery, same prop drilling). The only differences:
- Whether Sidebar is in client bundle (MUI: yes / Radix: no)
- Styling engine (Emotion runtime vs Tailwind static class)
- Component JS (MUI Card/Select/Typography vs Radix Select +
<div className>)
5.4 Why Sidebar Should Stay a Server Component
Sidebar is the only component worth making SC, because:
- It doesn't share state with the KPI area → naturally independent
- In MUI: contains ListItem, Icon, etc. → real JS to save
- In Radix: contains nav link + icon → less JS but still non-zero
- Zero cost (it doesn't interact with any CC)
5.5 JSX Comparison: The UI Layer Differences
| Aspect | MUI v7 | Radix + Tailwind |
|---|---|---|
| Card | <Card> + <CardContent> — two wrappers, built-in padding/border/elevation | <div className="rounded-lg border p-4"> — one div + utility classes |
| Text | <Typography variant="h4"> — theme token mapping | <div className="text-3xl font-bold"> — direct utility class |
| Select | <Select> + <MenuItem> — 2 imports, built-in portal/animation/ripple | <Select.Root> + 6 sub-components — more JSX lines, each minimized |
| Button | <IconButton> — built-in ripple + focus ring | <button className="..."> — native button + utility class |
| Layout | <Box sx={{ display: 'grid' }}> — runtime CSS generation | <div className="grid grid-cols-3 gap-4"> — build-time static class |
| Hover | sx={{ '&:hover': { boxShadow: 4 } }} — Emotion runtime | className="hover:shadow-md" — Tailwind static class |
Logic layer (state, hooks, event handlers) = 100% identical. Differences are entirely in the UI rendering layer.
5.6 Shared Internal Logic (Both Sides Identical)
// State management
const [accountId, setAccountId] = useState('all')
const [year, setYear] = useState(2026)
const [month, setMonth] = useState(1)
// Data Fetching
const { data: accounts } = useQuery({
queryKey: ['accounts'],
queryFn: fetchAccounts,
staleTime: Infinity,
})
const { data: kpiData } = useQuery({
queryKey: ['kpi', year, month, accountId],
queryFn: () => fetchKPIs(year, month, accountId),
})
// Month navigation
const goToPrevMonth = () => {
if (month === 1) { setYear(y => y - 1); setMonth(12) }
else { setMonth(m => m - 1) }
}
const goToNextMonth = () => {
if (month === 12) { setYear(y => y + 1); setMonth(1) }
else { setMonth(m => m + 1) }
}5.1 架構演進
設計經歷了多輪討論,從極致 leaf node 架構逐步收斂到務實的單一 client boundary 架構。
最初提出:極致 Leaf Node 架構
DashboardPage (SC)
├── Sidebar (SC)
└── KPISection (SC)
├── <Suspense>
│ └── AccountDropdown (CC)
└── KPICard × 5 (SC)
├── CardTitle (SC)
├── <Suspense>
│ └── KPINumber (CC)
└── CardDescription (SC)問題:多個不相連的 Client Component 之間如何共享篩選狀態(year, month, accountId)?
討論過的共享狀態方案:
| 方案 | 說明 | 問題 |
|---|---|---|
| URL Search Params | router.push('?account=newId') 觸發 server navigation | 帳號切換多了 server round-trip,比 MUI 慢 |
| Shared Client Context | 在上層加 Provider | client boundary 被拉高,退化成非 leaf node |
| nuqs (URL state library) | URL 是共享 state | 仍可能觸發 server re-render |
| Zustand | Module-level store | 可行,但引入額外依賴 |
| TanStack Query Cache | setQueryData + invalidateQueries | 用 server state manager 存 client state,設計意圖不符 |
5.2 關鍵洞察:為什麼 Leaf Node 收益趨近於零
Server Component 省下的是「元件的 JavaScript runtime 邏輯」。但 Tailwind 版的 KPICard 本來就沒有 JS runtime 邏輯可省:
// Tailwind 版 KPICard — 就是一個 div + className
function KPICard({ title, description, children }) {
return (
<div className="rounded-lg border p-4 hover:shadow-md transition-shadow">
<span className="text-sm text-gray-500">{title}</span>
{children}
<span className="text-xs text-gray-400">{description}</span>
</div>
)
}
// 作為 SC:server 執行 → 送 HTML → 省下「在 client 呼叫此 function」
// 作為 CC:client 呼叫此 function → 產出同樣的 HTML
// 差距 ≈ 0.01ms(呼叫一個只 return JSX 的 function)對比 MUI 的 Card,有真正的 runtime 邏輯(讀 theme context → Emotion 序列化 → CSS hash → DOM 注入),但 MUI 的元件因為依賴 Emotion 的 browser Context,做不到 Server Component。
| 作為 SC 能省下的 JS runtime cost | 能否做到 SC | |
|---|---|---|
MUI <Card> | 高(Emotion 序列化 + theme lookup + DOM 注入) | 不行(Emotion 限制) |
Tailwind <div className> | ≈ 0(就是一個 function return JSX) | 可以,但沒什麼好省的 |
Leaf node 的成本卻是實實在在的:
- 每個 leaf 需獨立的
<Suspense>boundary → 7 個 loading skeleton,UX 不一致 - 用 Query Cache 存 client state → 非設計意圖,程式碼重複
- 每個 leaf 都要重複讀 3 個
useQuery(['selectedXxx'])→ 維護困難
5.3 最終架構
MUI v7 方案:
─────────────
DashboardPage ("use client") ← Emotion 強制整頁 client
├── Sidebar ← 被拉進 client bundle(~10-15 KB)
└── KPIDashboard
├── FilterBar
│ ├── AccountDropdown
│ └── MonthNavigator (◀ ▶)
└── KPIGrid
└── KPICard × 5
Radix + Tailwind 方案:
─────────────
DashboardPage (Server Component)
├── Sidebar (Server Component) ← 不在 client bundle ✅
└── <Suspense>
└── KPIDashboard ("use client") ← client boundary 在這
├── FilterBar
│ ├── AccountDropdown
│ └── MonthNavigator (◀ ▶)
└── KPIGrid
└── KPICard × 5兩邊的 KPIDashboard 內部邏輯完全一致(同樣的 useState、同樣的 useQuery、同樣的 prop drilling)。唯一差異:
- Sidebar 是否在 client bundle(MUI: 是 / Radix: 否)
- Styling engine(Emotion runtime vs Tailwind 靜態 class)
- Component JS(MUI Card/Select/Typography vs Radix Select +
<div className>)
5.4 Sidebar 為何應保持 Server Component
Sidebar 是唯一值得做 SC 的元件,因為:
- 不需要跟 KPI 區域共享 state → 天然獨立
- MUI 情況下:含 ListItem、Icon 等 → 有實質 JS 可省
- Radix 情況下:含 nav link + icon → JS 較少但仍非零
- 成本為零(它本來就不跟其他 CC 互動)
5.5 JSX 差異:UI 渲染層的不同
| 面向 | MUI v7 | Radix + Tailwind |
|---|---|---|
| Card | <Card> + <CardContent> — 兩層包裝,自帶 padding/border/elevation | <div className="rounded-lg border p-4"> — 一個 div + utility classes |
| 文字 | <Typography variant="h4"> — theme token 映射 | <div className="text-3xl font-bold"> — 直接 utility class |
| Select | <Select> + <MenuItem> — 兩個 import,內建 portal/animation/ripple | <Select.Root> + 6 個子元件 — 更多 JSX 行數,各自最小化 |
| 按鈕 | <IconButton> — 自帶 ripple + focus ring | <button className="..."> — 原生 button + utility class |
| Layout | <Box sx={{ display: 'grid' }}> — runtime 生成 CSS | <div className="grid grid-cols-3 gap-4"> — build-time 靜態 class |
| Hover | sx={{ '&:hover': { boxShadow: 4 } }} — Emotion runtime 處理 | className="hover:shadow-md" — Tailwind 靜態 class |
邏輯層(state、hooks、event handlers)= 100% 一致。差異完全在 UI 渲染層。
5.6 共用內部邏輯(兩邊完全一致)
// State 管理
const [accountId, setAccountId] = useState('all')
const [year, setYear] = useState(2026)
const [month, setMonth] = useState(1)
// Data Fetching
const { data: accounts } = useQuery({
queryKey: ['accounts'],
queryFn: fetchAccounts,
staleTime: Infinity,
})
const { data: kpiData } = useQuery({
queryKey: ['kpi', year, month, accountId],
queryFn: () => fetchKPIs(year, month, accountId),
})
// 月份切換
const goToPrevMonth = () => {
if (month === 1) { setYear(y => y - 1); setMonth(12) }
else { setMonth(m => m - 1) }
}
const goToNextMonth = () => {
if (month === 12) { setYear(y => y + 1); setMonth(1) }
else { setMonth(m => m + 1) }
}6. Emotion Cache Behavior Analysis (Under This Spec)
6. Emotion 快取行為分析(本 Spec 下)
6.1 First Render (S1)
Serialize all sx props → generate CSS → hash → inject <style> tag
Highest cost (serialization + DOM operations + Style Recalculation)6.2 Account/Month Switch Re-render (S2)
Serialize sx prop (even if values are identical, due to referential inequality)
→ hash → cache lookup → HIT ✅
→ No new <style> tag injected
→ Cost: only serialization computation (~0.2ms per styled component)
→ 5 Cards × 4-5 styled elements ≈ ~4-5ms total6.3 Comparison: Tailwind on Re-render
MUI re-render flow:
Each <Box sx={...}> → Emotion serialize → hash → cache lookup → ~0.2ms
~20 styled components × ~0.2ms = ~4ms
Tailwind re-render flow:
Each <div className="..."> → static string, direct assignment → ~0ms extra costEmotion's re-render cost is O(n) where n = number of styled components. Tailwind's is O(0). Under this spec the gap is small (~4ms), but it scales linearly as component count grows.
6.1 首次 Render(S1)
序列化所有 sx prop → 產生 CSS → hash → 注入 <style> tag
成本最高(序列化 + DOM 操作 + Style Recalculation)6.2 帳號切換 / 月份切換 Re-render(S2)
序列化 sx prop(即使值相同,因 referential inequality)
→ hash → 查 cache → 命中 ✅
→ 不注入新 <style> tag
→ 成本:只有序列化計算(~0.2ms per styled component)
→ 5 張 Card × 4-5 個 styled elements ≈ ~4-5ms total6.3 對比 Tailwind 在 Re-render 時
MUI re-render 流程:
每個 <Box sx={...}> → Emotion 序列化 → hash → cache lookup → ~0.2ms
~20 個 styled component × ~0.2ms = ~4ms
Tailwind re-render 流程:
每個 <div className="..."> → 靜態字串,直接賦值 → ~0ms 額外成本Emotion 的 re-render 成本是 O(n),n 是 styled component 的數量。Tailwind 的是 O(0)。 目前 spec 下差異小(~4ms),但元件數量增加時差異線性增長。
7. Performance Experiment Design
7. 效能實驗設計
7.1 Test Scenarios
| Scenario | Operation | Measurement Focus |
|---|---|---|
| S1: Cold Start | Clear browser profile → first load | JS bundle size, TBT, LCP, hydration scope |
| S2: Filter Switch | Switch account / click ◀▶ to change month → refetch | React Profiler render duration, INP |
Removed scenario: S3: Stress Re-render (update KPI every 500ms) — no continuous dynamic styles in this spec, unrealistic.
7.2 Measurement Metrics
| Priority | Metric | Tool | Scenario |
|---|---|---|---|
| P0 | TBT (Total Blocking Time) | Lighthouse CLI | S1 |
| P0 | Time to KPI (custom mark) | performance.mark() | S1, S2 |
| P0 | JS bundle size (gzipped) | @next/bundle-analyzer | Build |
| P0 | React Profiler render duration | <Profiler onRender> | S2 |
| P1 | LCP | Lighthouse CLI | S1 |
| P1 | INP | web-vitals library | S2 |
| P1 | Style Recalculation count/time | Chrome Performance tab | S1 |
| P1 | Memory usage (JS heap) | performance.measureUserAgentSpecificMemory() | Long-running |
7.3 The sx Prop Decision
| Approach | Vote | Rationale |
|---|---|---|
| Naive (no useMemo) | 3/5 — Adopted | Reflects real-world typical usage |
| Optimized (useMemo every sx) | 1/5 | Library comparison should be at best state |
| Both | 1/5 | Run if time permits |
In naive mode, Emotion re-serializes every sx prop on each render (even if values haven't changed), but doesn't inject new CSS when the cache hits.
7.4 Statistical Methodology
- Minimum 25 runs per scenario
- Report median + P95
- Wilcoxon signed-rank test (non-parametric, suitable for right-skewed distributions)
- Cliff's Delta for effect size
- CV < 10% threshold to accept results
- Browser profile cleared between every run
7.5 Environment Controls
- MSW fixed 50ms delay (warm-up request to avoid Service Worker startup latency)
- Production build (
next build→next start) - Deep imports (
@mui/material/Card, not barrel imports) - Fixed viewport (1920×1080)
- Two CPU throttling levels: no throttle + 4x CPU slowdown
7.6 Two-Round Experiment Design
| Round 1 (Control) | Round 2 (Real-World) | |
|---|---|---|
| MUI v7 + Emotion | "use client" at page level | "use client" at page level (same as Round 1) |
| Radix + Tailwind | "use client" at page level (intentionally downgraded) | SC page + CC KPIDashboard (best practice) |
| What it measures | Pure UI Library difference (styling engine + component JS) | Full impact of library choice (including architecture differences) |
Round 1 difference = Styling Engine + Component JS
Round 2 difference = Styling Engine + Component JS + Architecture (Sidebar SC vs CC)
Round 2 - Round 1 = Architecture contributionKey insight: MUI's architecture is identical in Round 1 and Round 2 (locked into "use client" at page level by Emotion — no choice). Only one test run needed. The only additional work is Radix's Round 1 version (intentionally downgraded).
7.1 測試場景
| 場景 | 操作 | 測量重點 |
|---|---|---|
| S1: Cold Start | 清空 browser profile → 首次載入 | JS bundle size、TBT、LCP、hydration 範圍 |
| S2: 篩選切換 | 切換帳號 / 點擊 ◀▶ 切換月份 → refetch | React Profiler render duration、INP |
已移除的場景: S3: Stress Re-render(每 500ms 更新 KPI)——spec 無連續動態樣式,不切實際。
7.2 測量指標
| 優先級 | 指標 | 工具 | 場景 |
|---|---|---|---|
| P0 | TBT (Total Blocking Time) | Lighthouse CLI | S1 |
| P0 | Time to KPI(custom mark) | performance.mark() | S1, S2 |
| P0 | JS bundle size (gzipped) | @next/bundle-analyzer | Build |
| P0 | React Profiler render duration | <Profiler onRender> | S2 |
| P1 | LCP | Lighthouse CLI | S1 |
| P1 | INP | web-vitals library | S2 |
| P1 | Style Recalculation count/time | Chrome Performance tab | S1 |
| P1 | Memory usage (JS heap) | performance.measureUserAgentSpecificMemory() | 長時間 |
7.3 sx prop 寫法決策
| 方案 | 投票 | 理由 |
|---|---|---|
| Naive(不加 useMemo) | 3/5 採用 | 反映真實世界典型用法 |
| Optimized(useMemo 每個 sx) | 1/5 | library 比較應在最佳狀態 |
| 兩組都跑 | 1/5 | 時間允許可做 |
Emotion 在 naive 寫法下,每次 render 都重新序列化 sx prop(即使值相同),但命中 cache 時不注入新 CSS。
7.4 統計方法
- 每場景至少 25 次 run
- 取 median + P95
- Wilcoxon signed-rank test(非參數檢驗,適合右偏分佈)
- Cliff's Delta for effect size
- CV < 10% 才接受結果
- 每次 run 清除 browser profile
7.5 環境控制
- MSW 固定 50ms delay(warm-up request 避免 Service Worker 啟動延遲)
- Production build(
next build→next start) - Deep import(
@mui/material/Card,非 barrel import) - 固定 viewport(1920×1080)
- 兩組 CPU throttling:no throttle + 4x CPU slowdown
7.6 兩輪實驗設計
| Round 1(控制組) | Round 2(實戰組) | |
|---|---|---|
| MUI v7 + Emotion | "use client" page 層級 | "use client" page 層級(同 Round 1) |
| Radix + Tailwind | "use client" page 層級(刻意降級配合) | SC page + CC KPIDashboard(最佳實踐) |
| 測的是什麼 | 純 UI Library 差異(styling engine + component JS) | Library 選擇的全面影響(含架構差異) |
Round 1 差異 = Styling Engine + Component JS
Round 2 差異 = Styling Engine + Component JS + 架構差異(Sidebar SC vs CC)
Round 2 - Round 1 = 架構差異的貢獻量關鍵洞察:MUI 在 Round 1 和 Round 2 架構相同(被 Emotion 鎖在 "use client" page 層級,無選擇權),只需跑一次測試。真正多出的工作量只有 Radix 的 Round 1 版本(刻意降級版)。
8. DX Experiment Design
8. DX 實驗設計
8.1 Experiment Structure
Shared Preparation (not counted):
├── Test framework (Vitest + RTL)
├── MSW handlers
├── useQuery hooks + utils
└── Shared type definitions
Phase 1: UI Design (Human)
├── MUI version: design 5 KPI Card layout, colors, spacing
└── Radix version: same
Phase 2: Implementation (Cursor AI Agent)
├── MUI version: Cursor generates code from Phase 1 design
└── Radix version: same8.2 DX Metrics
| Metric | Priority | Definition |
|---|---|---|
| Iteration Count | P0 | Rounds of conversation from first prompt to passing tests |
| Human Intervention Count | P0 | Manual fixes to AI output |
| Test Pass Rate | P0 | AI's first-attempt test pass rate |
| Final Lines of Code | P1 | Final usable code line count |
| Total Token | P1 | Total tokens consumed |
| Wall Clock Time | P1 | Total elapsed time (excluding API wait) |
If you could pick only one metric: Iteration Count. It's the most intuitive, hardest to game, and best reflects real AI-assisted development DX.
8.3 Prompt Fairness
Using fixed structure + library-specific details:
Role: Senior React developer
Stack: Next.js 15 + [MUI v7 / Radix + Tailwind] + TanStack Query v5
Task: Implement Dashboard per specification
Design spec:
- 5 KPI Cards (title, value, trend arrow)
- Hover shadow elevation
- Account dropdown + month nav buttons → refetch
- MSW mock API (configured)
- useQuery hook (wrapped)
Requirements:
- Use [library] best practices
- Only implement UI components (API/data layer done)
- Must pass pre-written tests8.4 Human Intervention Counting Rules
| Intervention Type | Counted? | Rationale |
|---|---|---|
| Fix import path | Yes | AI not knowing optimal import = part of library DX |
| Fix TypeScript type error | Yes | Library type definition usability |
| Fix logic error (useQuery usage) | No | Unrelated to UI library (data layer is extracted) |
| Adjust styling (color, spacing) | Depends | AI not knowing spacing scale → count; design preference → don't count |
| Additional prompt requesting changes | Yes | This IS a new iteration |
8.1 實驗架構
前置準備(兩邊共用,不計入):
├── 測試框架(Vitest + RTL)
├── MSW handlers
├── useQuery hooks + utils
└── 共用型別定義
Phase 1: UI 程式設計(人工)
├── MUI 版:設計 5 張 KPI Card 佈局、配色、間距
└── Radix 版:同上
Phase 2: 程式實作(Cursor AI Agent)
├── MUI 版:用 Cursor 根據 Phase 1 設計產出程式碼
└── Radix 版:同上8.2 DX 量測指標
| 指標 | 優先級 | 定義 |
|---|---|---|
| Iteration Count | P0 | 從第一個 prompt 到通過測試需幾輪對話 |
| Human Intervention Count | P0 | 手動修改 AI 輸出多少次 |
| Test Pass Rate | P0 | AI 首次產出跑測試的通過率 |
| Final Lines of Code | P1 | 最終可用程式碼行數 |
| Total Token | P1 | 整個流程消耗的總 token |
| Wall Clock Time | P1 | 全程耗時(排除 API 等待) |
如果只選一個指標,選 Iteration Count。它最直觀、最難作弊、最能反映 AI-assisted 開發的真實 DX。
8.3 Prompt 公平性
採用固定結構 + library-specific 細節:
角色:資深 React 開發者
技術棧:Next.js 15 + [MUI v7 / Radix + Tailwind] + TanStack Query v5
任務:根據規格實作 Dashboard
設計規格:
- 5 張 KPI Card(標題、數值、趨勢箭頭)
- Hover shadow elevation
- 帳號下拉選單 + 月份切換按鈕 → refetch
- MSW mock API(已設定)
- useQuery hook(已封裝)
要求:
- 使用 [library] 的最佳實踐
- 只實作 UI 元件(API/data layer 已完成)
- 需通過已寫好的測試8.4 Human Intervention 計算標準
| 介入類型 | 是否計入 | 理由 |
|---|---|---|
| 修正 import path | 是 | AI 不知道最佳 import = library DX 的一部分 |
| 修正 TypeScript 型別錯誤 | 是 | library 型別定義的易用性 |
| 修正邏輯錯誤(useQuery 用法) | 否 | 跟 UI library 無關(data layer 已抽出) |
| 調整樣式(顏色、間距) | 視情況 | AI 不知道 spacing scale → 計入;設計偏好 → 不計入 |
| 追加 prompt 要求修改 | 是 | 這就是新的 iteration |
9. Performance Predictions
9. 效能預測
9.1 Client Bundle Size Difference
MUI — client bundle contains:
├── React runtime ~40 KB
├── Emotion runtime ~10-15 KB
├── MUI ThemeProvider + Context ~5 KB
├── MUI Card × 5 (with sub-component logic) ~20-30 KB
├── MUI Sidebar components (ListItem etc.) ~10-15 KB
├── AccountDropdown (MUI Select) ~15-20 KB
├── TanStack Query ~12 KB
└── Business logic ~5 KB
Total: ~120-145 KB gzipped
Radix — client bundle contains:
├── React runtime ~40 KB
├── Radix Select (for AccountDropdown) ~5-8 KB
├── TanStack Query ~12 KB
├── KPIDashboard business logic ~3-5 KB
└── lucide-react icons ~2-3 KB
Total: ~62-68 KB gzipped
Gap: ~55-80 KB gzipped9.2 Per-Scenario Predicted Differences
| Scenario | Predicted Gap (no throttle) | Predicted Gap (4x CPU) | User Perception |
|---|---|---|---|
| S1: First Load | 100-200ms | 400-800ms | Perceivable (especially for infrequent users) |
| S2: Account/Month Switch | ~4ms | ~16ms | Nearly imperceptible |
9.3 Impact of Usage Frequency
This Dashboard is used fewer than 10 times per month. Users almost always arrive with a cold start (browser cache may be cleared).
| Usage Pattern | S1 Gap Perception | S2 Gap Accumulation |
|---|---|---|
| Low frequency (< 10/month) | Felt every time | Low (few S2 interactions) |
| High frequency (daily) | Only felt on first visit | High (many S2 interactions) |
Note: The 55-80 KB bundle difference is a fixed cost. Regardless of usage frequency, the absolute difference on each cold start is identical. Different frequencies change which scenario's gap is felt, not the gap itself.
9.1 Client Bundle Size 差異
MUI 方案 — client bundle 包含:
├── React runtime ~40 KB
├── Emotion runtime ~10-15 KB
├── MUI ThemeProvider + Context ~5 KB
├── MUI Card × 5(含所有子元件邏輯) ~20-30 KB
├── MUI Sidebar 元件(ListItem 等) ~10-15 KB
├── AccountDropdown(MUI Select) ~15-20 KB
├── TanStack Query ~12 KB
└── 業務邏輯 ~5 KB
總計約 120-145 KB gzipped
Radix 方案 — client bundle 包含:
├── React runtime ~40 KB
├── Radix Select(AccountDropdown 用) ~5-8 KB
├── TanStack Query ~12 KB
├── KPIDashboard 業務邏輯 ~3-5 KB
└── lucide-react icons ~2-3 KB
總計約 62-68 KB gzipped
差距:~55-80 KB gzipped9.2 各場景預估差異
| 場景 | 預估差異(no throttle) | 預估差異(4x CPU) | 使用者感知 |
|---|---|---|---|
| S1 首次載入 | 100-200ms | 400-800ms | 可感知(尤其低頻使用者) |
| S2 帳號/月份切換 | ~4ms | ~16ms | 幾乎無感 |
9.3 使用頻率的影響
此 Dashboard 每月使用不到 10 次,使用者每次來幾乎都是 cold start(browser cache 可能已清除)。
| 使用頻率 | S1 差異的感受 | S2 差異的累積 |
|---|---|---|
| 低頻(每月 <10 次) | 每次都感受到 | 累積少(S2 次數少) |
| 高頻(每天用) | 只有第一次感受到 | 累積多(S2 次數多) |
注意:55-80 KB 的 bundle 差異 = 固定成本。不管使用頻率高低,每次 cold start 的絕對差異相同。不同頻率下感受到差異的場景不同,而非差異本身被放大或縮小。
10. The Full Decision Framework: Beyond Performance
10. 完整決策框架:超越效能
Performance experiment results should not be the sole selection criteria. The complete decision framework:
Design Principle (Highest Priority)
- MUI: Material Design as established language. The closer your product is to Material Design, the less you override
- Radix: Headless, no default appearance. Full customization without "grow Material Design first, then override"
DX (Developer Experience)
- MUI: Complete component set, thorough documentation, one import and it works
- Radix: Requires assembling your own styling, building design tokens, higher initial investment
Maintainability
- MUI: Upgrades may introduce breaking changes (v5→v6 Emotion→Pigment), but large community
- Radix: Independent component versioning, lower upgrade risk, but Tailwind classes in complex components are hard to maintain
Performance (What This Experiment Measures)
Under this spec, the difference exists but is not large. Performance should not be the primary decision factor.
效能實驗的結論不應是唯一的選擇依據。完整的決策框架:
Design Principle(最優先)
- MUI:Material Design 既定語言。產品越接近 Material Design,改越少
- Radix:Headless,無預設外觀。完全客製化時不用「先長 MD 再覆蓋」
DX(開發者體驗)
- MUI:元件齊全、文件完整、一個 import 就能用
- Radix:需自己組裝 styling、建 design token、初始投入高
維護性
- MUI:升級可能 breaking change(v5→v6 Emotion→Pigment),但社群大
- Radix:元件獨立版本、升級風險低,但 Tailwind class 複雜元件難維護
效能(本實驗量測)
在此 spec 下差異存在但不大。效能不應是主要決策因素。
11. Conclusion and Validity Period
11. 結論與有效期限
Expected Conclusion
MUI v7 + Emotion carries a measurable extra cost on first load (S1) — estimated 100-200ms without throttling, 400-800ms with 4x CPU — primarily from Emotion runtime + MUI component JS bundle size. In daily interactions (S2 account/month switching), the difference is negligible (~4ms without throttling).
Radix + Tailwind has zero styling engine runtime cost on every re-render (Tailwind's className is a static string), while MUI's Emotion serialization cost is O(n), scaling linearly with styled component count.
Performance should not be the primary decision factor. Selection should prioritize the product's Design Principle (degree of customization needed), followed by team DX preference and long-term maintainability.
Validity Period
This conclusion is based on MUI v7 + Emotion. When MUI migrates to Pigment CSS (compile-time CSS-in-JS) as the default, the SSR architecture constraint disappears (components can become Server Components), runtime CSS cost is eliminated, and this conclusion may fundamentally change.
預期結論
MUI v7 + Emotion 在首次載入(S1)有可量測的額外成本(預估 100-200ms no throttle,400-800ms 4x CPU),主要來自 Emotion runtime + MUI 元件的 JS bundle 大小。在日常互動(S2 帳號/月份切換)中差異 negligible(預估 ~4ms no throttle)。
Radix + Tailwind 在每次 re-render 時沒有 styling engine 的 runtime 成本(Tailwind 的 className 是靜態字串),而 MUI 的 Emotion 序列化成本是 O(n),隨 styled component 數量線性增長。
效能不應是主要決策因素。選擇應優先根據產品的 Design Principle(客製化需求程度),其次是團隊 DX 偏好與長期維護性。
有效期限
此結論基於 MUI v7 + Emotion。未來 MUI 遷移至 Pigment CSS(compile-time CSS-in-JS)成為預設後,SSR 架構約束消失(元件可為 Server Component),runtime CSS 成本消除,結論可能根本性改變。
Appendix A: Dynamic Rendering Scenarios Reference
附錄 A:Dynamic 渲染情境參考
cookies() / headers()
Scenarios requiring per-user, per-request information to render:
| Scenario | Why Needed | Example |
|---|---|---|
| Login state | Determine display based on session cookie | "Welcome back, Crystal" vs "Please log in" |
| Locale preference | Read Accept-Language header or locale cookie | Same /products page, TW users see Chinese, JP users see Japanese |
| A/B Testing | Experiment group stored in cookie | Same page shows new UI for group A, old UI for group B |
| Permission control | Determine visible content based on auth token | Admin sees "Delete" button, regular users don't |
| Dark/light mode (server-side) | Read theme preference cookie to avoid flicker | Cookie instead of localStorage to prevent FOUC |
searchParams
URL query strings are potentially different on every request, impossible to pre-render at build time:
| Scenario | URL Example | Why Needed |
|---|---|---|
| Search results | /search?q=iphone&sort=price | Infinite combinations, can't pre-render all |
| Filter/facets | /products?category=shoes&color=red | Same |
| Pagination | /blog?page=3 | Dynamic page numbers |
| UTM-driven content | /landing?utm_source=google | Show different CTA based on source |
| URL-driven tabs | /dashboard?tab=analytics | URL manages UI state for shareability |
export const dynamic = 'force-dynamic'
Developer explicitly knows this route always needs request-time rendering:
| Scenario | Why Force Dynamic |
|---|---|
| Real-time dashboards | Stock prices, server monitoring, data changes every second |
| Live inventory/seats | Ticketing systems, can't show stale inventory |
| Fully personalized home | 100% based on user history, nothing can be static |
| Development/debugging | Ensure latest results, avoid cache interference |
| Runtime env variables | Config that can only be determined at request time |
cookies() / headers()
需要讀取每個使用者當下 request 的資訊才能渲染的場景:
| 場景 | 為什麼需要 | 範例 |
|---|---|---|
| 登入狀態判斷 | 依據 session cookie 決定顯示什麼 | 「歡迎回來,Crystal」vs「請登入」 |
| 語系/地區偏好 | 讀 Accept-Language header 或 locale cookie | 同一個 /products 頁面,台灣用戶看繁中、日本用戶看日文 |
| A/B Testing | cookie 裡存了實驗分組 | 同一頁面對 A 組顯示新版 UI、B 組顯示舊版 |
| 權限控制 | 依據 auth token 決定能看到什麼內容 | 管理員看到「刪除」按鈕,一般用戶看不到 |
| 深色/淺色模式(server-side) | 讀 cookie 裡的 theme 偏好避免閃爍 | 用 cookie 而非 localStorage 來避免 FOUC |
searchParams
URL query string 本質上是每次 request 都可能不同的值,無法 build-time 預渲染:
| 場景 | URL 範例 | 為什麼需要 |
|---|---|---|
| 搜尋結果頁 | /search?q=iphone&sort=price | 無限種組合,不可能全部預渲染 |
| 篩選/過濾 | /products?category=shoes&color=red | 同上 |
| 分頁 | /blog?page=3 | 頁碼動態 |
| UTM 追蹤影響內容 | /landing?utm_source=google | 根據來源顯示不同 CTA |
| Tab 切換(URL-driven) | /dashboard?tab=analytics | 用 URL 管理 UI state,方便分享連結 |
export const dynamic = 'force-dynamic'
開發者明確知道這個 route 永遠需要 request-time 渲染:
| 場景 | 為什麼要強制 dynamic |
|---|---|
| 即時資料儀表板 | 股價、伺服器監控面板,資料每秒都在變 |
| 即時庫存/座位 | 訂票系統,不能讓用戶看到過時的庫存數量 |
| 個人化首頁 | 100% 根據用戶歷史行為推薦,沒有任何可以 static 的部分 |
| 開發/除錯用途 | 開發階段想確保每次都拿到最新結果,避免快取干擾 |
| 依賴 runtime 環境變數 | 需要在 request time 才能確定的環境設定 |
Appendix B: ASG Multi-Instance Remote Cache
附錄 B:ASG 多 Instance 下的 Remote Cache
The default use cache is in-memory LRU cache, independent per Node.js process. In an ASG multi-instance setup:
Load Balancer
┌────┼────┐
▼ ▼ ▼
┌─────┐ ┌─────┐ ┌─────┐
│ EC2 │ │ EC2 │ │ EC2 │
│ ① │ │ ② │ │ ③ │
│cache│ │cache│ │cache│ ← Three independent in-memory caches
└─────┘ └─────┘ └─────┘This causes three problems:
| Problem | Description |
|---|---|
| Cache inconsistency | User A hits EC2① sees old data, User B hits EC2② sees new data |
| Resource waste | Three instances each fetch the same data, backend API load ×3 |
| Revalidation can't sync | revalidateTag('posts') on EC2① only clears ①'s cache, ②③ still stale |
Solution: cacheHandlers + external shared cache
Next.js 16 provides cacheHandlers config, combined with use cache: remote:
Load Balancer
┌────┼────┐
▼ ▼ ▼
┌─────┐ ┌─────┐ ┌─────┐
│ EC2 │ │ EC2 │ │ EC2 │
└──┬──┘ └──┬──┘ └──┬──┘
│ │ │
└───────┼───────┘
▼
┌──────────────┐
│ Redis/Valkey │ ← Shared cache
│ or DynamoDB │
└──────────────┘// next.config.ts
const nextConfig: NextConfig = {
cacheComponents: true,
cacheHandlers: {
default: require.resolve('./cache-handler.js'),
remote: require.resolve('./remote-cache-handler.js'),
},
}// Local in-memory (suitable for high-frequency access, acceptable inconsistency)
async function getNavItems() {
'use cache'
// ...
}
// Remote shared cache (needed for ASG scenarios)
async function getKPIData(accountId: string) {
'use cache: remote'
cacheLife('minutes')
cacheTag(`kpi-${accountId}`)
// ...
}This is essentially what Vercel does on their platform — their Data Cache is a managed remote cache. Self-hosting on ASG means building this layer yourself.
預設的 use cache 是 in-memory LRU cache,每個 Node.js process 各自獨立。在 ASG 多台 instance 的架構下:
Load Balancer
┌────┼────┐
▼ ▼ ▼
┌─────┐ ┌─────┐ ┌─────┐
│ EC2 │ │ EC2 │ │ EC2 │
│ ① │ │ ② │ │ ③ │
│cache│ │cache│ │cache│ ← 三份各自獨立的 in-memory cache
└─────┘ └─────┘ └─────┘這會造成三個問題:
| 問題 | 說明 |
|---|---|
| 快取不一致 | 用戶 A 打到 EC2① 看舊資料,用戶 B 打到 EC2② 看新資料 |
| 資源浪費 | 三台各自 fetch 同一筆資料、各自快取,後端 API 負載 ×3 |
| Revalidation 無法同步 | 在 EC2① 上 revalidateTag('posts') 只清了 ① 的快取,②③ 還是舊的 |
解法:cacheHandlers + 外部共享快取
Next.js 16 提供了 cacheHandlers 設定,搭配 use cache: remote:
Load Balancer
┌────┼────┐
▼ ▼ ▼
┌─────┐ ┌─────┐ ┌─────┐
│ EC2 │ │ EC2 │ │ EC2 │
└──┬──┘ └──┬──┘ └──┬──┘
│ │ │
└───────┼───────┘
▼
┌──────────────┐
│ Redis/Valkey │ ← 共享快取
│ or DynamoDB │
└──────────────┘// next.config.ts
const nextConfig: NextConfig = {
cacheComponents: true,
cacheHandlers: {
default: require.resolve('./cache-handler.js'),
remote: require.resolve('./remote-cache-handler.js'),
},
}// 本地 in-memory(適合極高頻存取、可接受不一致)
async function getNavItems() {
'use cache'
// ...
}
// 遠端共享快取(ASG 場景需要這個)
async function getKPIData(accountId: string) {
'use cache: remote'
cacheLife('minutes')
cacheTag(`kpi-${accountId}`)
// ...
}這本質上就是 Vercel 在平台上幫你做的事——他們的 Data Cache 就是一個 managed 的 remote cache。Self-host 在 ASG 上,得自己搭這一層。