React & Frontend⏱ 13 phút đọc · 7 thg 10, 2026

React Server Components: hiểu đúng ranh giới server và client trong Next.js

Thêm useState vào page.tsx là lỗi, rải "use client" khắp nơi thì bundle phình to. Bài này giải thích React Server Components cho dev quen React SPA: server vs client component, props qua ranh giới, streaming, Server Functions, lỗi hay gặp và bảo mật.

HOLETEX · POST
RSC
server components

Bạn code React SPA đã quen: useState, useEffect gọi API, loading spinner. Rồi dự án mới dùng Next.js App Router. Bạn mở app/page.tsx, thêm một useState cho ô tìm kiếm, và nhận ngay lỗi build báo hook này chỉ chạy trong Client Component. Cách sửa nhanh nhất là thêm "use client" lên đầu file. Hết lỗi. Vài tuần sau, gần như file nào cũng có "use client", bundle JavaScript không nhỏ hơn bản SPA cũ, và bạn tự hỏi Next.js mang lại gì.

Vấn đề không nằm ở cú pháp mà ở mô hình tư duy. React Server Components (RSC) thay đổi câu hỏi "component này render thế nào" thành "component này chạy ở đâu". Bài này giải thích mô hình đó cho dev đã quen React phía client, kèm code chạy được với Next.js 16.4 và React 19.3 (bản hiện hành tháng 10/2026), các lỗi hay gặp và những điểm bảo mật cần biết.

Nếu bạn chưa rõ React và Next.js khác nhau ở đâu, nên đọc trước bài React vs Next.js.

React Server Components là gì

Docs React định nghĩa Server Component là loại component render trước, trong một môi trường tách biệt với ứng dụng phía client hoặc server SSR. Trong Next.js App Router, môi trường đó là server của bạn, và mọi layout, page mặc định là Server Component.

Điểm khác biệt cốt lõi so với React bạn quen:

  • Server Component chạy trên server, có thể async/await, đọc database, dùng secret. Code của nó không được gửi xuống browser. Kết quả render được đóng gói thành một định dạng gọi là RSC Payload.
  • Client Component là React quen thuộc: state, event handler, effect, API của browser. Code của nó nằm trong bundle JavaScript gửi xuống browser.

Một hiểu lầm phổ biến: Client Component chỉ chạy trên browser. Thực tế, ở lần tải đầu, Next.js vẫn dùng Client Component để prerender HTML trên server, sau đó browser tải JavaScript và hydrate (gắn event handler vào HTML có sẵn). "Client" ở đây nghĩa là "có code gửi xuống browser và có thể tương tác", không phải "chỉ chạy ở browser".

Luồng tải trang đầu tiên trông như sau:

  1. Server render Server Component thành RSC Payload. Payload chứa kết quả render, chỗ giữ vị trí cho Client Component kèm tham chiếu tới file JS của chúng, và props truyền từ server sang client.
  2. Next.js dùng RSC Payload và Client Component để tạo HTML, gửi về để người dùng thấy nội dung ngay.
  3. Browser tải JS của Client Component rồi hydrate để trang tương tác được.

Khi điều hướng sang trang khác trong app, Next.js chỉ lấy RSC Payload (có prefetch), còn Client Component render hoàn toàn ở client.

Một chi tiết nhỏ nhưng hay bị nhầm: không có directive nào để đánh dấu Server Component. Docs React nói rõ "use server" dùng cho Server Functions, không phải để tạo Server Component. Không có directive gì thì mặc định là Server Component.

"use client" là ranh giới module, không phải công tắc bật tắt

"use client" đặt ở dòng đầu file (trên mọi import) để khai báo ranh giới giữa cây module server và cây module client. Khi một file có "use client", mọi module nó import và mọi component nó render trực tiếp đều thuộc bundle client. Vì vậy bạn không cần thêm directive vào từng component con.

Hệ quả thực tế: đặt "use client" càng cao trong cây thì càng nhiều code bị kéo xuống browser. Docs Next.js khuyên chỉ đánh dấu đúng component cần tương tác:

