ข้ามไปยังเนื้อหา

Server และ Client Components

ใน App Router ทุก component เป็น Server Component จนกว่าไฟล์จะบอกว่า "use client" และบรรทัดเดียวนี้ลากเส้น boundary ที่ตัดสินว่าโค้ดไหนจะไปถึง browser

component ใน app/ รันบน server เว้นแต่คุณจะ opt out นั่นแปลว่าเป็น async ได้, await data ได้ และแตะ database, filesystem หรือ secret ตรง ๆ ได้ — โค้ดพวกนี้ไม่ถูกส่งไป browser เลย

app/dashboard/page.tsx
import { db } from '@/lib/db';
export default async function Dashboard() {
const users = await db.user.findMany(); // runs on the server only
return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}

เพราะสิ่งนี้ไม่เคยถูกส่งไป client browser จึงไม่ต้องดาวน์โหลด JavaScript ของ component พวกนี้เลยสักไบต์ Server Components ใช้ useState, useEffect, event handler หรือ browser API ไม่ได้ — เพราะ render ครั้งเดียวบน server แล้วจบ

วาง "use client" ไว้บนสุดของไฟล์ แล้วทุกอย่างที่ไฟล์นั้น export จะกลายเป็น Client Component ซึ่งรันใน browser และใช้ hook, state, effect, event handler และ browser API ได้

app/counter.tsx
'use client';
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}

จุดที่ละเอียดอ่อนคือ boundary นี้เป็น transitive ทุก module ที่ Client Component import เข้ามาจะถูกดึงเข้า client bundle ด้วย ไม่ว่าจะมี directive ของตัวเองหรือไม่ คุณมาร์ก boundary ครั้งเดียวที่บนสุดของ tree — ไม่ต้องใส่ "use client" ซ้ำในทุก child

มีสองกฎที่คุมว่าทั้งสองชนิดผสมกันยังไง และไม่สมมาตร —

  • Server Component import และ render Client Component ได้ตรง ๆ
  • Client Component import Server Component ไม่ได้ — แต่รับเข้ามาเป็น children หรือ prop ได้

กฎข้อที่สองคือ doughnut (slot) pattern — Client Component เป็นวงแหวน และ Server Component เติมเข้าไปในรู เนื้อหาที่ render บน server ยังอยู่บน server แม้จะมี client component ล้อมรอบ

app/client-shell.tsx
'use client';
export default function ClientShell({ children }: { children: React.ReactNode }) {
return <div className="shell">{children}</div>;
}
app/server-data.tsx
export default function ServerData() {
return <p>Rendered on the server</p>;
}
app/page.tsx
import ClientShell from './client-shell';
import ServerData from './server-data';
export default function Page() {
return (
<ClientShell>
<ServerData /> {/* stays a Server Component, passed as children */}
</ClientShell>
);
}

มีข้อจำกัดหนึ่งที่ทำให้สิ่งนี้ทำงานได้ — prop ที่ข้าม boundary ต้อง serializable คุณส่ง string, number, Date, plain object, array และ JSX ได้ แต่ส่ง function (ยกเว้น Server Action) หรือ class instance เป็น prop ดิบ ๆ ไม่ได้ — ค่าพวกนี้รอดการเดินทางจาก server ไป client ไม่ได้ สองหัวข้อถัดไปโชว์ว่าเกิดอะไรขึ้นจริงตอนลอง

แทนที่จะ await data ใน Server Component แล้วส่งค่าที่ resolve แล้วลงไป คุณเริ่ม fetch แล้วส่ง Promise ที่ยังไม่ resolve ตรง ๆ ไปให้ Client Component ได้เลย Client Component จะแกะมันด้วย use() ของ React และ <Suspense> ที่ใกล้ที่สุดจะโชว์ fallback จนกว่าจะ resolve static shell จะ stream ออกไปทันที ส่วน data ที่ช้ามาทีหลัง โดยไม่ต้องมี client-side useEffect fetch เลย

