React Server Components 實戰:下一代React架構

前端工程

什麼是 React Server Components?為什麼它很重要?

React Server Components(RSC)是 React 團隊提出的全新元件模型,允許元件在伺服器端渲染並直接將結果串流傳輸到客戶端,從根本上改變了 React 應用的架構方式。

RSC 解決的核心問題

問題 傳統 SPA SSR RSC
首屏 JS 體積 大(全量打包) 中(需 hydration) 小(僅客戶端元件)
資料取得 useEffect + API getServerSideProps 元件內直接存取資料來源
打包體積 所有依賴進 bundle 同 SPA 伺服器端依賴零打包
請求瀑布流 嚴重(客戶端串行) 中(伺服器端並行) 伺服器端並行 + 串流

RSC 的三大核心優勢

  1. 零打包體積:伺服器端元件的依賴不會進入客戶端 bundle
  2. 直接資料存取:元件內直接讀寫資料庫、檔案系統,無需 API 層
  3. 自動程式碼分割:客戶端元件天然成為程式碼分割邊界

💡 使用 JSON 格式化 工具檢查 Next.js 設定檔,確保 App Router 設定正確。


Server 元件 vs Client 元件:完整對比

理解 Server 和 Client 元件的差異是掌握 RSC 的第一步

核心差異

特性 Server Component Client Component
渲染環境 伺服器端 客戶端(瀏覽器)
檔案後綴 .tsx(預設) .tsx + "use client"
資料取得 直接存取 DB/API/檔案 useEffect / SWR / React Query
狀態管理 無 useState/useReducer useState / useReducer / Context
副作用 無 useEffect / 生命週期 useEffect / useLayoutEffect
依賴打包 不進入客戶端 bundle 進入客戶端 bundle
DOM 存取
事件處理 無 onClick/onChange
非同步支援 async/await 需要第三方函式庫

元件選擇決策樹

需要互動(onClick / useState / useEffect)?
├── 是 → Client Component
└── 否 → 需要存取伺服器端資源(DB / 檔案系統 / 私有 API)?
    ├── 是 → Server Component
    └── 否 → 需要減少客戶端 bundle 體積?
        ├── 是 → Server Component
        └── 否 → Server Component(預設選擇)

實際範例:Server 元件

// app/articles/page.tsx(Server Component,預設)
import { db } from '@/lib/db';

interface Article {
  id: number;
  title: string;
  content: string;
  createdAt: Date;
}

export default async function ArticlesPage() {
  const articles: Article[] = await db.query(
    'SELECT id, title, content, created_at FROM articles ORDER BY created_at DESC LIMIT 20'
  );

  return (
    <main className="max-w-4xl mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-6">最新文章</h1>
      <div className="space-y-4">
        {articles.map((article) => (
          <article key={article.id} className="border rounded-lg p-4">
            <h2 className="text-xl font-semibold">{article.title}</h2>
            <p className="text-gray-600 mt-2">{article.content}</p>
            <time className="text-sm text-gray-400">
              {article.createdAt.toLocaleDateString('zh-TW')}
            </time>
          </article>
        ))}
      </div>
    </main>
  );
}

實際範例:Client 元件

// app/components/LikeButton.tsx
'use client';

import { useState, useTransition } from 'react';

interface LikeButtonProps {
  articleId: number;
  initialLikes: number;
}

export default function LikeButton({ articleId, initialLikes }: LikeButtonProps) {
  const [likes, setLikes] = useState(initialLikes);
  const [isPending, startTransition] = useTransition();

  const handleLike = async () => {
    startTransition(async () => {
      const res = await fetch(`/api/articles/${articleId}/like`, {
        method: 'POST',
      });
      const data = await res.json();
      setLikes(data.likes);
    });
  };

  return (
    <button
      onClick={handleLike}
      disabled={isPending}
      className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
    >
      {isPending ? '處理中...' : `按讚 (${likes})`}
    </button>
  );
}

"use client" 和 "use server" 指令詳解

這兩個指令是 RSC 架構的邊界標記,決定了程式碼的執行環境。

"use client" 指令

// app/components/SearchBar.tsx
'use client';