tsx
// app/layout.tsx (Server Component)
import Search from './search' // Client Component
import Logo from './logo'     // Server Component

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <nav>
        <Logo />
        <Search />
      </nav>
      <main>{children}</main>
    </>
  )
}
tsx
// app/search.tsx
'use client'

import { useState } from 'react'

export default function Search() {
  const [q, setQ] = useState('')
  return <input value={q} onChange={(e) => setQ(e.target.value)} />
}

Layout, logo, menu vẫn là Server Component. Chỉ ô tìm kiếm cần state mới trở thành Client Component.

Server Component vẫn nằm được bên trong Client Component

Docs React nhấn mạnh: "use client" chia cây module (ai import ai), không chia cây render (ai bọc ai). Nếu Client Component nhận Server Component qua children hoặc prop khác, Server Component đó vẫn render trên server, vì nó không bị import vào module client:

tsx
// app/ui/modal.tsx
'use client'

import { useState } from 'react'

export default function Modal({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false)
  return (
    <>
      <button onClick={() => setOpen(true)}>Xem giỏ hàng</button>
      {open && <div role="dialog">{children}</div>}
    </>
  )
}
tsx
// app/page.tsx (Server Component)
import Modal from './ui/modal'
import Cart from './ui/cart' // Server Component, đọc giỏ hàng từ DB

export default function Page() {
  return (
    <Modal>
      <Cart />
    </Modal>
  )
}

Đây là pattern "slot": phần vỏ tương tác ở client, phần nội dung nặng dữ liệu ở server. Dùng nó thường xuyên thì bạn ít khi phải đẩy cả một nhánh cây xuống client.

Props đi qua ranh giới phải serializable

Props từ Server Component truyền sang Client Component được đóng gói vào RSC Payload, nên phải là dữ liệu React serialize được.

Truyền đượcKhông truyền được
string, number, bigint, boolean, null, undefinedFunction thường (như onClick định nghĩa ở server)
Array, Map, Set, TypedArray, ArrayBufferInstance của class (trừ các built-in ở cột trái)
Date, plain object có thuộc tính serializableObject có prototype null
PromiseSymbol không đăng ký qua Symbol.for()
JSX (element của Server hoặc Client Component)
Server Function (hàm có "use server")

Hai hệ quả quan trọng:

  1. Không truyền handler từ server xuống. Muốn client gọi logic server, dùng Server Function (phần dưới). Muốn xử lý sự kiện thuần client, định nghĩa handler ngay trong Client Component.
  2. Mọi thứ bạn truyền xuống đều nằm trong payload người dùng nhìn thấy được. Truyền nguyên record user từ database vào một Client Component là tự lộ email, hash mật khẩu hay field nội bộ. Chỉ truyền đúng field UI cần.

Lấy dữ liệu trực tiếp trong Server Component

Đây là khác biệt dễ thấy nhất so với SPA: không cần useEffect + state loading + API route trung gian. Server Component là hàm async, cứ await dữ liệu:

ts
// lib/products.ts
import 'server-only'
import { cache } from 'react'
import { db } from '@/lib/db' // ORM của bạn (Prisma, Drizzle...)

export const getProduct = cache(async (id: string) => {
  const product = await db.product.findUnique({ where: { id } })
  if (!product) return null
  // Trả về DTO tối thiểu, không trả nguyên record
  return {
    id: product.id,
    name: product.name,
    priceVnd: product.priceVnd,
    inStock: product.stock > 0,
  }
})
tsx
// app/products/[id]/page.tsx
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { getProduct } from '@/lib/products'
import AddToCartButton from './add-to-cart-button'
import Reviews from './reviews'

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const product = await getProduct(id)
  if (!product) notFound()

  return (
    <main>
      <h1>{product.name}</h1>
      <p>{product.priceVnd.toLocaleString('vi-VN')} đ</p>
      <AddToCartButton productId={product.id} disabled={!product.inStock} />
      <Suspense fallback={<p>Đang tải đánh giá...</p>}>
        <Reviews productId={product.id} />
      </Suspense>
    </main>
  )
}
tsx
// app/products/[id]/loading.tsx
export default function Loading() {
  return <p>Đang tải sản phẩm...</p>
}