lib/notifications-data.ts
export async function getNotifications(userId: string) {
await new Promise((r) => setTimeout(r, 20));
return [{ id: '1', text: `Hello ${userId}` }, { id: '2', text: 'Another one' }];
}
app/notifications/notification-list.tsx
'use client'
import { use } from 'react'
export function NotificationList({
notificationsPromise,
}: {
notificationsPromise: Promise<{ id: string; text: string }[]>
}) {
// Unwraps the Promise on the client — suspends the nearest boundary until it resolves
const notifications = use(notificationsPromise)
return (
<ul>
{notifications.map((n) => (
<li key={n.id}>{n.text}</li>
))}
</ul>
)
}
app/notifications/page.tsx
import { Suspense } from 'react'
import { NotificationList } from './notification-list'
import { getNotifications } from '@/lib/notifications-data'
export default function NotificationsPage() {
// Do not await — hand the unresolved Promise straight to the Client Component
const notificationsPromise = getNotifications('u_1')
return (
<Suspense fallback={<p>Loading notifications…</p>}>
<NotificationList notificationsPromise={notificationsPromise} />
</Suspense>
)
}

build ใน probe (next build) ผ่านและ prerender ได้สะอาด — use() คือสิ่งที่ทำให้ Promise (ซึ่งไม่ใช่ plain value ที่ serialize เป็น JSON ได้) ข้าม boundary ได้ — React จัดการ Promise เป็นกรณีพิเศษและ stream ผลลัพธ์ของมัน แทนที่จะพยายาม serialize มันเป็นค่าธรรมดา

module เปล่า ๆ ที่ไม่มี directive ถูก import จากฝั่งไหนก็ได้ และไม่มีอะไรห้ามเพื่อนร่วมทีม (หรือโค้ดที่ AI generate) import ไฟล์ที่มี server secret เข้า Client Component โดยไม่ตั้งใจ — มันไม่ error ที่ type level และรั่วเฉพาะตอน runtime เท่านั้น package server-only (และคู่หูของมัน client-only) เปลี่ยนความผิดพลาดนั้นให้เป็น build-time error แทน

lib/secret-data.ts
import 'server-only'
export function getSecret() {
return process.env.SECRET_KEY ?? 'unset'
}
app/exp1/client-uses-server-only.tsx
'use client'
import { getSecret } from '@/lib/secret-data'
export default function Bad() {
return <p>{getSecret()}</p>
}

tsc --noEmit ผ่านทั้งสองไฟล์ — ไม่มีอะไรผิดใน type level เลย รัน next build ใน probe ต่างหากที่ fail จริง ด้วย error สองอันนี้ —

Error: You're importing a module that depends on "server-only". This API is
only available in Server Components in the App Router, but you are using it
in the Pages Router.
Learn more: https://nextjs.org/docs/app/building-your-application/rendering/server-components
Error: 'server-only' cannot be imported from a Client Component module
It should only be used from a Server Component.

(ข้อความแรกที่พูดถึง “Pages Router” เป็น boilerplate ที่ใช้ร่วมกันทั้งสอง router — ข้อความที่สองคือตัวที่บอกปัญหาจริง ๆ) การติดตั้ง server-only/client-only เป็น optional ใน Next.js — framework รู้จักชื่อ package ทั้งสองอยู่แล้วภายในตัวมันเอง และจะ error แบบนี้ไม่ว่าจะติดตั้งจาก npm จริงหรือไม่ — แต่การติดตั้งช่วยให้ linter ที่เช็ค dependency ที่ขาดหายเงียบลง และให้ editor มี module จริงให้ resolve

data ที่ serialize ได้ข้ามได้ ส่วน function กับ class instance ข้ามไม่ได้ ทั้งสอง failure เป็น error จริงที่พิสูจน์ได้ — ไม่ใช่ type error แต่เป็น error ที่ React throw ระหว่าง prerender —

