Skip to content

Server Component và Client Component trong Next.js: Những quy tắc cần nhớ để không mắc sai lầm

Sẽ rất rủi ro nếu bạn chưa nắm rõ ranh giới giữa Server Component và Client Component trong Next.js.
Bạn đang xây dựng một ứng dụng Next.js, mọi thứ trông có vẻ ổn. Rồi bỗng dưng bạn thấy màn hình trắng xóa trong vài giây mỗi khi trang load — navbar không có user, avatar trống rỗng, rồi mới hiện ra. Hoặc tệ hơn, bạn vô tình để lộ một database query xuống phía client mà không hề hay biết.
Vấn đề không nằm ở logic — mà nằm ở chỗ bạn chưa nắm rõ ranh giới giữa Server Component và Client Component trong Next.js.

Server Component và Client Component là gì?

Next.js App Router có hai loại component:
Server Component (RSC): render trên server, có thể truy cập database, đọc file, gọi API nội bộ trực tiếp. Không có state, không có event handler. Đây là mặc định khi bạn tạo một file component mới.
Client Component: render trên trình duyệt, có thể dùng useState, useEffect, event handler (onClick, onChange…). Phải khai báo "use client" ở đầu file.
// ServerComponent.tsx — không cần khai báo gì, đây là mặc định
export default async function UserProfile() {
const user = await db.user.findUnique(...) // ✅ truy cập DB trực tiếp
return <div>{user.name}</div>
}
// ClientComponent.tsx
"use client"

export default function Dropdown() {
const [open, setOpen] = useState(false) // ✅ dùng state bình thường
return <button onClick={() => setOpen(!open)}>Menu</button>
}

Quy tắc 1: "use client" lây lan xuống, không lây ngược lên

Đây là điều dễ gây nhầm lẫn nhất. Khi bạn đặt "use client" vào một file, tất cả những gì được import bên trong file đó cũng trở thành client, dù chúng không có directive này.
// ❌ Vấn đề tiềm ẩn
"use client"

import HeavyChart from "./HeavyChart" // → trở thành client
import UserService from "./UserService" // → trở thành client, dù bạn không muốn
Ngược lại, children hoặc props được truyền từ bên ngoài vào thì không bị ảnh hưởng — chúng vẫn giữ nguyên là Server Component.
// ✅ Layout server component
export default async function Layout({ children }) {
const user = await getUser()
return <Navbar user={user}>{children}</Navbar>
// ↑ children vẫn là server, không bị Navbar "lây"
}
Quy tắc nhớ nhanh: "use client" chỉ lây qua import, không lây qua props hay children.

Quy tắc 2: Đẩy "use client" xuống càng sâu càng tốt

Một sai lầm phổ biến là đặt "use client" ở component cha cấp cao — chỉ vì cần một button có onClick. Điều này vô tình biến cả một cây component lớn thành client-side.
// ❌ Không nên — toàn bộ ProductPage thành client chỉ vì một button
"use client"

export default function ProductPage() {
const [added, setAdded] = useState(false)

return (
<div>
<ProductInfo /> {/* không cần client */}
<ReviewList /> {/* không cần client */}
<button onClick={() => setAdded(true)}>Add to cart</button>
</div>
)
}
// ✅ Nên làm — tách phần interactive ra riêng
// AddToCartButton.tsx
"use client"
export default function AddToCartButton() {
const [added, setAdded] = useState(false)
return <button onClick={() => setAdded(true)}>Add to cart</button>
}

// ProductPage.tsx — vẫn là server component
export default async function ProductPage() {
return (
<div>
<ProductInfo />
<ReviewList />
<AddToCartButton /> {/* chỉ mình nó là client */}
</div>
)
}

Quy tắc 3: Data fetching thuộc về server, interactivity thuộc về client

Đây là nguyên tắc cốt lõi nhất. Hãy nghĩ theo hướng:
Server component = cái gì được hiển thị (data, structure, layout)
Client component = nó hoạt động như thế nào (state, animation, event)
Áp dụng vào Navbar — một ví dụ rất thực tế:
// ❌ Sai — fetch user bên trong client component
"use client"

export default function Navbar() {
const [user, setUser] = useState(null)

useEffect(() => {
fetch("/api/me").then(res => res.json()).then(setUser)
// Vấn đề:
// 1. Trang load xong mới fetch → navbar trống trong vài giây
// 2. Phải tạo thêm một API route chỉ để phục vụ chỗ này
// 3. Khó cache hơn
}, [])

return <nav>{user ? user.name : "..."}</nav>
}
// ✅ Đúng — server component lo data, client component lo UI
// layout.tsx (server)
export default async function Layout({ children }) {
const user = await getUser() // fetch một lần, ngay trên server
const navItems = await getNavItems()

return (
<>
<Navbar user={user} items={navItems} />
{children}
</>
)
}

// Navbar.tsx (client)
"use client"

export default function Navbar({ user, items }) {
const [menuOpen, setMenuOpen] = useState(false)
// Chỉ xử lý UI, không fetch gì cả
return (
<nav>
<span>{user.name}</span>
<button onClick={() => setMenuOpen(!menuOpen)}></button>
{menuOpen && <NavItems items={items} />}
</nav>
)
}

Quy tắc 4: Dùng console.log để xác nhận component đang chạy ở đâu

Vì Next.js trừu tượng hóa nhiều thứ, đôi khi bạn không chắc component mình đang ở đâu. Thủ thuật đơn giản:
export default function SomeComponent() {
console.log("Tôi đang chạy ở đây") // log này xuất hiện ở đâu?
return <div>...</div>
}
Log xuất hiện ở terminal → Server Component, render động trên server
Log xuất hiện ở browser console → Client Component
Không thấy log ở đâu → trang đã được cache hoặc pre-render

Do & Don’t

✅ Nên
❌ Không nên
Data fetching
Trong Server Component, truyền xuống qua props
useEffect + fetch bên trong Client Component
"use client"
Đặt ở component leaf (button, dropdown, modal)
Đặt ở component cha cấp cao chỉ vì một interaction nhỏ
DB / secret
Chỉ dùng trong Server Component
Import vào Client Component dù “chỉ để dùng thử”
Modal, Drawer
Client Component nhận children từ server
Import và fetch data bên trong modal
Kiểm tra
console.log để xác nhận vị trí render
Đoán mò rồi deploy
There are no rows in this table

Tóm lại

Ranh giới giữa Server và Client Component không phức tạp — nhưng rất dễ vi phạm nếu không để ý. Ba điều cần nhớ:
"use client" lây qua import, không lây qua children/props
Đẩy "use client" xuống sâu nhất có thể — chỉ bọc phần thực sự cần interactivity
Server lo data, client lo UI — đừng fetch trong useEffect khi có thể fetch trên server
Nắm được ba điều này, bạn sẽ tránh được phần lớn các lỗi phổ biến khi làm việc với Next.js App Router.
Want to print your doc?
This is not the way.
Try clicking the ··· in the right corner or using a keyboard shortcut (
CtrlP
) instead.