Next.js App Router 常見踩雷:Server / Client Component 怎麼切?

2026/06/26

從實務角度整理 Next.js App Router 中 Server Component 與 Client Component 的分工方式,包含常見錯誤、切分原則、資料取得、互動狀態與元件設計建議。
Abstract Next.js App Router server and client boundary banner

開始使用 Next.js App Router 之後,最常遇到的問題之一就是:

這個元件到底要不要加 'use client'?

一開始很容易看到錯誤就直接加。

元件不能用 useState,加。

不能用 useEffect,加。

不能用 click event,加。

第三方套件報錯,也加。

最後整個頁面越來越多 Client Component,原本 App Router 想帶來的 Server Component 優勢,可能就慢慢消失了。

我自己現在會把這件事想成:

預設先 Server,需要互動再 Client。

這句話很簡單,但實務上難的是判斷「什麼叫需要互動」。

Server Component 是什麼?

Server Component 是在伺服器端執行的元件。

它適合處理:

  • 從資料庫取得資料
  • 呼叫內部 API
  • 讀取 server-only 環境變數
  • 組出 SEO 需要的內容
  • 減少送到瀏覽器的 JavaScript
  • 渲染不需要互動的 UI

例如文章詳情頁:

tsx
export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;

  const post = await getPostBySlug(slug);

  return (
    <main>
      <h1>{post.title}</h1>
      <article>{post.content}</article>
    </main>
  );
}

這種頁面本身不一定需要互動。

它只是根據 slug 取得文章資料,然後渲染內容。

這時候讓它保持 Server Component 會比較合理。

Client Component 是什麼?

Client Component 是會送到瀏覽器端執行的元件。

只要元件需要使用 React client-side feature,就要加 'use client'。

例如:

tsx
'use client';

import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount((prev) => prev + 1)}>
      Count: {count}
    </button>
  );
}

只要有這些需求,通常就是 Client Component:

需求原因
useState需要瀏覽器端狀態
useEffect需要在瀏覽器端執行副作用
onClick需要使用者互動
window / document只能在瀏覽器端使用
localStorage瀏覽器 API
Zustandclient state
React Queryclient-side cache
表單即時互動需要瀏覽器狀態

常見踩雷 1:整個 page 都加 'use client'

這是最常見的情況。

例如:

tsx
'use client';

export default function PostPage() {
  return (
    <main>
      <PostHeader />
      <PostContent />
      <LikeButton />
    </main>
  );
}

這樣不是不能跑,但問題是整個 page 都變成 Client Component。

如果 PostHeader、PostContent 其實只是靜態內容,它們也會被一起拉到 client side。

比較好的切法是:

tsx
export default async function PostPage() {
  const post = await getPost();

  return (
    <main>
      <PostHeader title={post.title} />
      <PostContent content={post.content} />
      <LikeButton postId={post.id} />
    </main>
  );
}

然後只有真的需要互動的部分加 'use client':

tsx
'use client';

export function LikeButton({ postId }: { postId: string }) {
  return <button onClick={() => console.log(postId)}>Like</button>;
}

這樣頁面主體仍然是 Server Component。

只有 LikeButton 是 Client Component。

常見踩雷 2:因為一個 button,整個區塊都變 Client

很多時候,我們只是需要某個小按鈕有互動。

但如果沒有切好,就會讓整個區塊變成 Client Component。

例如:

tsx
'use client';

export function PostCard({ post }: { post: Post }) {
  return (
    <article>
      <h2>{post.title}</h2>
      <p>{post.excerpt}</p>
      <button onClick={() => alert('saved')}>收藏</button>
    </article>
  );
}

這樣 PostCard 會整個變 Client。

如果文章卡片內容很多,而且大部分都是靜態顯示,可以改成:

tsx
export function PostCard({ post }: { post: Post }) {
  return (
    <article>
      <h2>{post.title}</h2>
      <p>{post.excerpt}</p>
      <SaveButton postId={post.id} />
    </article>
  );
}
tsx
'use client';

export function SaveButton({ postId }: { postId: string }) {
  return (
    <button onClick={() => alert(`saved: ${postId}`)}>
      收藏
    </button>
  );
}