app/exp2/client-receiver.tsx
'use client'
export default function ClientReceiver({ onSave }: { onSave: () => void }) {
return <button onClick={onSave}>Save</button>
}
app/exp2/page.tsx
import ClientReceiver from './client-receiver'
function saveOnServer() {
console.log('saved')
}
export default function Page() {
return <ClientReceiver onSave={saveOnServer} />
}

tsc --noEmit ผ่าน — () => void เป็น function type ธรรมดาทั่วไป next build fail ระหว่าง prerender —

Error: Event handlers cannot be passed to Client Component props.
{onSave: function d}
^^^^^^^^^^
If you need interactivity, consider converting part of this to a Client Component.

class instance ก็ fail แบบเดียวกัน แต่ด้วยเหตุผลต่างกัน — React serialize แค่ plain object กับ built-in สั้น ๆ ไม่กี่ตัวข้าม boundary เท่านั้น —

app/exp3/client-receiver-user.tsx
'use client'
class UserModel {
constructor(public name: string) {}
}
export default function ClientReceiver({ user }: { user: UserModel }) {
return <p>{user.name}</p>
}
app/exp3/page.tsx
import ClientReceiver from './client-receiver-user'
class UserModel {
constructor(public name: string) {}
}
export default function Page() {
const user = new UserModel('Ada')
return <ClientReceiver user={user} />
}
Error: Only plain objects, and a few built-ins, can be passed to Client
Components from Server Components. Classes or null prototypes are not
supported.
{user: {name: "Ada"}}
^^^^^^^^^^^^^

ข้อยกเว้นเดียวของ “function ข้ามไม่ได้” คือ Server Function ('use server') ซึ่งข้ามไปเป็น reference ไม่ใช่ closure — นี่คือสิ่งที่ทำให้ส่ง action ไปให้ form หรือ button ทำงานได้ตั้งแต่แรก เรื่องนี้อยู่ใน What Are Server Actions

เพราะ boundary เป็น transitive ตำแหน่งที่คุณวาง "use client" จึงตัดสินว่า JavaScript จะถูกส่งไปเท่าไร — ไม่ใช่แค่ส่วนที่ interactive แต่รวมถึงทุก component ที่เป็น presentational ล้วน ๆ ที่บังเอิญอยู่ใต้มันด้วย layout สองเวอร์ชันของหน้าเดียวกันทำให้ความต่างจับต้องได้ — อันหนึ่งมาร์กทั้ง layout เป็น Client Component อีกอันเก็บ layout ไว้บน server แล้วดัน "use client" ลงไปที่ Counter leaf เล็ก ๆ

lib/widgets.tsx
// Static presentational content — no interactivity, no client-only APIs.
export function Sidebar() {
return <aside data-widget="sidebar">Recent posts, tags, links.</aside>
}
export function Nav() {
return <nav data-widget="nav">Home · Blog · About · Contact</nav>
}
export function Footer() {
return <footer data-widget="footer">© Example Co. All rights reserved.</footer>
}
app/heavy/high/layout.tsx
'use client'
// 'use client' on the layout — everything it imports and renders directly
// joins the client bundle, including Nav/Sidebar/Footer, which have no
// interactivity of their own.
import { useState } from 'react'
import { Sidebar, Nav, Footer } from '@/lib/widgets'
export default function HighLayout({ children }: { children: React.ReactNode }) {
const [count, setCount] = useState(0)
return (
<div>
<Nav />
<Sidebar />
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
{children}
<Footer />
</div>
)
}
app/heavy/low/layout.tsx
// A plain Server Component layout — Nav/Sidebar/Footer never leave the server.
import { Sidebar, Nav, Footer } from '@/lib/widgets'
import Counter from './counter'
export default function LowLayout({ children }: { children: React.ReactNode }) {
return (
<div>
<Nav />
<Sidebar />
<Counter />
{children}
<Footer />
</div>
)
}
app/heavy/low/counter.tsx
'use client'
// 'use client' pushed down to only the interactive leaf.
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>
}