import { useState } from 'react';

export default function SearchBar() {
  const [query, setQuery] = useState('');

  return (
    <div className="flex gap-2">
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="搜尋文章..."
        className="border rounded px-3 py-2 flex-1"
      />
      <button className="bg-blue-500 text-white px-4 py-2 rounded">
        搜尋
      </button>
    </div>
  );
}

"use server" 指令

// app/actions/article.ts
'use server';

import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';

export async function createArticle(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  if (!title || !content) {
    throw new Error('標題和內容不能為空');
  }

  await db.query(
    'INSERT INTO articles (title, content, created_at) VALUES ($1, $2, NOW())',
    [title, content]
  );

  revalidatePath('/articles');
}

export async function deleteArticle(id: number) {
  await db.query('DELETE FROM articles WHERE id = $1', [id]);
  revalidatePath('/articles');
}

指令使用規則

規則 說明
"use client" 必須在檔案頂部 在所有 import 之前宣告
"use server" 必須在檔案頂部 在所有 import 之前宣告,匯出的函式必須是 async
Server 元件可匯入 Client 元件 但 Client 元件不能匯入 Server 元件
Client 元件可透過 props 接收 Server 元件 使用 children prop 傳遞
"use client" 是邊界標記 標記該檔案及其所有依賴為客戶端程式碼

元件組合模式:Server 包裹 Client

// app/articles/page.tsx(Server Component)
import { db } from '@/lib/db';
import LikeButton from '@/components/LikeButton';
import SearchBar from '@/components/SearchBar';

export default async function ArticlesPage() {
  const articles = await db.query('SELECT * FROM articles');

  return (
    <div>
      <SearchBar />
      {articles.map((article) => (
        <article key={article.id}>
          <h2>{article.title}</h2>
          <p>{article.content}</p>
          <LikeButton articleId={article.id} initialLikes={article.likes} />
        </article>
      ))}
    </div>
  );
}

資料取得模式:告別 useEffect

RSC 最大的範式轉變是資料取得從客戶端移到伺服器端,徹底告別 useEffect 請求瀑布流。

傳統模式 vs RSC 模式

// 傳統模式:useEffect 瀑布流
'use client';
import { useState, useEffect } from 'react';

export default function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data));
  }, [userId]);

  useEffect(() => {
    if (!user) return;
    fetch(`/api/users/${userId}/posts`)
      .then(res => res.json())
      .then(data => setPosts(data));
  }, [userId, user]);

  if (!user) return <div>載入中...</div>;

  return (
    <div>
      <h1>{user.name}</h1>
      {posts.map(post => <p key={post.id}>{post.title}</p>)}
    </div>
  );
}
// RSC 模式:伺服器端並行取得
import { db } from '@/lib/db';

export default async function UserProfile({ userId }: { userId: string }) {
  const [user, posts] = await Promise.all([
    db.query('SELECT * FROM users WHERE id = $1', [userId]),
    db.query('SELECT * FROM posts WHERE user_id = $1 ORDER BY created_at DESC', [userId]),
  ]);

  return (
    <div>
      <h1>{user.name}</h1>
      {posts.map(post => <p key={post.id}>{post.title}</p>)}
    </div>
  );
}

直接資料庫存取

// app/dashboard/page.tsx
import { db } from '@/lib/db';

