English

Trang chủ

Server Component và Client Component: cái gì chạy ở đâu

Trong App Router, mọi component là Server Component cho tới khi có "use client". Định nghĩa thì tài liệu đã có; câu hỏi thật là cái gì thật sự xuống trình duyệt. Trang này dựng một ứng dụng nhỏ trên next@16.4.4, rồi grep đầu ra build để đếm tệp chứa từng đoạn mã, đo lượng JS mỗi trang tải, và ghi lại nguyên văn các lỗi build mà ranh giới đó sinh ra. Mã in bên dưới là mã đã chạy.

Mã trên trang này là một ứng dụng chạy được: 34/34 kiểm tra đạt ngày 09/11/2026. Phiên bản đã chạy thử: next@16.4.4, react@19.3.0, Node.js v26.0.0.

Ranh giới nằm ở đâu

App Router mặc định là phía máy chủ. Một tệp không có "use client" là Server Component: không useState, không event handler, không gửi mã xuống trình duyệt. Chỉ thị "use client" đặt ở đầu một tệp mở ranh giới: tệp đó và mọi thứ nó import trở thành mã trình duyệt.

Ranh giới tính theo tệp, không theo component. Đặt "use client" ở app/components/khung.tsx thì khung.tsx và các import của nó xuống trình duyệt; app/page.tsx bên cạnh vẫn ở lại máy chủ. Và nó chỉ đi một chiều: Client Component không import được Server Component. Muốn đặt một cây server vào trong một khung client, truyền nó qua children — mục 5 đo đúng việc đó.

Một điểm dễ đọc sai: Client Component vẫn được render thành HTML ở máy chủ trước khi hydrate. Trong phép đo, HTML của trang chủ chứa sẵn Bấm: 0, nên lần vẽ đầu không cần JavaScript. "use client" nói mã này *sẽ* chạy ở trình duyệt, không nói nó *chỉ* chạy ở đó.

Đó cũng là lý do câu hỏi không phải “component này server hay client” mà “phần nào cần tương tác”. Mọi thứ khác để nguyên phía máy chủ: nó đọc được cơ sở dữ liệu, biến môi trường và tệp, và không tốn byte nào trong bundle.

Grep đầu ra build: cái gì thật sự xuống trình duyệt

Ứng dụng mẫu có ba tệp mã để phân biệt. lib/du-lieu.ts mở đầu bằng import "server-only" — gói này không có tác dụng lúc chạy, chỉ có tác dụng lúc build: nếu một Client Component import nó, build hỏng ngay. lib/dung-chung.ts thì ngược lại: không có chỉ thị nào, và được cả hai phía import. components/dem.tsx là Client Component duy nhất.

Cách đo không cần đọc bundle bằng mắt: mỗi tệp mang một chuỗi nhận dạng riêng, rồi script đếm xem có bao nhiêu tệp trong .next/static (gửi xuống trình duyệt) và .next/server (ở lại máy chủ) chứa chuỗi đó. Kết quả trên next@16.4.4.

Mô-đun dùng chung nằm trong 1 tệp của .next/static và 19 tệp của .next/server — đúng như tên gọi: mã của nó chạy ở cả hai nơi, nên nó phải không có tác dụng phụ. Mô-đun server-only nằm trong 9 tệp phía máy chủ và 0 tệp phía trình duyệt. Đây là điều server-only bảo đảm, và là lý do nên dùng nó cho mọi tệp đọc cơ sở dữ liệu hay khoá bí mật.

Biến môi trường đi theo cùng một quy tắc, và đây là chỗ hay sai nhất. BI_MAT (không có tiền tố NEXT_PUBLIC_) được một Server Component đọc và chỉ in ra độ dài: chuỗi 11 ký tự. Giá trị thật của nó xuất hiện trong 0 tệp của toàn bộ .next — không chỉ bundle trình duyệt, mà cả bundle máy chủ, vì mã đọc nó lúc chạy chứ không nhúng giá trị vào build. Còn NEXT_PUBLIC_TEN thì bị thay bằng giá trị ngay lúc build và có mặt trong 1 tệp của .next/static. Tên biến đã nói rõ số phận của nó: NEXT_PUBLIC_ là công khai, mãi mãi, trong mọi bundle bạn phát hành.

app/lib/du-lieu.ts
import "server-only";

// `server-only` là một gói chỉ có tác dụng lúc build: nếu một Client Component import tệp này,
// build hỏng ngay thay vì để mã máy chủ (và biến môi trường) rơi vào bundle trình duyệt.
export const NHAN_MAY_CHU = "chi-may-chu-2d9b";