Nav, Sidebar, Footer เป็น presentational component ธรรมดาที่ไม่มี hook เลย — ใน low มันยังเป็น Server Components และไม่ส่ง JavaScript ออกไปเลย next build ไม่พิมพ์คอลัมน์ bundle-size ต่อ route ให้แล้ว (ถูกถอดออกใน v16.0.0) หลักฐานจริงจึงมาจากการอ่าน client-reference manifest ที่ build ออกมาแล้วรวมขนาดไฟล์ chunk ที่ถูกอ้างถึงใต้ .next/static/chunks/ (next experimental-analyze --output ก็โชว์ data ชุดเดียวกันเป็น interactive treemap) —

--- /heavy/high ---
chunk 3fntmmi971322.js 14377 bytes (shared framework chunk — both routes load this)
chunk 3gejn2apugqil.js 8967 bytes (route-specific: Nav + Sidebar + Footer + Counter)
TOTAL referenced by /heavy/high: 23344 bytes
--- /heavy/low ---
chunk 3fntmmi971322.js 14377 bytes (same shared framework chunk)
chunk 1p8pi2r0mnrj-.js 287 bytes (route-specific: Counter only)
TOTAL referenced by /heavy/low: 14664 bytes

UI ที่เห็นเหมือนกันเป๊ะ static widget เดียวกันสามตัว — แต่ chunk เฉพาะ route ต่างกัน 31 เท่า (8,967 เทียบกับ 287 ไบต์) เพียงเพราะ "use client" อยู่ที่ layout แทนที่จะอยู่ที่ leaf

React Context ใช้ใน Server Components ไม่ได้ — createContext กับ <Context.Provider> ต้องรันในที่ที่มี React client runtime ทางแก้ไม่ใช่การเลี่ยง Context แต่คือห่อแค่ provider ไว้ใน Client Component เล็ก ๆ แล้ว render มัน จาก Server Component เหมือน Client Component ตัวอื่น ๆ —

app/theme/theme-provider.tsx
'use client'
import { createContext, useState } from 'react'
export const ThemeContext = createContext<{ theme: string; toggle: () => void }>({
theme: 'dark',
toggle: () => {},
})
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState('dark')
const toggle = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'))
return <ThemeContext.Provider value={{ theme, toggle }}>{children}</ThemeContext.Provider>
}
app/theme/theme-toggle.tsx
'use client'
import { useContext } from 'react'
import { ThemeContext } from './theme-provider'
export function ThemeToggle() {
const { theme, toggle } = useContext(ThemeContext)
return <button onClick={toggle}>Theme: {theme}</button>
}
app/theme/page.tsx
import { ThemeProvider } from './theme-provider'
import { ThemeToggle } from './theme-toggle'
// A Server Component renders the provider directly; every Client Component
// below it in the tree can read the context with useContext.
export default function ThemePage() {
return (
<ThemeProvider>
<ThemeToggle />
</ThemeProvider>
)
}

Page เป็น Server Component และไม่แตะ useState หรือ createContext เลย — มัน render แค่ ThemeProvider ซึ่งเป็น Client Component boundary วาง provider ให้ลึกใน tree ที่สุดเท่าที่ทำได้ (ห่อแค่ children ไม่ใช่ทั้ง document <html>) เพื่อให้ static shell ส่วนที่เหลืออยู่นอก client bundle

flowchart LR
  Root[Server Component root] --> Client["use client boundary"]
  Root --> Server[More Server Components]
  Client --> Bundle[Imports join client bundle]
  Root -. passes children .-> Client
  Client -. renders server child .-> Server
เส้น client boundary อยู่ตรงไหน