export default async function DashboardPage() {
  const [totalUsers, totalOrders, recentOrders] = await Promise.all([
    db.query('SELECT COUNT(*) as count FROM users'),
    db.query('SELECT COUNT(*) as count FROM orders WHERE created_at > NOW() - INTERVAL 30 DAY'),
    db.query('SELECT * FROM orders ORDER BY created_at DESC LIMIT 10'),
  ]);

  return (
    <div className="grid grid-cols-3 gap-4">
      <div className="bg-white rounded-lg shadow p-6">
        <h3 className="text-gray-500">總使用者數</h3>
        <p className="text-3xl font-bold">{totalUsers.count}</p>
      </div>
      <div className="bg-white rounded-lg shadow p-6">
        <h3 className="text-gray-500">近30天訂單</h3>
        <p className="text-3xl font-bold">{totalOrders.count}</p>
      </div>
      <div className="bg-white rounded-lg shadow p-6 col-span-3">
        <h3 className="text-gray-500 mb-4">最近訂單</h3>
        <table className="w-full">
          <thead>
            <tr>
              <th>訂單號</th>
              <th>金額</th>
              <th>狀態</th>
            </tr>
          </thead>
          <tbody>
            {recentOrders.map(order => (
              <tr key={order.id}>
                <td>{order.orderNo}</td>
                <td>NT${order.amount}</td>
                <td>{order.status}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

檔案系統存取

// app/docs/page.tsx
import { readFile, readdir } from 'fs/promises';
import { join } from 'path';

export default async function DocsPage() {
  const docsDir = join(process.cwd(), 'content/docs');
  const files = await readdir(docsDir);
  const docs = await Promise.all(
    files
      .filter(f => f.endsWith('.md'))
      .map(async (f) => {
        const content = await readFile(join(docsDir, f), 'utf-8');
        return { filename: f, content };
      })
  );

  return (
    <div>
      {docs.map(doc => (
        <article key={doc.filename}>
          <h2>{doc.filename}</h2>
          <pre>{doc.content}</pre>
        </article>
      ))}
    </div>
  );
}

💡 使用 JSON 轉 TypeScript 工具從 API 回應產生 TypeScript 型別定義,減少手寫型別的工作量。


Streaming 與 Suspense 整合

RSC 與 React Suspense 深度整合,實現串流渲染——頁面各部分獨立載入,使用者無需等待所有資料。

基礎 Suspense 模式

// app/page.tsx
import { Suspense } from 'react';

async function SlowSection() {
  const data = await fetch('https://api.example.com/slow', {
    next: { revalidate: 60 },
  }).then(res => res.json());

  return <div>{data.map(item => <p key={item.id}>{item.name}</p>)}</div>;
}

async function FastSection() {
  const data = await fetch('https://api.example.com/fast').then(res => res.json());
  return <div>{data.message}</div>;
}

export default function HomePage() {
  return (
    <div>
      <h1>串流渲染範例</h1>
      <Suspense fallback={<div className="animate-pulse">快速區域載入中...</div>}>
        <FastSection />
      </Suspense>
      <Suspense fallback={<div className="animate-pulse">慢速區域載入中...</div>}>
        <SlowSection />
      </Suspense>
    </div>
  );
}

巢狀 Suspense:漸進式載入

// app/dashboard/page.tsx
import { Suspense } from 'react';

async function RevenueChart() {
  const revenue = await db.query('SELECT * FROM revenue_monthly ORDER BY month');
  return <div className="h-64 bg-gray-100 rounded">圖表: {revenue.length} 筆資料</div>;
}

async function UserGrowthChart() {
  const growth = await db.query('SELECT * FROM user_growth ORDER BY month');
  return <div className="h-64 bg-gray-100 rounded">成長曲線: {growth.length} 筆資料</div>;
}

async function RecentActivity() {
  const activities = await db.query('SELECT * FROM activities ORDER BY created_at DESC LIMIT 20');
  return (
    <ul>
      {activities.map(a => <li key={a.id}>{a.description}</li>)}
    </ul>
  );
}

export default function DashboardPage() {
  return (
    <div className="space-y-6">
      <h1 className="text-2xl font-bold">資料看板</h1>
      <div className="grid grid-cols-2 gap-4">
        <Suspense fallback={<div className="h-64 animate-pulse bg-gray-200 rounded" />}>
          <RevenueChart />
        </Suspense>
        <Suspense fallback={<div className="h-64 animate-pulse bg-gray-200 rounded" />}>
          <UserGrowthChart />
        </Suspense>
      </div>
      <Suspense fallback={<div className="space-y-2">{Array.from({length: 5}).map((_, i) => (
        <div key={i} className="h-8 animate-pulse bg-gray-200 rounded" />
      ))}</div>}>
        <RecentActivity />
      </Suspense>
    </div>
  );
}

loading.tsx:Next.js 內建 Suspense

// app/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <div className="space-y-6 animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-48" />
      <div className="grid grid-cols-2 gap-4">
        <div className="h-64 bg-gray-200 rounded" />
        <div className="h-64 bg-gray-200 rounded" />
      </div>
      <div className="space-y-2">
        {Array.from({ length: 5 }).map((_, i) => (
          <div key={i} className="h-8 bg-gray-200 rounded" />
        ))}
      </div>
    </div>
  );
}

Next.js App Router 與 RSC

Next.js App Router 是 RSC 的最佳實踐框架,提供了完整的 RSC 支援。

App Router 目錄結構

app/
├── layout.tsx          # 根佈局(Server Component)
├── page.tsx            # 首頁(Server Component)
├── loading.tsx         # 首頁載入狀態
├── error.tsx           # 首頁錯誤處理
├── not-found.tsx       # 404 頁面
├── globals.css
├── articles/
│   ├── layout.tsx      # 文章佈局
│   ├── page.tsx        # 文章列表
│   ├── loading.tsx
│   ├── [id]/
│   │   ├── page.tsx    # 文章詳情
│   │   └── loading.tsx
│   └── new/
│       └── page.tsx    # 新建文章
├── api/
│   └── health/
│       └── route.ts    # API Route
└── components/
    ├── Header.tsx
    ├── LikeButton.tsx  # Client Component
    └── SearchBar.tsx   # Client Component

根佈局

// app/layout.tsx
import type { Metadata } from 'next';
import './globals.css';

export const metadata: Metadata = {
  title: {
    template: '%s | ToolsKu 部落格',
    default: 'ToolsKu 部落格',
  },
  description: '技術文章與教學',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="zh-TW">
      <body className="min-h-screen bg-gray-50">
        <nav className="bg-white shadow-sm border-b">
          <div className="max-w-6xl mx-auto px-4 py-3 flex justify-between items-center">
            <a href="/" className="text-xl font-bold text-blue-600">ToolsKu</a>
            <div className="flex gap-4">
              <a href="/articles" className="text-gray-600 hover:text-blue-600">文章</a>
              <a href="/about" className="text-gray-600 hover:text-blue-600">關於</a>
            </div>
          </div>
        </nav>
        <main className="max-w-6xl mx-auto px-4 py-8">
          {children}
        </main>
      </body>
    </html>
  );
}

動態路由

// app/articles/[id]/page.tsx
import { db } from '@/lib/db';
import { notFound } from 'next/navigation';
import LikeButton from '@/components/LikeButton';

interface PageProps {
  params: Promise<{ id: string }>;
}

export default async function ArticleDetailPage({ params }: PageProps) {
  const { id } = await params;
  const article = await db.query(
    'SELECT * FROM articles WHERE id = $1',
    [id]
  );

  if (!article) {
    notFound();
  }

  return (
    <article className="max-w-3xl mx-auto">
      <h1 className="text-3xl font-bold mb-4">{article.title}</h1>
      <div className="prose lg:prose-xl" dangerouslySetInnerHTML={{ __html: article.htmlContent }} />
      <div className="mt-8">
        <LikeButton articleId={article.id} initialLikes={article.likes} />
      </div>
    </article>
  );
}

Server Actions:伺服器端變更操作

Server Actions 是 RSC 生態中處理資料變更的核心機制,替代傳統 API Route。

基礎 Server Action

// app/articles/new/page.tsx
import { createArticle } from '@/actions/article';

export default function NewArticlePage() {
  return (
    <form action={createArticle} className="max-w-2xl mx-auto space-y-4">
      <div>
        <label className="block text-sm font-medium mb-1">標題</label>
        <input
          name="title"
          type="text"
          required
          className="w-full border rounded px-3 py-2"
        />
      </div>
      <div>
        <label className="block text-sm font-medium mb-1">內容</label>
        <textarea
          name="content"
          required
          rows={10}
          className="w-full border rounded px-3 py-2"
        />
      </div>
      <button
        type="submit"
        className="bg-blue-500 text-white px-6 py-2 rounded hover:bg-blue-600"
      >
        發布文章
      </button>
    </form>
  );
}

useActionState:帶狀態的 Server Action

// app/components/ArticleForm.tsx
'use client';

import { useActionState } from 'react';
import { createArticleWithState } from '@/actions/article';

const initialState = { message: '', errors: {} };

export default function ArticleForm() {
  const [state, formAction, isPending] = useActionState(
    createArticleWithState,
    initialState
  );

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <input name="title" type="text" placeholder="標題" className="w-full border rounded px-3 py-2" />
        {state.errors.title && <p className="text-red-500 text-sm">{state.errors.title}</p>}
      </div>
      <div>
        <textarea name="content" rows={10} placeholder="內容" className="w-full border rounded px-3 py-2" />
        {state.errors.content && <p className="text-red-500 text-sm">{state.errors.content}</p>}
      </div>
      <button
        type="submit"
        disabled={isPending}
        className="bg-blue-500 text-white px-6 py-2 rounded disabled:opacity-50"
      >
        {isPending ? '發布中...' : '發布文章'}
      </button>
      {state.message && <p className="text-green-600">{state.message}</p>}
    </form>
  );
}
// actions/article.ts
'use server';

import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';

export async function createArticleWithState(prevState: any, formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;
  const errors: Record<string, string> = {};

  if (!title || title.trim().length < 2) {
    errors.title = '標題至少2個字元';
  }
  if (!content || content.trim().length < 10) {
    errors.content = '內容至少10個字元';
  }

  if (Object.keys(errors).length > 0) {
    return { message: '', errors };
  }

  await db.query(
    'INSERT INTO articles (title, content, created_at) VALUES ($1, $2, NOW())',
    [title, content]
  );

  revalidatePath('/articles');
  return { message: '文章發布成功!', errors: {} };
}

樂觀更新:useOptimistic

// app/components/OptimisticLikeButton.tsx
'use client';

import { useOptimistic } from 'react';
import { toggleLike } from '@/actions/article';

interface LikeButtonProps {
  articleId: number;
  initialLikes: number;
  isLiked: boolean;
}

export default function OptimisticLikeButton({ articleId, initialLikes, isLiked }: LikeButtonProps) {
  const [optimisticState, addOptimistic] = useOptimistic(
    { likes: initialLikes, isLiked },
    (state, newIsLiked: boolean) => ({
      likes: newIsLiked ? state.likes + 1 : state.likes - 1,
      isLiked: newIsLiked,
    })
  );

  const handleToggle = async () => {
    const newIsLiked = !optimisticState.isLiked;
    addOptimistic(newIsLiked);
    await toggleLike(articleId, newIsLiked);
  };

  return (
    <button
      onClick={handleToggle}
      className={`px-4 py-2 rounded ${optimisticState.isLiked ? 'bg-red-500 text-white' : 'bg-gray-200'}`}
    >
      {optimisticState.isLiked ? '❤️' : '🤍'} {optimisticState.likes}
    </button>
  );
}

快取策略:revalidate 與 ISR

RSC 的快取策略決定了資料的新鮮度效能之間的平衡。

快取策略對比

策略 函式 快取時間 適用場景
靜態渲染 預設 建構時產生,永久快取 部落格、文件
ISR revalidate 定時重新驗證 新聞、商品列表
動態渲染 no-store 每次請求重新取得 即時資料、使用者面板
按需重新驗證 revalidatePath/Tag 手動觸發 內容更新後

fetch 快取設定

// 靜態渲染:建構時取得,永久快取
const staticData = await fetch('https://api.example.com/docs', {
  cache: 'force-cache',
});

// ISR:每60秒重新驗證
const isrData = await fetch('https://api.example.com/articles', {
  next: { revalidate: 60 },
});

// 動態渲染:每次請求重新取得
const dynamicData = await fetch('https://api.example.com/realtime', {
  cache: 'no-store',
});

// 按標籤快取,可按需清除
const taggedData = await fetch('https://api.example.com/products', {
  next: { tags: ['products'] },
});

按需重新驗證

// actions/revalidate.ts
'use server';

import { revalidatePath, revalidateTag } from 'next/cache';

export async function revalidateArticles() {
  revalidatePath('/articles');
  revalidatePath('/articles/[id]', 'page');
}

export async function revalidateProducts() {
  revalidateTag('products');
}

路由段設定

// app/articles/page.tsx
export const revalidate = 300; // ISR:5分鐘重新驗證

export default async function ArticlesPage() {
  const articles = await fetch('https://api.example.com/articles', {
    next: { revalidate: 300 },
  }).then(res => res.json());

  return <div>{articles.map(a => <p key={a.id}>{a.title}</p>)}</div>;
}
// app/dashboard/page.tsx
export const dynamic = 'force-dynamic'; // 強制動態渲染

export default async function DashboardPage() {
  const data = await db.query('SELECT * FROM stats');
  return <div>{data.length} 筆記錄</div>;
}

💡 使用 Base64 編碼 工具處理 API Token 和快取 Key 的編碼需求。


效能對比:Bundle Size、TTFB、FCP

RSC 在效能指標上有顯著提升,以下是真實專案的對比資料。

Bundle Size 對比

指標 Pages Router (SSR) App Router (RSC) 改善
首頁 JS 體積 187 KB 42 KB -77%
文章頁 JS 體積 203 KB 38 KB -81%
第三方函式庫體積 94 KB 0 KB(伺服器端) -100%
Hydration 指令碼 156 KB 23 KB -85%

Core Web Vitals 對比

指標 Pages Router App Router (RSC) 改善
TTFB 320ms 180ms -44%
FCP 1.8s 0.9s -50%
LCP 2.5s 1.4s -44%
TTI 3.2s 1.6s -50%
CLS 0.12 0.05 -58%

為什麼 RSC 更快?

  1. 零 Hydration:Server Component 不需要 Hydration,減少 JS 執行時間
  2. 串流傳輸:頁面分塊到達,FCP 大幅提前
  3. 依賴不進 Bundlemarkedhighlight.js 等僅在伺服器端執行
  4. 更少的請求瀑布流:伺服器端並行取得資料

效能測量程式碼

// app/api/metrics/route.ts
import { NextResponse } from 'next/server';

export async function GET() {
  const metrics = {
    bundleSizeKB: 42,
    ttfbMs: 180,
    fcpMs: 900,
    lcpMs: 1400,
    cls: 0.05,
    timestamp: new Date().toISOString(),
  };

  return NextResponse.json(metrics);
}

常見陷阱與錯誤

陷阱1:在 Server Component 中使用客戶端 API

// ❌ 錯誤:Server Component 不能使用 useState
export default async function Page() {
  const [count, setCount] = useState(0); // 報錯!
  return <div>{count}</div>;
}

// ✅ 正確:提取為 Client Component
// components/Counter.tsx
'use client';
import { useState } from 'react';
export default function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

陷阱2:Client 元件匯入 Server 元件

// ❌ 錯誤:Client 元件不能直接匯入 Server 元件
'use client';
import ServerDataTable from './ServerDataTable'; // 報錯!

export default function ClientWrapper() {
  return <ServerDataTable />;
}

// ✅ 正確:透過 children prop 傳遞
// components/ClientWrapper.tsx
'use client';
export default function ClientWrapper({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>切換</button>
      {isOpen && children}
    </div>
  );
}

// page.tsx
import ClientWrapper from '@/components/ClientWrapper';
import ServerDataTable from '@/components/ServerDataTable';

export default function Page() {
  return (
    <ClientWrapper>
      <ServerDataTable />
    </ClientWrapper>
  );
}

陷阱3:Server Action 中忘記 revalidate

// ❌ 錯誤:資料變更後快取未更新
'use server';
export async function updateArticle(id: number, data: FormData) {
  await db.query('UPDATE articles SET title = $1 WHERE id = $2', [data.get('title'), id]);
  // 缺少 revalidatePath!使用者看到的還是舊資料
}

// ✅ 正確:變更後重新驗證
'use server';
import { revalidatePath } from 'next/cache';
export async function updateArticle(id: number, data: FormData) {
  await db.query('UPDATE articles SET title = $1 WHERE id = $2', [data.get('title'), id]);
  revalidatePath('/articles');
  revalidatePath(`/articles/${id}`);
}

陷阱4:序列化不可序列化的資料

// ❌ 錯誤:傳遞不可序列化的 props 給 Client 元件
import LikeButton from './LikeButton';
export default async function Page() {
  const result = await db.query('SELECT * FROM articles');
  // Date 物件無法序列化傳遞給 Client Component
  return <LikeButton createdAt={result.createdAt} />;
}

// ✅ 正確:轉換為可序列化的值
export default async function Page() {
  const result = await db.query('SELECT * FROM articles');
  return <LikeButton createdAt={result.createdAt.toISOString()} />;
}

常見錯誤速查表

錯誤訊息 原因 解決方案
useState is not defined Server Component 中使用了 Hook 新增 "use client" 或提取為 Client 元件
onClick is not defined Server Component 中使用了事件 提取互動部分為 Client 元件
Functions cannot be passed as props 傳遞函式給 Client 元件 使用 Server Actions
Only plain objects can be passed 傳遞了不可序列化的資料 轉換為 JSON 相容格式
Hydration mismatch Server/Client 渲染不一致 檢查動態內容是否一致

從 Pages Router 遷移指南

遷移步驟概覽

步驟 內容 預估時間
1 建立 app/ 目錄,保留 pages/ 1 小時
2 遷移根佈局 _app.tsxapp/layout.tsx 2-4 小時
3 逐頁遷移 pages/app/ 每頁 1-2 小時
4 遷移 getServerSideProps → Server Component 每頁 1-2 小時
5 遷移 getStaticProps → 靜態渲染 + ISR 每頁 1-2 小時
6 遷移 API Routes → Server Actions / Route Handlers 每個 0.5-1 小時
7 刪除 pages/ 目錄 0.5 小時

getServerSideProps 遷移

// Pages Router:getServerSideProps
export async function getServerSideProps({ params }) {
  const article = await db.article.findUnique({ where: { id: params.id } });
  return { props: { article } };
}

export default function ArticlePage({ article }) {
  return <div>{article.title}</div>;
}
// App Router:Server Component
export default async function ArticlePage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const article = await db.article.findUnique({ where: { id } });

  if (!article) {
    notFound();
  }

  return <div>{article.title}</div>;
}