/** Biến môi trường không có tiền tố NEXT_PUBLIC_ chỉ đọc được ở phía máy chủ. */
export function doDaiBiMat() {
  return (process.env.BI_MAT ?? "").length;
}
app/lib/dung-chung.ts
// Không có "use client": tệp này được cả Server Component lẫn Client Component import,
// nên mã của nó nằm trong cả hai bundle. Đó là lý do nó phải không có tác dụng phụ.
export const NHAN_DUNG_CHUNG = "dung-chung-8f3a";

export function dinhDang(n: number) {
  return `${new Intl.NumberFormat("vi-VN").format(n)} đồng`;
}

Một trang hai bundle: 172,4 KB và 172,0 KB

Trang chủ có một Client Component — một nút bấm có state. Trang /tinh không có Client Component nào. Script lấy mọi thẻ <script src> trong HTML của từng trang, gzip từng tệp và cộng lại: 172,4 KB trong 7 tệp cho trang chủ, 172,0 KB trong 6 tệp cho /tinh.

Con số đáng chú ý không phải 0,4 KB chênh lệch, mà là 172 KB có mặt ở cả hai. Phần lớn lượng JS đó là runtime của App Router — thứ luôn tải để điều hướng và hydrate — không phải mã của bạn. Một Client Component nhỏ thêm rất ít; một Client Component import một thư viện nặng thì thêm đúng bằng thư viện đó.

Vì vậy hãy chọn theo phụ thuộc, đừng chọn theo số dòng. Một ô tìm kiếm 40 dòng dùng useState gần như miễn phí. Một bảng biểu kéo theo moment, lodash và một thư viện chart thì đắt, và đắt ở chỗ nó import. Cùng một trang có thể để bảng ở phía máy chủ, chỉ đưa ô lọc xuống trình duyệt.

Trang chủ cũng là bằng chứng cho điểm ở mục 1: next build xếp / vào nhóm prerender (ký hiệu ○), và HTML trả về đã có Bấm: 0. Server Component chạy lúc build; Client Component cũng được render lúc build, rồi mới nhận lại state ở trình duyệt.

app/components/dem.tsx
"use client";

import { useState } from "react";
import { NHAN_DUNG_CHUNG, dinhDang } from "../lib/dung-chung";

// Client Component: có state và event handler, nên mã này xuống trình duyệt.
// Biến NEXT_PUBLIC_* được thay bằng giá trị ngay lúc build, ở đây và trong bundle.
const TEN = process.env.NEXT_PUBLIC_TEN ?? "chua-dat";

export function Dem() {
  const [n, setN] = useState(0);
  return (
    <p>
      <button onClick={() => setN(n + 1)}>{`Bấm: ${n}`}</button>{" "}
      <small>{`Tên công khai: ${TEN}; ${dinhDang(n * 1000)}; ${NHAN_DUNG_CHUNG}`}</small>
    </p>
  );
}
app/page.tsx
import { Dem } from "./components/dem";
import { NHAN_DUNG_CHUNG, dinhDang } from "./lib/dung-chung";
import { NHAN_MAY_CHU, doDaiBiMat } from "./lib/du-lieu";

export default function Home() {
  return (
    <main>
      <h1>Server Component và Client Component</h1>
      <p>{`Máy chủ đọc BI_MAT: ${doDaiBiMat()} ký tự`}</p>
      <p>{`Nhãn chỉ có ở máy chủ: ${NHAN_MAY_CHU}`}</p>
      <p>{`Dùng chung: ${NHAN_DUNG_CHUNG}, ${dinhDang(1500000)}`}</p>
      <Dem />
    </main>
  );
}

Trang không có Client Component vẫn tải JS

app/tinh/page.tsx không import gì có "use client". Nó không gửi mã ứng dụng nào xuống trình duyệt — nhưng vẫn tải 172 KB, vì layout, router và runtime hydrate của Next.js là mã client bất kể trang viết gì. Đừng kỳ vọng một trang “thuần server” có 0 KB JS; kỳ vọng đúng là trang đó không thêm mã của bạn vào con số nền.

Hệ quả thực tế: nếu mục tiêu là trang nhẹ, thứ cần cắt là thư viện trong các Client Component, không phải số component. Và nếu một trang không cần tương tác, đừng thêm "use client" chỉ để dùng một tiện ích — hãy để tiện ích đó ở phía máy chủ.

app/tinh/page.tsx
import { NHAN_DUNG_CHUNG } from "../lib/dung-chung";