Vài điểm đáng chú ý trong đoạn code:

  • params trong Next.js 16 là Promise, phải await.
  • import 'server-only' khiến build báo lỗi nếu ai đó lỡ import file này vào Client Component.
  • cache của React giúp nhiều component gọi getProduct(id) trong cùng một request chỉ chạy query một lần. Với fetch, các request giống hệt nhau đã được memoize sẵn. Cả hai chỉ có tác dụng trong phạm vi một request, không phải cache giữa các request.
  • App tạo bằng create-next-app từ Next.js 16.4 bật sẵn Cache Components. Ở chế độ này, đọc params động hoặc query không cache bên ngoài <Suspense> sẽ bị báo lỗi khi build. File loading.tsx ở trên tự bọc page trong một <Suspense>, nên đoạn code chạy được ở cả hai chế độ.
  • Trong Next.js 16, fetch không được cache mặc định. Muốn cache kết quả giữa các request, dùng directive 'use cache' của Cache Components. Từ Next.js 16.4, app mới tạo bằng create-next-app đã bật sẵn (cacheComponents: true và partialPrefetching: true trong next.config.ts); app cũ thì tự thêm hai option này. Chi tiết có ở docs caching của Next.js.

Khi có nhiều request độc lập, đừng await lần lượt, vì như vậy tạo waterfall. Khởi động cùng lúc rồi dùng Promise.all:

tsx
const [artist, albums] = await Promise.all([getArtist(username), getAlbums(username)])

Streaming với Suspense

Nếu một phần dữ liệu chậm (ví dụ đánh giá sản phẩm), không có lý do gì bắt cả trang chờ. Trong ví dụ trên, <Reviews> nằm trong <Suspense>: phần tên, giá, nút mua được gửi về ngay, còn phần đánh giá được stream tới sau khi query xong, thay thế fallback.

tsx
// app/products/[id]/reviews.tsx (Server Component)
import { getReviews } from '@/lib/reviews'

export default async function Reviews({ productId }: { productId: string }) {
  const reviews = await getReviews(productId)
  return (
    <ul>
      {reviews.map((r) => (
        <li key={r.id}>{r.content}</li>
      ))}
    </ul>
  )
}

Next.js có hai cách stream:

  • loading.tsx: đặt cạnh page.tsx, tự bọc cả page trong một <Suspense>. Nhanh, nhưng thô: cả page chờ chung một fallback. Lưu ý docs Next.js ghi rõ nếu chính layout đọc dữ liệu runtime (như cookies()) thì loading.tsx cùng segment không che được layout đó.
  • <Suspense> thủ công: đặt sát chỗ dữ liệu chậm, chi tiết hơn. Docs Next.js khuyến nghị cách này khi làm việc với dữ liệu runtime hoặc không cache.

Còn một biến thể: tạo promise trên server, đọc nó ở client bằng API use. Server Component không await, chỉ truyền promise xuống (promise nằm trong danh sách serializable ở trên):

tsx
// Server Component
const commentsPromise = getComments(postId) // không await
return (
  <Suspense fallback={<p>Đang tải bình luận...</p>}>
    <Comments commentsPromise={commentsPromise} />
  </Suspense>
)
tsx
// comments.tsx
'use client'

import { use } from 'react'

export default function Comments({
  commentsPromise,
}: {
  commentsPromise: Promise<{ id: string; text: string }[]>
}) {
  const comments = use(commentsPromise)
  return comments.map((c) => <p key={c.id}>{c.text}</p>)
}

Cách này hợp khi Client Component cần dữ liệu để tương tác (lọc, sắp xếp phía client). Docs React lưu ý: Client Component không thể await trong lúc render, nên phải dùng use. Và đừng tạo promise ngay trong lúc render của Client Component (như use(fetch(...))), vì mỗi lần render lại tạo promise mới, fallback sẽ hiện đi hiện lại.

"use server" và Server Functions