getStaticProps 遷移

// Pages Router:getStaticProps + ISR
export async function getStaticPaths() {
  const articles = await db.article.findMany({ select: { id: true } });
  return {
    paths: articles.map(a => ({ params: { id: String(a.id) } })),
    fallback: 'blocking',
  };
}

export async function getStaticProps({ params }) {
  const article = await db.article.findUnique({ where: { id: params.id } });
  return { props: { article }, revalidate: 300 };
}
// App Router:generateStaticParams + ISR
export async function generateStaticParams() {
  const articles = await db.article.findMany({ select: { id: true } });
  return articles.map(a => ({ id: String(a.id) }));
}

export const revalidate = 300;

export default async function ArticlePage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const article = await db.article.findUnique({ where: { id } });
  return <div>{article.title}</div>;
}

API Routes 遷移

// Pages Router:API Route
// pages/api/articles.ts
import type { NextApiRequest, NextApiResponse } from 'next';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method === 'GET') {
    const articles = await db.article.findMany();
    res.status(200).json(articles);
  } else if (req.method === 'POST') {
    const { title, content } = req.body;
    const article = await db.article.create({ data: { title, content } });
    res.status(201).json(article);
  }
}
// App Router:Route Handler
// app/api/articles/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET() {
  const articles = await db.article.findMany();
  return NextResponse.json(articles);
}