// Không import gì có "use client": trang này không gửi mã ứng dụng nào xuống trình duyệt.
export default function Tinh() {
  return (
    <main>
      <h1>Trang chỉ có Server Component</h1>
      <p>{`Không có nút bấm, không có state. ${NHAN_DUNG_CHUNG}`}</p>
    </main>
  );
}

Server Component làm con của Client Component

Client Component không import được Server Component, nhưng nó nhận được children. Đó là cách duy nhất — và là cách đúng — để bọc một cây server trong một khung có state. components/khung.tsx là Client Component có một nút ẩn/hiện; app/ghep/page.tsx truyền vào đó một đoạn do máy chủ render.

Đo trên HTML: /ghep trả 200 với Dòng này do máy chủ render: 42 nằm trong khung. Và chuỗi đó xuất hiện trong 0 tệp của .next/static: nội dung con không đi vào bundle trình duyệt, nó đi qua RSC payload. Đây là mẫu để dùng cho những thứ như provider, modal, tab — khung tương tác ở ngoài, nội dung nặng ở trong và vẫn ở phía máy chủ.

Cái giá phải trả: children phải serialize được qua ranh giới, và khung không đọc được dữ liệu của con. Nếu khung cần biết con là gì, hãy truyền dữ liệu đó thành prop riêng thay vì cố import con vào.

app/components/khung.tsx
"use client";

import { useState } from "react";

// Client Component nhận children. Children do máy chủ render rồi được truyền qua,
// nên phần con vẫn là Server Component dù khung cha chạy ở trình duyệt.
export function Khung({ children }: { children: React.ReactNode }) {
  const [mo, setMo] = useState(true);
  return (
    <section>
      <button onClick={() => setMo(!mo)}>{`${mo ? "Ẩn" : "Hiện"} phần con`}</button>
      {mo && children}
    </section>
  );
}
app/ghep/page.tsx
import { Khung } from "../components/khung";

// children của một Client Component vẫn được render ở máy chủ: đây là cách giữ
// một cây lớn ngoài bundle trong khi vẫn bọc nó bằng một khung có state.
export default function Ghep() {
  return (
    <main>
      <h1>Server Component làm con của Client Component</h1>
      <p>Phần dưới do máy chủ render, nhưng nằm trong một khung có state ở trình duyệt.</p>
      <Khung>
        <p>{`Dòng này do máy chủ render: ${6 * 7}`}</p>
      </Khung>
    </main>
  );
}

Năm lỗi build, nguyên văn

Ranh giới này không im lặng khi bị vi phạm — nhưng thông báo không phải lúc nào cũng nói đúng thứ bạn vừa làm. Năm lỗi dưới đây là nguyên văn từ next build trên next@16.4.4, mỗi lỗi từ một thay đổi một tệp trên ứng dụng mẫu.

1. Client Component import mô-đun server-only: Error: 'server-only' cannot be imported from a Client Component module. Đây là lỗi bạn *muốn* có — nó chặn khoá bí mật rơi vào bundle.

2. Truyền một hàm từ Server Component sang Client Component: Event handlers cannot be passed to Client Component props. Hàm không serialize được qua ranh giới. Nếu prop tên không bắt đầu bằng on, thông báo đổi thành “Functions cannot be passed directly to Client Components”. Cách đúng: chuyển phần xử lý vào trong Client Component, hoặc dùng Server Function ("use server") rồi truyền tham chiếu đó.

3. Gọi useState trong Server Component: You're importing a module that depends on useState into a React Server Component module. This API is only available in Client Components. Lỗi đến từ bundler, ngay khi build, không phải lúc chạy.

4. Client Component async có await trước hook: build thoát 1, nhưng thông báo là TypeError: Cannot read properties of null (reading 'useState'), kèm Error occurred prerendering page "/". Không có chữ “async” nào trong lỗi. Phép đo thứ năm cho thấy mặt còn lại của cùng vấn đề: cùng tệp đó, nếu hook chạy trước await đầu tiên, next build thoát 0 — build xanh cho một component mà React không hỗ trợ. Đừng viết Client Component async; nếu cần dữ liệu, tải nó ở Server Component rồi truyền xuống, hoặc dùng useEffect/use.

5. Đặt "use client" lên app/layout.tsx — tệp đang export metadata: You are attempting to export "metadata" from a component marked with "use client", which is disallowed. Đây là lỗi hay gặp khi ai đó thêm "use client" vào layout để dùng một hook. Cách sửa: giữ layout là Server Component, tách phần tương tác ra một tệp riêng.