Server Functions là hàm async chạy trên server nhưng gọi được từ client. Từ tháng 9/2024, React gọi chung mọi hàm có "use server" là Server Functions; khái niệm Server Actions chỉ còn dùng cho những Server Function truyền vào prop action hoặc gọi trong một action. Docs Next.js vẫn dùng chữ Server Actions khá nhiều, nên bạn sẽ gặp cả hai tên.

Tiếp tục ví dụ giỏ hàng:

ts
// app/products/[id]/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { auth } from '@/lib/auth' // hàm lấy session của bạn
import { addItemToCart } from '@/lib/cart'

type Result = { ok: true } | { ok: false; error: string }

export async function addToCart(productId: string): Promise<Result> {
  // Server Function là endpoint công khai: luôn kiểm tra lại auth ở đây
  const session = await auth()
  if (!session?.user) return { ok: false, error: 'Bạn cần đăng nhập' }

  // Tham số đến từ client, không tin được: validate trước khi dùng
  if (typeof productId !== 'string' || productId.length === 0) {
    return { ok: false, error: 'Dữ liệu không hợp lệ' }
  }

  await addItemToCart(session.user.id, productId)
  revalidatePath('/cart')
  return { ok: true } // chỉ trả về thứ UI cần
}
tsx
// app/products/[id]/add-to-cart-button.tsx
'use client'

import { useState, useTransition } from 'react'
import { addToCart } from './actions'

export default function AddToCartButton({
  productId,
  disabled,
}: {
  productId: string
  disabled: boolean
}) {
  const [isPending, startTransition] = useTransition()
  const [message, setMessage] = useState<string | null>(null)

  return (
    <div>
      <button
        disabled={disabled || isPending}
        onClick={() =>
          startTransition(async () => {
            const result = await addToCart(productId)
            setMessage(result.ok ? 'Đã thêm vào giỏ' : result.error)
          })
        }
      >
        {isPending ? 'Đang thêm...' : 'Thêm vào giỏ'}
      </button>
      {message && <p>{message}</p>}
    </div>
  )
}

Lưu ý: cập nhật state sau await trong transition sẽ không còn được tính là transition. Nếu cần, bọc setMessage trong một startTransition nữa, hoặc dùng useActionState.

Với form, bạn có thể truyền Server Function thẳng vào <form action={...}> và dùng useActionState để lấy trạng thái pending và lỗi trả về.

Docs Next.js ghi rõ Server Action gọi được bằng một POST request trực tiếp, không chỉ qua UI của bạn. Việc trang đã kiểm tra đăng nhập không bảo vệ action nằm trong trang đó. Mỗi action phải tự kiểm tra authentication (đã đăng nhập chưa) và authorization (có quyền với tài nguyên này không).

Lỗi hay gặp và cách sửa

Triệu chứngNguyên nhânCách sửa
Báo lỗi khi dùng useState, useEffect, onClick trong pagePage là Server ComponentTách phần tương tác ra một component nhỏ có "use client"
Lỗi không truyền được function sang Client ComponentHandler định nghĩa ở serverĐịnh nghĩa handler trong Client Component, hoặc dùng Server Function
Thư viện UI báo lỗi khi dùng trong pageThư viện dùng hook nhưng chưa khai báo "use client"Tạo file wrapper có "use client" rồi re-export component đó
Biến môi trường ra chuỗi rỗng ở clientChỉ biến có tiền tố NEXT_PUBLIC_ mới vào bundle clientGiữ logic dùng secret ở server, thêm import 'server-only'
Bundle JS to như SPA cũ"use client" đặt ở layout hoặc page gốcĐẩy ranh giới xuống lá, dùng pattern children
Context không dùng được trong Server ComponentServer Component không tạo hoặc đọc được context (createContext, useContext là API client)Tạo context trong file "use client"; từ React 19.3 có thể render thẳng <MyContext value={...}> trong Server Component, còn đọc context thì làm trong Client Component
Trang chậm dù từng query nhanhawait lần lượt, tạo waterfallPromise.all, hoặc tách phần chậm vào <Suspense>