export async function POST(request: NextRequest) {
  const { title, content } = await request.json();
  const article = await db.article.create({ data: { title, content } });
  return NextResponse.json(article, { status: 201 });
}

SEO 最佳化與 RSC

RSC 天然對 SEO 友好——Server Component 在伺服器端渲染,搜尋引擎可直接抓取完整 HTML。

Metadata API

// app/articles/[id]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next';

interface PageProps {
  params: Promise<{ id: string }>;
}

export async function generateMetadata(
  { params }: PageProps,
  parent: ResolvingMetadata
): Promise<Metadata> {
  const { id } = await params;
  const article = await db.article.findUnique({ where: { id } });

  return {
    title: article.title,
    description: article.excerpt,
    openGraph: {
      title: article.title,
      description: article.excerpt,
      type: 'article',
      publishedTime: article.createdAt.toISOString(),
      authors: [article.author.name],
      images: [{ url: article.coverImage, width: 1200, height: 630 }],
    },
    twitter: {
      card: 'summary_large_image',
      title: article.title,
      description: article.excerpt,
      images: [article.coverImage],
    },
  };
}

Sitemap 與 Robots

// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { db } from '@/lib/db';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const articles = await db.article.findMany({
    select: { id: true, updatedAt: true },
  });

  const articleUrls = articles.map(article => ({
    url: `https://toolsku.com/articles/${article.id}`,
    lastModified: article.updatedAt,
    changeFrequency: 'weekly' as const,
    priority: 0.8,
  }));

  return [
    { url: 'https://toolsku.com', lastModified: new Date(), changeFrequency: 'daily', priority: 1 },
    { url: 'https://toolsku.com/articles', lastModified: new Date(), changeFrequency: 'daily', priority: 0.9 },
    ...articleUrls,
  ];
}
// app/robots.ts
import type { MetadataRoute } from 'next';

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      { userAgent: '*', allow: '/', disallow: ['/api/', '/dashboard/'] },
    ],
    sitemap: 'https://toolsku.com/sitemap.xml',
  };
}