Một quy tắc để chọn

Bắt đầu bằng Server Component cho mọi thứ, và chỉ thêm "use client" ở lá cần tương tác: nút, form, ô tìm kiếm, tab. Đặt chỉ thị càng thấp càng tốt — mỗi tệp có nó kéo theo toàn bộ cây import của tệp đó.

Ba thứ không qua được ranh giới, nên thiết kế quanh chúng: hàm (trừ Server Function), class instance, và Date không serialize theo cách bạn muốn. Truyền dữ liệu phẳng, hoặc để con tự lấy dữ liệu ở phía máy chủ.

Một Client Component chỉ nên nhận những trường nó hiển thị. Mọi prop bạn truyền xuống đều nằm trong payload gửi tới trình duyệt — kể cả khi nó chỉ để tính một con số. Đây là cùng một lý do khiến server-only đáng dùng: ranh giới không tự bảo vệ, nó chỉ tách bundle.

Và hãy kiểm tra bằng đầu ra build, đừng kiểm tra bằng cảm giác. Hai lệnh là đủ: grep -rl "mã-nhận-dạng" .next/static để xem một chuỗi có xuống trình duyệt hay không, và next build để xem ranh giới có bị vi phạm. Đó chính là hai phép đo của trang này.

Cách đo trang này

Mọi con số trên trang đến từ một script: chép ứng dụng mẫu vào một thư mục làm việc, cài đúng các phiên bản đã ghim, build, chạy next start. Danh sách phép đo bên dưới in từ kết quả của lần chạy đó.

Kết quả chỉ đúng với phiên bản ghi ở đầu trang. Next.js ra bản mới thì trang không tự đổi: chúng tôi phải đổi phiên bản đã ghim và đo lại, và phép đo nào không đạt thì trang không được lên site.

Đã kiểm tra

  • next build exits 0: 0
  • the home page is prerendered at build time, so the Server Component ran during the build: prerendered
  • the home page answers 200: 200
  • a Client Component is prerendered to HTML as well, so the first paint needs no JS: present
  • the Server Component read the non-public env var while rendering: present
  • the server-only module rendered on the server: present
  • a module imported by both sides rendered on the server page: present
  • the page prints the formatted value from the shared module: present
  • NEXT_PUBLIC_ value is in the served HTML, so the client Component saw it: present
  • a Server Component passed as children still renders on the server: present
  • the page with no Client Component still serves its text: present
  • JS shipped by / (one Client Component): 172.4 KB in 7 files
  • JS shipped by /tinh (no Client Component): 172.0 KB in 6 files
  • one Client Component makes the page ship more JS than a Server-Component-only page: 172.4 KB vs 172.0 KB
  • the shared module is in the client bundle: 1 file
  • the shared module is also in the server bundle: 19 files
  • the client Component's own code is in the client bundle: 1 file
  • the server-only module is NOT in the client bundle: 0 files
  • the server-only module IS in the server bundle: 9 files
  • the NEXT_PUBLIC_ value is baked into the client bundle: 1 file
  • the non-public secret is nowhere in .next, not even in the server bundle: 0 files
  • the text a Server Component passed as children is not in the client bundle: 0 files
  • a Client Component imports a server-only module: next build exits 1: 1
  • a Client Component imports a server-only module: the error names the rule: cannot be imported from a Client Component module
  • a function prop crosses from a Server Component to a Client Component: next build exits 1: 1
  • a function prop crosses from a Server Component to a Client Component: the error names the rule: Event handlers cannot be passed to Client Component props.
  • a Server Component calls useState: next build exits 1: 1
  • a Server Component calls useState: the error names the rule: depends on `useState` into a React Server Component module. This API is only available in Client Components. To fix, m
  • a Client Component awaits before its hooks: next build exits 1: 1
  • a Client Component awaits before its hooks: the error names the rule: Cannot read properties of null (reading 'useState')
  • an async Client Component whose hooks run before the first await: next build exits 0: 0
  • a layout that exports metadata is marked "use client": next build exits 1: 1
  • a layout that exports metadata is marked "use client": the error names the rule: You are attempting to export "metadata" from a component marked with "use client", which is disallowed. "metadata" must be resolved on the se
  • every variant file is restored byte for byte after its build: restored

Script đo (chưa công khai): bun scripts/guides/verify-server-client.ts

Liên quan

Nguồn

Tóm tắt và diễn giải bằng lời của chúng tôi; mã viết lại từ ví dụ trong tài liệu Next.js. Khi có khác biệt, tài liệu Next.js là chuẩn.