Hai lỗi cần giải thích thêm.

Context provider. Pattern mà docs Next.js hiện hành hướng dẫn là tạo Provider trong Client Component rồi render nó trong layout:

tsx
// app/theme-provider.tsx
'use client'

import { createContext } from 'react'

export const ThemeContext = createContext<'light' | 'dark'>('light')

export default function ThemeProvider({
  theme,
  children,
}: {
  theme: 'light' | 'dark'
  children: React.ReactNode
}) {
  return <ThemeContext value={theme}>{children}</ThemeContext>
}

Từ React 19, <ThemeContext value={...}> dùng trực tiếp làm provider, không cần .Provider. Docs Next.js khuyên bọc provider sâu nhất có thể, ví dụ chỉ bọc {children} thay vì cả <html>, để Next.js tối ưu phần tĩnh tốt hơn. Từ React 19.3 (đi kèm Next.js 16.4), Server Component còn có thể render trực tiếp một Context import từ module "use client", không cần component Provider riêng. Ví dụ layout có thể await getCurrentUser() rồi render <UserContext value={user}>{children}</UserContext>. Server Component vẫn không tạo hay đọc được context, chỉ cung cấp giá trị. Docs Next.js hiện vẫn minh họa cách wrapper ở trên, cả hai cách đều dùng được.

Import nhầm code server vào client. Một file lib/data.ts dùng process.env.API_KEY có thể bị import vào Client Component mà không báo lỗi gì, chỉ âm thầm chạy sai vì biến môi trường bị thay bằng chuỗi rỗng. Thêm import 'server-only' ở đầu file để biến lỗi âm thầm thành lỗi build. Chiều ngược lại có client-only cho code dùng window.

Nếu bạn đang tối ưu bundle và re-render cho phần Client Component, các kỹ thuật trong bài tối ưu performance React vẫn áp dụng nguyên vẹn.

Bảo mật: RSC không phải chỉ là chuyện render

RSC dựa trên một giao thức riêng để gửi dữ liệu giữa client và server. Giao thức này đã có những lỗ hổng nghiêm trọng, và bạn cần biết chúng để không chạy phiên bản cũ.

  • 3/12/2025: React công bố CVE-2025-55182 (thường gọi là React2Shell), CVSS 10.0, cho phép thực thi code từ xa không cần xác thực do lỗi giải mã payload gửi tới endpoint Server Function. Lỗi nằm trong react-server-dom-webpack, react-server-dom-parcel, react-server-dom-turbopack các bản 19.0, 19.1.0, 19.1.1, 19.2.0. Theo React, app không có Server Function nào vẫn có thể bị ảnh hưởng nếu hỗ trợ RSC. Không chỉ Next.js: React Router, Waku, @parcel/rsc, @vitejs/plugin-rsc, Redwood SDK cũng nằm trong danh sách.
  • 11/12/2025 và 26/1/2026: thêm các lỗi DoS (CVE-2025-55184, CVE-2025-67779, CVE-2026-23864) và lộ source code (CVE-2025-55183). Bản vá đợt 11/12 chưa đủ.
  • Trong năm 2026, React tiếp tục công bố thêm lỗi DoS ở endpoint Server Function (CVE-2026-23870 tháng 5, CVE-2026-44907 tháng 7). Tại thời điểm 2026-10-08, các bản an toàn của nhánh cũ là 19.0.8, 19.1.9, 19.2.8 trở lên, hoặc React 19.3. Danh sách này còn có thể thay đổi, hãy kiểm tra advisory mới nhất của React trước khi chốt phiên bản.
  • Từ tháng 7/2026, Next.js chuyển sang phát hành bản vá bảo mật định kỳ có báo trước. Đợt tháng 7 có các lỗi liên quan trực tiếp tới Server Actions (DoS, SSRF, lộ ID của Server Function). Đợt 30/9/2026 ra 16.3.8 / 15.5.27, vá 7 lỗi (1 high, 5 medium, 1 low), trong đó có lỗi rò dữ liệu giữa các request khi dùng 'use cache'. Next.js cũng thông báo còn 1 lỗi critical và 1 lỗi high đang chờ phối hợp với upstream, sẽ vá ở bản sau. Riêng bản 16.3.6 (22/9) vá lỗi RCE trong ImageResponse của next/og, không liên quan RSC. Tại thời điểm 2026-10-08, hãy kiểm tra advisory mới nhất trên blog Next.js trước khi kết luận app của bạn đã an toàn.

