ใน Next.js App Router ทุก component เป็น Server Component อยู่แล้วถ้าไม่ได้บอกเป็นอย่างอื่น กฎของบทนี้มีข้อเดียว คือ ให้ page และ layout เป็น Server Component ไว้ก่อน แล้วใส่ 'use client' เฉพาะชิ้นเล็กที่สุดที่ต้องมี state หรือรับ event เนื้อหาที่ render บน server ได้ ให้ส่งเข้าไปใน client component เป็น children แทนการ import
ฟังดูง่าย แต่โปรเจกต์ที่โตมาสักพักมักมี 'use client' อยู่บนสุดของไฟล์ใหญ่ ๆ เพราะตอนแรกต้องใช้ useState ตัวเดียว ผลคือทั้งหน้ากลายเป็นโค้ดที่เบราว์เซอร์ต้องโหลด ทั้งที่ส่วนใหญ่ไม่ได้ขยับเลย
'use client' ทำงานกับทั้งไฟล์และทุกอย่างที่ไฟล์นั้น import
'use client' ไม่ได้ทำเครื่องหมายแค่ component ที่เขียนไว้ในไฟล์ เอกสารของ React อธิบายว่า directive นี้ทำเครื่องหมายทั้ง module และทุก module ที่ไฟล์นั้น import ต่อไป Next.js เรียกกลุ่มนี้ว่า module graph ของ client component Next.js จะ bundle ทุกไฟล์ในกลุ่มนี้ส่งไปที่เบราว์เซอร์ และ render ซ้ำที่ฝั่ง client อีกรอบหลัง hydrate
สิ่งที่ไม่ได้อยู่ในกลุ่มนี้คือ Server Component ที่ส่งเข้ามาเป็น children หรือ props อื่น เพราะ Next.js render ชิ้นนั้นบน server เสร็จแล้วส่งเข้ามาเป็นผลลัพธ์ client component แค่วางผลลัพธ์นั้นไว้ในช่องที่เตรียมไว้ ไม่ต้องรู้ว่าข้างในเขียนยังไง
เส้นแบ่งระหว่าง server กับ client จึงเรียกว่า boundary ยิ่งวางไว้สูง (ใกล้ page) ยิ่งลากโค้ดลงไปที่เบราว์เซอร์มาก ยิ่งวางต่ำ (ใกล้ปุ่มที่กดได้) ยิ่งเหลือโค้ดฝั่ง client น้อย
สิ่งที่ได้เมื่อวาง boundary ต่ำ:
- JavaScript ที่ส่งไปน้อยลง ข้อความบทความ ตารางราคา รูปภาพ ส่งไปเป็น HTML อย่างเดียว ไม่มีโค้ด component ตามไปด้วย
- ดึงข้อมูลได้ตรงที่ใช้ Server Component เป็น
asyncได้ อ่านฐานข้อมูลหรือเรียก API ได้เลย ไม่ต้องทำ endpoint ให้ client มาเรียกอีกชั้น - ความลับอยู่บน server API key, query, logic เรื่องสิทธิ์ ไม่มีทางหลุดไปอยู่ใน bundle
- ส่วนที่ต้องโต้ตอบชัดเจนขึ้น เปิดโฟลเดอร์ดูก็รู้ว่าไฟล์ไหนมี state ไฟล์ไหนแค่แสดงผล
อาการที่บอกว่า boundary อยู่สูงเกินไป
ไล่ดูในโปรเจกต์ได้ง่าย ๆ จากสี่ข้อนี้:
- ไฟล์ route มี
'use client'เช่นpage.tsxหรือlayout.tsxขึ้นต้นด้วย'use client'แปลว่าทั้งหน้าเป็น client ไปแล้ว และ page จะเป็นasyncเพื่อดึงข้อมูลเองไม่ได้ - ไฟล์ client import โค้ดฝั่ง server เช่น
server-only,next/headers, ตัวต่อฐานข้อมูล หรือnode:fsถ้า build ผ่านได้ก็มักเป็นเพราะไปโชคดีที่ไม่ได้ใช้จริง - client component import เนื้อหานิ่ง ๆ เข้าไปเอง เช่น component ที่แสดงรายละเอียดสินค้า หรือ markdown ที่ render แล้ว ทั้งที่ไม่ได้ขยับเลย
- มี
useStateตัวเดียวในไฟล์ที่ยาวหลายร้อยบรรทัด state นั้นมักเป็นของปุ่มหรือ dropdown เล็ก ๆ ตัวเดียว ส่วนที่เหลือของไฟล์ไม่ได้ใช้
ข้อ 1 ถึง 3 ตรวจอัตโนมัติได้ ถ้าโปรเจกต์มีสคริปต์ตรวจขนาดไฟล์หรือ lint rule อยู่แล้ว ให้นับข้อพวกนี้เป็นเรื่องที่ต้องแก้เสมอ ไม่ว่าไฟล์จะยาวแค่ไหน วิธีตั้งเกณฑ์อยู่ในบทเกณฑ์ขนาดไฟล์และ component
ตัวอย่าง: ปุ่มเพิ่มจำนวนปุ่มเดียวลากทั้งหน้าไปเป็น client
หน้าสินค้าที่มีชื่อสินค้า รายละเอียดยาว ๆ และกล่องเลือกจำนวนกับปุ่มใส่ตะกร้า เขียนแบบแรกที่นึกออกจะได้แบบนี้
// ก่อน: app/products/[id]/page.tsx ทั้งหน้าเป็น client เพราะ qty ตัวเดียว
'use client';
import { useState } from 'react';
export default function ProductPage({ product }: { product: Product }) {
const [qty, setQty] = useState(1);
return (
<article>
<h1>{product.name}</h1>
<ProductGallery images={product.images} />
<ProductDescription html={product.body} />
<QtyStepper value={qty} onChange={setQty} />
<AddToCart productId={product.id} qty={qty} />
</article>
);
}
// หลัง: page เป็น Server Component ดึงข้อมูลเอง มี client เหลือแค่กล่องซื้อ
// app/products/[id]/page.tsx
import { getProduct } from '@/lib/data/products';
import { BuyBox } from './_components/buy-box';
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const product = await getProduct(id);
return (
<article>
<h1>{product.name}</h1>
<ProductGallery images={product.images} />
<ProductDescription html={product.body} />
<BuyBox productId={product.id} />
</article>
);
}
// app/products/[id]/_components/buy-box.tsx
'use client';
import { useState } from 'react';
export function BuyBox({ productId }: { productId: string }) {
const [qty, setQty] = useState(1);
return (
<div className="buy-box">
<QtyStepper value={qty} onChange={setQty} />
<AddToCart productId={productId} qty={qty} />
</div>
);
}
สิ่งที่เปลี่ยน:
- page กลับมาเป็น
asyncได้ แบบก่อนต้องมีใครสักคนส่งproductเข้ามา ซึ่งมักจบที่useEffectไป fetch API อีกที หรือ layout ที่เป็น server ดึงมาส่งให้ แบบหลังดึงเองตรงนั้นผ่าน data layer (getProduct) ส่วนparamsใน Next.js 15 ขึ้นไปเป็น Promise จึงต้องawaitก่อน - gallery กับรายละเอียดสินค้าไม่ไปอยู่ใน bundle ถ้า
ProductDescriptionใช้ library แปลง markdown หรือ sanitize HTML ตัวใหญ่ ๆ library นั้นก็อยู่บน server ด้วย - state อยู่กับคนที่ใช้
qtyใช้แค่QtyStepperกับAddToCartจึงย้ายไปอยู่ในBuyBoxซึ่งเป็น client component ตัวเดียวของหน้านี้ - ไฟล์ใหม่อยู่ใน
_components/โฟลเดอร์ที่ขึ้นต้นด้วย_เป็น private folder ของ App Router ไม่กลายเป็น route วางไฟล์ที่ใช้กับหน้านี้หน้าเดียวไว้ข้าง ๆ กันได้ (รายละเอียดในบทจัดโฟลเดอร์ตาม feature)
ถ้า ProductGallery มีปุ่มเลื่อนรูป ก็ทำแบบเดียวกัน คือให้ตัว gallery เป็น server แล้วแยกเฉพาะส่วนที่เลื่อนได้เป็น client component อีกชิ้น ไม่ต้องย้ายทั้ง gallery
ส่งเนื้อหาจาก server เข้าไปใน client component ผ่าน children
บางครั้ง client component ต้อง "ห่อ" เนื้อหาไว้ เช่น modal ที่เปิดปิดได้ แท็บ หรือ accordion ตัวห่อต้องมี state ว่าเปิดหรือปิด แต่เนื้อหาข้างในอาจเป็นข้อมูลที่ดึงจากฐานข้อมูล ถ้า import เนื้อหาเข้าไปใน modal ตรง ๆ เนื้อหานั้นก็กลายเป็น client ไปด้วย
ทางที่ Next.js แนะนำคือให้ตัวห่อรับ children แล้วให้ Server Component ที่อยู่สูงกว่าเป็นคนประกอบ
// ก่อน: modal import ตะกร้าเข้าไปเอง ตะกร้าจึงเป็น client และต้อง fetch เอง
'use client';
import { useState } from 'react';
import { CartSummary } from './cart-summary';
export function CartModal() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>ดูตะกร้า</button>
{open && <dialog open><CartSummary /></dialog>}
</>
);
}
// หลัง: modal รับ children ตะกร้ายังเป็น Server Component ที่ดึงข้อมูลเองได้
// cart-modal.tsx
'use client';
import { useState, type ReactNode } from 'react';
export function CartModal({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>ดูตะกร้า</button>
{open && <dialog open>{children}</dialog>}
</>
);
}
// header.tsx (Server Component)
import { CartModal } from './cart-modal';
import { CartSummary } from './cart-summary';
export function Header() {
return (
<header>
<CartModal>
<CartSummary />
</CartModal>
</header>
);
}
CartSummary ใน header เป็น async ได้ อ่าน cookie ได้ และไม่ไปอยู่ใน bundle ของ CartModal เลย ทั้งที่ตอนแสดงผลอยู่ข้างใน dialog เหมือนเดิม
ใช้ได้กับ props อื่นด้วย ไม่จำกัดแค่ children เช่น <Tabs overview={<Overview />} reviews={<Reviews />} /> ทุก prop ที่รับเป็น ReactNode คือช่องให้ server เสียบเนื้อหาเข้าไปได้
Provider ของ context ก็เป็นตัวอย่างแบบเดียวกัน ตัว provider ต้องเป็น client เพราะใช้ createContext แต่วางไว้ใน layout ที่เป็น server ได้ เพราะ layout ส่ง children เข้าไปให้ วาง provider ให้ลึกที่สุดเท่าที่จะทำได้ เช่น ครอบเฉพาะส่วนหลังร้าน ไม่ต้องครอบ <html> ทั้งเว็บ
ไฟล์ที่แยกออกมาต้องมี 'use client' ของตัวเอง
ตอนแยกไฟล์ client ใหญ่ ๆ ออกเป็นหลายไฟล์ มีกับดักหนึ่งที่เจอบ่อย ไฟล์ใหม่ที่ไม่มี 'use client' ยังทำงานได้ปกติ ตราบใดที่ทุกไฟล์ที่ import เข้าไปเป็น client อยู่แล้ว เพราะไฟล์นั้นอยู่ใน module graph ของ client ตัวที่ import
ปัญหาเกิดเมื่อไฟล์เดิมเหลือแค่บรรทัด re-export และมีไฟล์ server มา import ผ่านทางนั้น
// ก่อน: settings-shell.tsx เป็น client และ re-export provider ที่ย้ายไปอยู่ไฟล์อื่น
'use client';
export { BannersProvider } from './settings-shell/contexts';
// contexts.tsx ไม่มี 'use client' แต่มี createContext และ useState
// layout.tsx (server) import BannersProvider ผ่าน './settings-shell'
// หลัง: ไฟล์ที่ย้ายออกไปประกาศ boundary เอง และ server import จากไฟล์นั้นตรง ๆ
// settings-shell/contexts.tsx
'use client';
export function BannersProvider({ children }: { children: ReactNode }) {
// createContext, useState …
}
// layout.tsx (server)
import { BannersProvider } from '@/components/settings-shell/contexts';
แบบก่อนทำงานได้ก็จริง แต่ความถูกต้องไปพึ่งบรรทัด re-export บรรทัดเดียว ถ้าวันหนึ่งมีคนลบบรรทัดนั้นแล้วเปลี่ยน import ให้ตรงไปที่ไฟล์ใหม่ (ซึ่งดูเป็นการ cleanup ธรรมดา) layout จะ import component ที่มี hook เข้าไปเป็น server component ทันที และไม่มี test ไหนผ่านทางนี้ให้เห็น
กฎสั้น ๆ คือ ไฟล์ใหม่ที่มี hook, state, event handler หรือ context ใส่ 'use client' เองเสมอ แม้วันนี้จะมีแต่ client import อยู่ก็ตาม ส่วนไฟล์ที่มีแค่ฟังก์ชันล้วน ไม่มี hook ไม่มี JSX ที่มี state ให้ปล่อยไว้ไม่ใส่ directive จะได้ใช้ได้ทั้งสองฝั่ง
ใช้ server-only กันโค้ดฝั่ง server หลุดไป client
ทางกลับกันก็สำคัญพอกัน ไฟล์ที่อ่านฐานข้อมูล อ่าน env ที่เป็นความลับ หรือเรียก API ภายใน ควรขึ้นต้นด้วย
import 'server-only';
ถ้ามี client component ไป import ไฟล์นี้ไม่ว่าทางตรงหรือทางอ้อม build จะล้มทันทีพร้อมบอกว่าเส้นทางไหนพาไป ดีกว่าค้นพบทีหลังว่า bundle มีชื่อตารางหรือ key ติดไปด้วย ส่วน type ที่ client ต้องใช้จากไฟล์ฝั่ง server ให้ใช้ import type ซึ่ง TypeScript ลบทิ้งตอน build ไม่ลากไฟล์นั้นเข้า bundle
ส่วนการดึงข้อมูลและ server action แยกออกจาก component ยังไง อยู่ในบทแยก server action และ data access ออกจาก component
ข้อมูลที่ส่งข้าม boundary ต้อง serialize ได้
props ที่ Server Component ส่งให้ client component ต้องแปลงเป็นข้อมูลส่งผ่านเครือข่ายได้ เช่น string, number, object และ array ธรรมดา, Date, Promise หรือ JSX ที่ render แล้ว ฟังก์ชันธรรมดาส่งไม่ได้ ยกเว้น server action
สองเรื่องที่ควรคิดตอนส่ง:
- ส่งเฉพาะ field ที่ใช้ ส่ง row ทั้งแถวจากฐานข้อมูลไปให้ client แสดงแค่ชื่อกับราคา คือส่งทุกคอลัมน์ไปอยู่ใน HTML ของหน้าด้วย รวมถึงคอลัมน์ที่ไม่ควรมีใครเห็น ให้ data layer คืนเฉพาะที่หน้าต้องใช้
- ส่ง id แทน object ใหญ่ ถ้า client ต้องการแค่ระบุว่าสินค้าตัวไหน ส่ง
productIdไปพอ อย่างในBuyBoxข้างบน
เลือกว่าชิ้นไหนเป็น server ชิ้นไหนเป็น client
เวลาแยกหน้าใหญ่ ๆ ออกเป็นชิ้น ให้ถามทีละชิ้นตามตารางนี้ ถ้าตอบว่าไม่ทุกข้อ ชิ้นนั้นเป็น Server Component
| ถามว่า | ถ้าใช่ |
|---|---|
มี useState, useReducer หรือ useEffect ไหม |
client |
มี onClick, onChange หรือ event อื่นไหม |
client |
ต้องใช้ window, localStorage หรือ API ของเบราว์เซอร์ไหม |
client |
ใช้ context ด้วย useContext ไหม |
client |
| อ่านฐานข้อมูล, cookie, header หรือ env ที่เป็นความลับไหม | server เท่านั้น |
| แค่รับ props แล้วแสดงผลไหม | server (ใช้ใน client ก็ได้ถ้าจำเป็น) |
แถวสุดท้ายสำคัญ component แสดงผลล้วน ๆ อย่างการ์ดสินค้าหรือป้ายราคา ไม่มี directive เลยก็ได้ ถ้า Server Component เรียกใช้ ชิ้นนั้นก็ render บน server ถ้า client component import ไปใช้ ก็ render ฝั่ง client ตามไปด้วย ใส่ 'use client' เฉพาะชิ้นที่ต้องเป็น client จริง ๆ
ถ้าชิ้นหนึ่งมีทั้งสองแบบผสมกัน เช่น การ์ดสินค้าที่มีปุ่มกดถูกใจ ให้แยกปุ่มออกมาเป็น client component ตัวเล็ก แล้วการ์ดยังเป็น server เหมือนเดิม
ดึงข้อมูลแยกตามส่วนแล้วให้แต่ละส่วนโหลดเสร็จไม่พร้อมกันได้
พอ page เป็น Server Component แต่ละส่วนของหน้าก็เป็น async ดึงข้อมูลของตัวเองได้ สิ่งที่ต้องระวังคือ await ต่อกันเป็นทอด ๆ หน้าจะช้าเท่ากับผลรวมของทุก query
// ก่อน: รอทีละอย่าง หน้าช้าเท่าผลรวม
export default async function ProductPage({ params }: Props) {
const { id } = await params;
const product = await getProduct(id);
const reviews = await getReviews(id);
const related = await getRelatedProducts(id);
// …
}
// หลัง: ดึงพร้อมกัน ส่วนที่ช้าแยกไปรอใน Suspense ของตัวเอง
export default async function ProductPage({ params }: Props) {
const { id } = await params;
const product = await getProduct(id);
return (
<article>
<ProductHeader product={product} />
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={id} />
</Suspense>
<Suspense fallback={<RelatedSkeleton />}>
<RelatedProducts productId={id} />
</Suspense>
</article>
);
}
// reviews.tsx (Server Component)
export async function Reviews({ productId }: { productId: string }) {
const reviews = await getReviews(productId);
return <ReviewList reviews={reviews} />;
}
ส่วนหัวสินค้าขึ้นก่อน รีวิวกับสินค้าที่เกี่ยวข้องตามมาเมื่อพร้อม Next.js stream HTML ของแต่ละส่วนตามมาเอง ไม่ต้องเขียนโค้ดฝั่ง client เพิ่มเลย ถ้าข้อมูลทุกชุดต้องมาพร้อมกันจริง ๆ ก็ใช้ Promise.all เรียกพร้อมกันแทนการ await ทีละบรรทัด
วิธีนี้ทำได้เพราะ boundary อยู่ต่ำ ถ้าทั้งหน้าเป็น client ทุกส่วนต้องรอ useEffect ยิง request หลัง JavaScript โหลดเสร็จ แล้วค่อยแสดง spinner ทีละอัน
ดูว่าอะไรไปอยู่ใน bundle ของหน้า
ย้าย boundary แล้วควรดูให้เห็นกับตาว่า JavaScript ของหน้าเล็กลงจริง ตั้งแต่ Next.js 16.1 มี bundle analyzer สำหรับ Turbopack ให้ลองใช้ (ยังเป็น experimental)
pnpm next experimental-analyze
หน้าจอจะแสดงว่าแต่ละ route มี module อะไรบ้าง ทั้งฝั่ง server และ client ให้มองหา library ที่ไม่น่าจะอยู่ฝั่ง client เช่น ตัวแปลง markdown, ตัวจัดรูปแบบวันที่ทั้งชุด หรือ SDK ของบริการภายนอก ถ้าเจอ ให้ไล่ย้อนว่า client component ตัวไหน import เข้ามา แล้วตัด boundary ให้ต่ำลงตรงนั้น ถ้าโปรเจกต์ยังใช้ Webpack ใช้ @next/bundle-analyzer แทนได้
เมื่อไหร่ไม่ต้องรีบย้าย boundary
- หน้าหลังร้านที่เป็น client ทั้งหน้าอยู่แล้ว เช่น editor หรือ dashboard ที่ทุกส่วนโต้ตอบได้ การตัด boundary ใหม่ทั้งแอปได้ไม่คุ้มเสีย ทำเฉพาะตอนที่กำลังแยกไฟล์นั้นอยู่แล้ว
- ชิ้นที่ต้องอ่านค่าฝั่ง browser ตอน render เช่น ขนาดหน้าจอหรือ
localStorageชิ้นนั้นต้องเป็น client อยู่ดี แต่ให้ส่วนอื่นรอบ ๆ ยังเป็น server - provider ระดับ layout เปลี่ยนตำแหน่งได้ แต่ห้ามเปลี่ยนสิ่งที่ render ตอน mount ครั้งแรก ไม่อย่างนั้น HTML จาก server กับฝั่ง client จะไม่ตรงกัน แล้วเจอ hydration error
ถ้าย้ายแล้วหน้าตาเปลี่ยน แปลว่าไม่ได้แค่ย้าย boundary แล้ว ให้แยกเป็นอีกงานหนึ่ง
จากงานจริง
ในร้านค้าออนไลน์ Next.js 16 กับ CMS ที่เป็น Next.js admin ใน pnpm monorepo ที่เรา refactor ไปเมื่อเร็ว ๆ นี้ สคริปต์ตรวจโค้ดเจอไฟล์ route ที่ขึ้นต้นด้วย 'use client' สี่ไฟล์ และไฟล์ .tsx ที่ import ตัวต่อฐานข้อมูลอีกสี่ไฟล์ ทุกข้อเกิดจากเหตุผลเดียวกัน คือตอนเขียนครั้งแรกต้องใช้ hook ตัวเดียว เลยใส่ directive ไว้บนสุด แล้วไฟล์ก็โตต่อจากตรงนั้น ตอนแยกหน้ารายละเอียดคำสั่งซื้อ เราย้ายการ์ดแต่ละใบไปไว้ใน _components/ ข้าง ๆ หน้า ตัวหน้ายังเป็น Server Component ดึงข้อมูลเองได้ มีแค่ปุ่มที่กดได้เป็น client ส่วนกับดัก re-export ที่เล่าไว้ข้างบน reviewer ทักไว้ราวสิบกว่าไฟล์ตลอดการแยกไฟล์ทั้งโปรเจกต์ เราจึงตั้งเป็นกฎว่าไฟล์ที่ย้ายออกมาต้องมี directive ของตัวเอง