Server Component คือโค้ดที่ browser ไม่เคยเห็น ส่วน Client Component คือโค้ดที่ browser รันจริง "use client" ไม่ได้มาร์ก component เดียว — มันมาร์ก entry point ของทั้ง subtree ใน module graph และทุกอย่างที่ import ถึงจากตรงนั้นถูกส่งไป browser ทางออกสองทางที่ทำให้เนื้อหายังอยู่ฝั่ง server ได้แม้อยู่ใน client subtree คือ — children/prop ที่แบก JSX ที่ render แล้ว (doughnut pattern) และ use() ที่แกะ Promise ที่ Server Component เริ่มไว้ prop เป็นช่องทางเดียวที่ข้าม boundary ได้ และ prop ต้องเป็น data ที่ React serialize ได้ — function reference กับ class instance ทั้งคู่ไม่ผ่านการทดสอบนี้ ด้วยวิธีที่ต่างกันแต่พิสูจน์ได้จริงทั้งคู่

ได้อะไรประโยชน์ต้นทุน
Server Components เป็น defaultclient JS เป็นศูนย์สำหรับทุกอย่างที่ไม่ต้อง interactiveใช้ hook, state, หรือ browser API ไม่ได้
"use client" วางต่ำใน treeclient bundle เล็กที่สุดเท่าที่จะทำได้ต้องตั้งใจแยก leaf ที่ interactive จริง ๆ ไม่ใช่ทั้ง feature
use() + <Suspense> สำหรับ data ที่ streamstatic shell ส่งออกทันที data ที่ช้ามาทีหลังต้องมี Promise ที่สร้างใน Server Component และ Suspense boundary ล้อม consumer
server-only / client-onlyimport ผิดฝั่งทำให้ build fail แทนที่จะรั่ว secret ตอน runtimeเพิ่ม dependency อีกหนึ่งตัว (optional)
Context Provider wrapperทำให้ pattern shared-state ยังทำงานข้าม boundary ได้wrapper เองและทุกอย่างใต้มันเป็น client JavaScript
  • “'use client' กระทบแค่ component ที่ใส่ไว้” มันกระทบทุก module ที่ component นั้น import และ render ตรง ๆ — ทั้ง subtree เข้า client bundle ด้วย นี่คือเหตุผลที่ตำแหน่งสำคัญมาก (ดูตัวอย่าง 8,967 เทียบกับ 287 ไบต์ด้านบน)
  • “ส่ง function prop จาก Server ไป Client แค่ทำงานไม่ตรงตามคาด” มันไม่ได้ compile แล้วพังแบบเงียบ ๆ — มัน throw error จริงเฉพาะเจาะจง: Event handlers cannot be passed to Client Component props. ข้อยกเว้นเดียวคือ Server Function ('use server') ที่ข้ามไปเป็น reference
  • “server-only จำเป็นเพื่อให้ secret ปลอดภัย” มันเป็น optional — Next.js ตัด env var ที่ไม่ใช่ NEXT_PUBLIC_ ออกจาก client bundle อยู่แล้ว server-only แปลง import ผิดฝั่งที่เกิดโดยไม่ตั้งใจให้กลายเป็น build error ที่ดัง ๆ ซึ่งเป็นเรื่อง correctness/DX ไม่ใช่ safety net เดียว
  • “React Context ใช้กับ App Router ไม่ได้” ใช้ได้ปกติ — แค่ต้องอยู่ใน Client Component (ตัว provider) ซึ่ง Server Component ยัง render ตรง ๆ ได้