這樣 Client Component 的範圍就比較小。

常見踩雷 3:把資料 fetching 全部搬到 Client

以前寫 React SPA 很習慣這樣:

tsx
'use client';

import { useEffect, useState } from 'react';

export default function PostsPage() {
  const [posts, setPosts] = useState<Post[]>([]);

  useEffect(() => {
    fetch('/api/posts')
      .then((res) => res.json())
      .then(setPosts);
  }, []);

  return (
    <main>
      {posts.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
    </main>
  );
}

這在傳統 React 裡很常見。

但在 App Router 裡,如果這些資料是頁面初始就需要的內容,通常可以直接在 Server Component 取得:

tsx
export default async function PostsPage() {
  const posts = await getPosts();

  return (
    <main>
      {posts.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
    </main>
  );
}

這樣有幾個好處:

  • 初始 HTML 內容比較完整
  • 對 SEO 比較友善
  • 不需要先顯示空畫面再 loading
  • 可以減少 client-side JavaScript
  • 資料取得邏輯更靠近 server

如果是部落格文章、商品詳情、行銷頁內容,通常都很適合放在 Server Component。

常見踩雷 4:在 Client Component 裡放敏感邏輯

Client Component 會送到瀏覽器。

所以不該把敏感邏輯放進 Client Component。

例如:

tsx
'use client';

const apiKey = process.env.SECRET_API_KEY;

這種概念上就不對。

敏感資料、server-only token、資料庫查詢,都應該放在 server 端。

比較好的方式是:

tsx
export default async function DashboardPage() {
  const data = await getDashboardData();

  return <DashboardView data={data} />;
}

如果畫面需要互動,再把安全處理過的資料傳給 Client Component。

tsx
'use client';

export function DashboardView({ data }: { data: DashboardData }) {
  return <div>{data.title}</div>;
}

重點是:

Client Component 只拿它需要顯示或互動的資料,不要拿敏感資訊。

常見踩雷 5:Server Component 傳不能序列化的 props 給 Client Component

Server Component 可以 import Client Component。

但傳給 Client Component 的 props 需要是可以序列化的資料。

通常可以傳:

  • string
  • number
  • boolean
  • null
  • array
  • plain object

不適合傳:

  • function
  • class instance
  • Date object 沒處理
  • Map / Set
  • 含有循環引用的 object

例如這樣就不適合:

tsx
<ClientComponent onClick={handleClick} />

因為 Server Component 裡的 function 不能直接傳到 Client Component。

比較好的方式是讓 Client Component 自己處理互動:

tsx
<ClientComponent postId={post.id} />
tsx
'use client';

export function ClientComponent({ postId }: { postId: string }) {
  function handleClick() {
    console.log(postId);
  }

  return <button onClick={handleClick}>Click</button>;
}

Server 負責給資料。

Client 負責互動。

常見踩雷 6:第三方 UI 套件導致整包變 Client

很多 UI library 的元件本身需要 client-side 行為。

例如:

  • Modal
  • Select
  • Date Picker
  • Tooltip
  • Drawer
  • Carousel
  • Rich Text Editor

這些元件通常會用到 state、effect、DOM event,所以多半需要 Client Component。

但不代表整個頁面都要變 Client。

可以把 UI 套件集中包在小型 Client Component 裡。

例如:

tsx
export default async function SettingsPage() {
  const profile = await getProfile();

  return (
    <main>
      <h1>設定</h1>
      <ProfileForm profile={profile} />
    </main>
  );
}
tsx
'use client';

export function ProfileForm({ profile }: { profile: Profile }) {
  return (
    <form>
      <input defaultValue={profile.name} />
      <button type="submit">儲存</button>
    </form>
  );
}

這樣頁面的資料取得仍然在 Server。

表單互動才放 Client。

我的切分原則

實務上我會用幾個問題判斷。

1. 這個元件需要使用者互動嗎?

如果有 click、input、select、drag、keyboard event,通常是 Client Component。

例如:

元件建議
LikeButtonClient
SearchInputClient
LoginFormClient
ThemeToggleClient
MobileDrawerClient
ArticleContentServer
BreadcrumbServer
FooterServer

2. 這個元件需要 React Hooks 嗎?

如果需要這些 hooks,就要是 Client Component:

  • useState
  • useEffect
  • useRef
  • useReducer
  • useMemo
  • useCallback
  • Zustand store
  • React Query hooks

但這裡要注意,不是看到要用 hooks 就直接把整頁改成 Client。

更好的做法是把需要 hooks 的部分拆出去。

3. 這個資料是不是初始畫面就需要?

如果資料是頁面一進來就要看到的內容,通常適合在 Server Component 取得。

例如:

資料建議
文章詳情Server
商品詳情Server
分類列表Server
SEO metadataServer
登入後使用者基本資料視情況
即時搜尋結果Client 或 URL + Server
無限滾動資料Client / Hybrid

4. 這段邏輯有沒有安全風險?

如果邏輯涉及:

  • token
  • cookie
  • session
  • database
  • 權限判斷
  • server-only API
  • 私密環境變數

就不要放在 Client Component。

這些應該放在:

  • Server Component
  • Route Handler
  • Server Action
  • DAL
  • Middleware

Client Component 只負責顯示結果或觸發動作。

5. 這個狀態是不是只存在瀏覽器?

如果狀態只跟瀏覽器互動有關,通常就是 Client。

例如:

狀態建議
modal openClient
tab activeClient
drawer openClient
input valueClient
localStorage themeClient
selected rowClient
URL search params視需求
文章內容Server

一個常見頁面的切法

以部落格文章頁來說,我會這樣切:

txt
PostPage.server  ├── PostHeader.server  ├── PostContent.server  ├── RelatedPosts.server  ├── ShareButton.client  └── FavoriteButton.client

文章內容、標題、相關文章都可以是 Server Component。

分享按鈕、收藏按鈕需要互動,所以拆成 Client Component。

範例:

tsx
export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPostBySlug(slug);

  return (
    <main>
      <PostHeader title={post.title} publishedAt={post.publishedAt} />
      <PostContent content={post.content} />

      <div>
        <ShareButton title={post.title} />
        <FavoriteButton postId={post.id} />
      </div>
    </main>
  );
}

這樣整個頁面不會因為兩個按鈕,就全部變成 Client Component。

搜尋頁要怎麼切?

搜尋頁比較容易卡住。

因為它同時有:

  • 搜尋框
  • 篩選條件
  • URL search params
  • API 資料
  • loading 狀態
  • 分頁

我會先看需求。

如果搜尋結果需要 SEO,或希望網址可以分享:

txt
/posts?keyword=react&category=frontend&page=1

那我會讓 search params 驅動 Server Component 重新取得資料。

tsx
export default async function PostsPage({
  searchParams,
}: {
  searchParams: Promise<{
    keyword?: string;
    category?: string;
    page?: string;
  }>;
}) {
  const params = await searchParams;

  const posts = await getPosts({
    keyword: params.keyword,
    category: params.category,
    page: Number(params.page ?? 1),
  });

  return (
    <main>
      <PostSearchBar />
      <PostList posts={posts} />
    </main>
  );
}

PostSearchBar 可以是 Client Component,負責更新 URL。

tsx
'use client';

import { useRouter, useSearchParams } from 'next/navigation';

export function PostSearchBar() {
  const router = useRouter();
  const searchParams = useSearchParams();

  function handleSearch(keyword: string) {
    const params = new URLSearchParams(searchParams);

    if (keyword) {
      params.set('keyword', keyword);
    } else {
      params.delete('keyword');
    }

    router.push(`/posts?${params.toString()}`);
  }

  return (
    <input
      placeholder="搜尋文章"
      onChange={(event) => handleSearch(event.target.value)}
    />
  );
}

這種切法的概念是:

txt
Client 更新 URL        ↓Server 根據 searchParams 取得資料        ↓畫面重新渲染

這樣搜尋條件可以被網址記住,也比較適合列表頁。

React Query 要放哪?

React Query 是 Client Side 的資料管理工具。

所以只要使用 useQuery,那個元件就要是 Client Component。

例如:

tsx
'use client';

import { useQuery } from '@tanstack/react-query';

export function NotificationList() {
  const { data, isLoading } = useQuery({
    queryKey: ['notifications'],
    queryFn: fetchNotifications,
  });

  if (isLoading) return <p>Loading...</p>;

  return (
    <ul>
      {data?.map((item) => (
        <li key={item.id}>{item.title}</li>
      ))}
    </ul>
  );
}

那什麼時候適合用 React Query?

我通常會放在這些情境:

情境適合
使用者登入後資料可以
通知列表可以
收藏狀態可以
dashboard 小卡可以
需要 refetch 的資料可以
初始 SEO 內容不一定
文章詳情頁主內容通常不用

如果資料跟 SEO 或初始內容強相關,我會優先 Server Component。

如果資料需要頻繁互動、重新整理、mutation、快取,就可以考慮 React Query。

Zustand 要放哪?

Zustand 也是 Client State,所以使用 Zustand 的元件一定是 Client Component。

例如:

tsx
'use client';

import { useUiStore } from '@/stores/useUiStore';

export function MobileMenuButton() {
  const openMenu = useUiStore((state) => state.openMenu);

  return <button onClick={openMenu}>Menu</button>;
}

Zustand 很適合放:

  • modal 狀態
  • drawer 狀態
  • selected item
  • compare mode
  • table UI 狀態
  • 跨元件共享的 client state

但我不會把 Server Component 為了使用 Zustand 直接改成 Client。

我會拆小:

txt
Layout.server  ├── Header.server  ├── MobileMenuButton.client  └── MobileDrawer.client

我自己的判斷表

問題如果是建議
需要 SEO 嗎?需要Server
初始畫面就要資料嗎?需要Server
需要使用者互動嗎?需要Client
需要 useState 嗎?需要Client
需要 Zustand 嗎?需要Client
需要 React Query 嗎?需要Client
需要讀 token / DB 嗎?需要Server
只是排版或內容顯示嗎?是Server
只是小按鈕互動嗎?是拆成小 Client Component

我會避免的寫法

1. 一遇到錯誤就整頁加 'use client'

這通常只是讓錯誤消失,不代表架構正確。

比較好的做法是先問:

是整頁都需要 client,還是只有某個小元件需要?

2. 把 server-only 邏輯放進 Client Component

例如:

  • 直接查 DB
  • 使用 secret token
  • 做完整權限判斷
  • 呼叫內部私密 API

這些不該放 Client。

3. 為了用一個 hook,讓整個 layout 變 Client

這也很常見。

例如為了 theme、menu、auth 狀態,把整個 layout 改成 Client。

通常可以拆成 provider 或小元件,不一定要讓整個 layout client-side。

4. 所有資料都用 useEffect 打 API

App Router 已經提供 Server Component 的資料取得模式。

如果是初始頁面內容,不一定要回到以前 SPA 的寫法。

我的實務分工方式

我現在會用這個方向拆:

txt
Server Component  負責資料取得、SEO、權限前置檢查、靜態內容、版面骨架 Client Component  負責互動、表單、瀏覽器 API、client state、mutation 操作

更具體一點:

txt
Page.server  ├── Data Section.server  ├── Content Section.server  ├── SEO Content.server  └── Interactive Widget.client

不要為了局部互動,把整個頁面變成 Client Component。

也不要為了追求 Server Component,把所有互動都寫得很彆扭。

重點是邊界要清楚。

總結

Next.js App Router 的 Server Component / Client Component 切分,核心不是背規則。

而是先分清楚每個元件的責任。

責任放哪裡
資料取得Server
SEO 內容Server
靜態 UIServer
權限與敏感邏輯Server
使用者互動Client
表單狀態Client
瀏覽器 APIClient
ZustandClient
React QueryClient

我自己的原則是:

頁面預設 Server,互動局部 Client。

這樣做可以保留 App Router 的優勢,也能避免整個專案慢慢退回傳統 SPA 寫法。

Server Component 不是要取代 Client Component。

Client Component 也不是不能用。

真正重要的是,不要讓一個小互動,拖著整個頁面一起變 Client。

切得清楚,資料流會比較穩,效能比較好,後面接手的人也比較容易看懂。


這篇文章有幫助嗎?