結構化資料(JSON-LD)

// app/articles/[id]/page.tsx
export default async function ArticlePage({ params }: PageProps) {
  const { id } = await params;
  const article = await db.article.findUnique({ where: { id } });

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: article.title,
    description: article.excerpt,
    image: article.coverImage,
    datePublished: article.createdAt.toISOString(),
    dateModified: article.updatedAt.toISOString(),
    author: {
      '@type': 'Person',
      name: article.author.name,
    },
  };

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <article>
        <h1>{article.title}</h1>
        <div dangerouslySetInnerHTML={{ __html: article.htmlContent }} />
      </article>
    </>
  );
}

FAQ

RSC 和 SSR 有什麼區別?

SSR 在伺服器端渲染 HTML 但仍需在客戶端 Hydration(載入完整 JS 重建元件樹)。RSC 渲染的 Server Component 不需要 Hydration,其程式碼完全不會傳送到客戶端。

所有元件都應該用 Server Component 嗎?

預設使用 Server Component,只在需要互動(useState、useEffect、事件處理)時才使用 Client Component。經驗法則:80% Server + 20% Client。

RSC 可以不用 Next.js 嗎?

理論上可以,React 提供了底層協定,但需要自行實作 RSC 的傳輸和渲染管道。目前 Next.js App Router 是最成熟的 RSC 實作,其他框架(Remix、Waku)也在跟進。

Server Actions 安全嗎?

Server Actions 本質是 POST 請求,Next.js 會自動做 CSRF 保護。但仍需在 Action 內部做輸入驗證和權限檢查,不要信任客戶端傳來的任何資料。

RSC 對 SEO 有幫助嗎?

非常有幫助。Server Component 在伺服器端渲染完整 HTML,搜尋引擎可直接抓取。結合 Metadata API、Sitemap、JSON-LD,SEO 效果遠超客戶端渲染。

如何除錯 Server Component?

  1. 在 Server Component 中使用 console.log,輸出在伺服器端終端機
  2. 使用 Next.js Dev Tools 檢視元件渲染邊界
  3. 使用 React DevTools 檢視 Client Component 樹
  4. next.config.js 中開啟 logging: true

大型專案遷移順序建議?

  1. 先遷移純展示頁面(部落格、文件)——風險最低
  2. 再遷移資料展示頁面(列表、詳情)——替換 getServerSideProps
  3. 最後遷移互動頁面(表單、儀表板)——需要 Server Actions
  4. 保持 pages/app/ 並存,逐步遷移

本站提供瀏覽器本地工具,免註冊即可試用 →

#React#RSC#Server Components#Next.js#教程