เวลาโค้ดที่ generate มาข้าม boundary Server/Client เช็คสิ่งเหล่านี้ก่อนเชื่อ —

  • "use client" อยู่ที่ทั้ง layout หรือ page ทั้งที่มีแค่ชิ้นเล็ก ๆ ข้างในที่ interactive จริง ๆ หรือเปล่า? นั่นลาก sibling ทุกตัวเข้า client bundle ไปด้วย
  • มี function (ที่ไม่ใช่ Server Function 'use server') ถูกส่งเป็น prop จาก Server Component ไป Client Component หรือเปล่า? มัน compile ผ่าน แต่ throw Event handlers cannot be passed to Client Component props. ตอน build/render
  • มี class instance — แถวจาก ORM, custom error class, อะไรก็ตามจาก new — ถูกส่งเป็น prop ข้าม boundary หรือเปล่า? มีแค่ plain object กับ built-in สั้น ๆ ที่รอด class ไม่รอด
  • ไฟล์ที่อ่าน secret หรือแตะ database ไม่มี server-only guard หรือเปล่า? ไม่มีอะไรห้ามมันถูก import เข้า Client Component โดยไม่ตั้งใจ
  • createContext/<Context.Provider> ถูกใช้ตรง ๆ ใน Server Component แทนที่จะอยู่ใน wrapper 'use client' เล็ก ๆ หรือเปล่า?
  • ถ้า Promise ถูกส่งให้ Client Component ไปใช้ use() มี <Suspense> boundary ล้อม consumer หรือเปล่า? ถ้าไม่มี ทั้ง tree จะ block แทนที่จะ stream

🐛 หา bug ในโค้ดนี้

app/nav.tsx
'use client'
import { useState } from 'react'
import { Sidebar, Footer } from '@/lib/widgets'
export default function Nav({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
return (
<div>
<button onClick={() => setOpen((o) => !o)}>Menu</button>
{open && <Sidebar />}
{children}
<Footer />
</div>
)
}
เฉลย

มีแค่ toggle open ที่ต้องการ client — แต่ "use client" อยู่ที่ทั้ง component Nav ทำให้ Sidebar กับ Footer (component แสดงผลธรรมดาที่ไม่มี hook ของตัวเอง) ถูกลากเข้า client bundle ไปด้วย ทั้งที่มันไม่เคยเปลี่ยนเลย พิสูจน์แล้วใน probe — ย้าย boundary "use client" ลงไปที่ MenuButton leaf เล็ก ๆ แล้ว render Sidebar/Footer จาก Server Component parent ทำให้ client chunk เฉพาะ route หดจาก 8,967 ไบต์เหลือ 287 ไบต์ สำหรับ layout ที่เทียบเท่ากัน — output ที่เห็นเหมือนเดิม แต่ JavaScript น้อยลง 31 เท่า ทางแก้คือแยก toggle ที่มี state ออกมาเป็น Client Component เล็ก ๆ ของตัวเอง แล้วให้ Nav, Sidebar, Footer เป็น Server Components ที่ render มัน

ใน App Router component เป็นอะไรก่อนที่จะเพิ่ม directive ใด ๆ
"use client" ทำอะไรกับ module ที่ไฟล์นั้น import เข้ามา
Client Component จะรวมเนื้อหาแบบ Server Component ได้ยังไง
เกิดอะไรขึ้นตอน build โค้ดนี้
// app/exp2/page.tsx
function saveOnServer() {
  console.log('saved')
}

export default function Page() {
  return <ClientReceiver onSave={saveOnServer} />
}
ทำไม import module ที่มี server-only guard จาก Client Component ถึงทำให้ build fail
ทำไม getNotifications ถูกเรียกโดยไม่มี await ตรงนี้
// lib/notifications-data.ts
export async function getNotifications(userId: string) {
  await new Promise((r) => setTimeout(r, 20))
  return [{ id: '1', text: `Hello ${userId}` }]
}
// app/notifications/page.tsx
export default function NotificationsPage() {
  const notificationsPromise = getNotifications('u_1')
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <NotificationList notificationsPromise={notificationsPromise} />
    </Suspense>
  )
}
layout สองอันที่เทียบเท่ากัน render Nav/Sidebar/Footer และปุ่ม interactive ปุ่มเดียวเหมือนกัน อันหนึ่งใส่ "use client" ที่ทั้ง layout อีกอันเก็บ layout ไว้บน server แล้วมาร์กแค่ wrapper ของปุ่มเป็น Client Component ผลวัดใน probe เป็นยังไง