Checklist thực tế:

  1. Chạy npm ls next react để biết mình đang ở bản nào (Next.js đóng gói sẵn phần RSC bên trong package next; framework khác thì kiểm tra thêm các package react-server-dom-*). Cập nhật lên bản vá mới nhất: với Next.js 16 là 16.4.x (hoặc tối thiểu 16.3.8 nếu chưa nâng minor được), với Next.js 15 là 15.5.27 trở lên. Next.js 16.x đang là Active LTS, 15.x là Maintenance LTS.
  2. Theo dõi trang blog và tag security của Next.js, vì lịch vá giờ được báo trước.
  3. Mỗi Server Function: kiểm tra auth, kiểm tra quyền sở hữu tài nguyên, validate input, chỉ trả về dữ liệu tối thiểu.
  4. Gom truy cập dữ liệu vào một Data Access Layer có import 'server-only', trả về DTO thay vì record thô. Đây là cách docs Next.js khuyến nghị cho dự án mới.
  5. Muốn thêm một lớp chặn, có thể bật experimental.taint để dùng Taint API của React, nhưng docs ghi rõ đây chỉ là lớp bổ sung, không thay cho việc lọc dữ liệu.

Khi nào dùng Server Component, khi nào dùng Client Component

Nhu cầuChọn
Đọc database, gọi API nội bộ, dùng secretServer Component
Hiển thị nội dung chủ yếu tĩnh: bài viết, danh sách, trang sản phẩmServer Component
Thư viện nặng chỉ dùng để render (markdown, highlight code)Server Component
State, event handler, form có phản hồi tức thìClient Component
useEffect, API browser (localStorage, window, geolocation)Client Component
Custom hook, Context consumerClient Component
Dữ liệu thay đổi liên tục theo thao tác người dùng (infinite scroll, polling)Client Component, thường kèm thư viện như TanStack Query

Nguyên tắc chung: mặc định để server, chỉ xuống client khi thật sự cần tương tác, và khi xuống client thì xuống ở mức component nhỏ nhất.

Tóm lại

React Server Components không thay thế React bạn đã biết, mà chia ứng dụng thành hai môi trường với hai bộ khả năng khác nhau. Có ba ý nên nắm chắc: "use client" là ranh giới module chứ không phải công tắc, mọi thứ qua ranh giới phải serializable và ai cũng đọc được, còn Server Function là endpoint công khai cần bảo vệ như mọi API khác.

Phần khó của RSC nằm ở chỗ bạn phải hiểu rõ React ở tầng dưới: component render khi nào, state sống ở đâu, Suspense hoạt động ra sao. Nếu muốn xây chắc nền đó trước khi đi sâu vào Next.js, khóa React PRO của HoleTex dạy React theo hướng thực chiến, từ hook, state tới kiến trúc ứng dụng.

Bài liên quan

Nguồn tham khảo: Server Components (react.dev), 'use client' (react.dev), Server Functions (react.dev), use (react.dev), React 19.3 (react.dev), Critical Security Vulnerability in React Server Components (react.dev), Denial of Service and Source Code Exposure in RSC (react.dev), Server and Client Components (nextjs.org), Fetching Data (nextjs.org), Data Security (nextjs.org), Next.js 16.4 (nextjs.org), Caching (nextjs.org), July 2026 Security Release (nextjs.org), Security Update September 22, 2026 (nextjs.org), September 2026 Security Release (nextjs.org), GHSA-wx67-qw84-cm4g (github.com), CVE-2026-23870 (osv.dev), Next.js support policy (nextjs.org). Cập nhật 2026-10-08.

Thấy hay? Chia sẻ