Tiếng Việt

Home

Server Components and Client Components: what runs where

In the App Router every component is a Server Component until a file says "use client". The docs define that; the real question is what actually reaches the browser. This page builds a small app on next@16.4.4, greps the build output to count the files that hold each piece of code, measures the JS each page ships, and records the build errors the boundary produces, word for word. The code below is the code that ran.

The code on this page is an app that runs: 34/34 checks passed on 2026-11-09. Versions run: next@16.4.4, react@19.3.0, Node.js v26.0.0.

Where the boundary sits

The App Router is server-first. A file without "use client" is a Server Component: no useState, no event handlers, no code sent to the browser. The "use client" directive at the top of a file opens the boundary: that file, and everything it imports, becomes browser code.

The boundary is per file, not per component. Put "use client" in app/components/khung.tsx and that file and its imports go to the browser; app/page.tsx beside it stays on the server. And it only goes one way: a Client Component cannot import a Server Component. To put a server tree inside a client frame, pass it as children — section 5 measures exactly that.

One easy misreading: a Client Component is still rendered to HTML on the server before it hydrates. In this measurement the home page's HTML already contains Bấm: 0, so the first paint needs no JavaScript. "use client" says the code *will* run in the browser, not that it *only* runs there.

That is why the question is not “is this component server or client” but “which part needs interaction”. Leave everything else on the server: it can read the database, environment variables and files, and costs no bytes in the bundle.

Grep the build output: what actually reaches the browser

The sample app has three code files to tell apart. lib/du-lieu.ts opens with import "server-only" — a package with no runtime effect, only a build-time one: if a Client Component imports it, the build fails at once. lib/dung-chung.ts is the opposite: no directive, imported by both sides. components/dem.tsx is the only Client Component.

The measurement does not read bundles by eye: each file carries a marker string, and the script counts how many files in .next/static (sent to the browser) and .next/server (left on the server) contain it. Results on next@16.4.4:

The shared module is in 1 file of .next/static and 19 files of .next/server — true to its name: its code runs in both places, which is why it must have no side effects. The server-only module is in 9 file of the server tree and 0 of the client tree. That is the guarantee server-only buys, and the reason to use it on every file that reads a database or a secret.

Environment variables follow the same rule, and this is where people get it wrong. BI_MAT (no NEXT_PUBLIC_ prefix) is read by a Server Component that prints only its length: 11 ký tự. The value itself appears in 0 files of the whole .next tree — not just the client bundle but the server bundle too, because the code reads it at runtime and never bakes it in. NEXT_PUBLIC_TEN, by contrast, is replaced at build time and appears in 1 file of .next/static. The name states its fate: NEXT_PUBLIC_ is public, permanently, in every bundle you ship.

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`;
}

One page, two bundles: 172.4 KB and 172.0 KB

The home page has one Client Component — a button with state. /tinh has no Client Component at all. The script takes every <script src> in each page's HTML, gzips each file and sums them: 172.4 KB in 7 files for the home page, 172.0 KB in 6 files for /tinh.

The number worth noticing is not the 0.4 KB gap but the 172 KB present on both. Most of that JS is the App Router runtime — what always loads to navigate and hydrate — not your code. A small Client Component adds very little; a Client Component that imports a heavy library adds exactly that library.

So choose by dependency, not by line count. A 40-line search box using useState is close to free. A data table pulling in moment, lodash and a chart library is expensive, and expensive where it imports. One page can leave the table on the server and ship only the filter.

The home page also proves the point from section 1: next build lists / in the prerendered group (the ○ mark), and the HTML it serves already contains Bấm: 0. The Server Component ran at build time; the Client Component was rendered at build time too, and takes over its state in the browser.

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>
  );
}

A page with no Client Component still loads JS

app/tinh/page.tsx imports nothing with "use client". It sends no application code to the browser — and still loads 172 KB, because the layout, the router and Next.js's hydration runtime are client code whatever the page says. Do not expect a “pure server” page to ship 0 KB; the correct expectation is that it adds none of your code to the baseline.

The practical consequence: if the goal is a light page, cut libraries inside Client Components, not the number of components. And if a page needs no interaction, do not add "use client" just to use a helper — leave the helper on the server.

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>
  );
}

A Server Component as the child of a Client Component

A Client Component cannot import a Server Component, but it can receive children. That is the only way — and the right way — to wrap a server tree in a stateful frame. components/khung.tsx is a Client Component with a hide/show button; app/ghep/page.tsx passes it a paragraph rendered by the server.

Measured on the HTML: /ghep returns 200 with Dòng này do máy chủ render: 42 inside the frame. And that string appears in 0 files of .next/static: the child's content does not enter the browser bundle, it travels in the RSC payload. This is the pattern for providers, modals and tabs — the interactive frame outside, the heavy content inside and still on the server.

The price: children must serialise across the boundary, and the frame cannot read the child's data. If the frame needs to know what the child is, pass that data as its own prop instead of trying to import the child.

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>
  );
}

Five build errors, word for word

The boundary is not silent when you cross it — but the message does not always name what you just did. The five below are verbatim from next build on next@16.4.4, each from a one-file change to the sample app.

1. A Client Component imports a server-only module: Error: 'server-only' cannot be imported from a Client Component module. This is the error you *want* — it stops a secret falling into the bundle.

2. Passing a function from a Server Component to a Client Component: Event handlers cannot be passed to Client Component props. A function does not serialise across the boundary. If the prop name does not start with on, the message becomes “Functions cannot be passed directly to Client Components”. The fix: move the handling into the Client Component, or use a Server Function ("use server") and pass that reference.

3. Calling useState in a 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. The bundler fails at build time, not at runtime.

4. An async Client Component with an await before its hooks: the build exits 1, but the message is TypeError: Cannot read properties of null (reading 'useState'), after Error occurred prerendering page "/". The word “async” appears nowhere. A fifth measurement shows the other face of the same problem: the same file, with hooks running before the first await, exits 0 — a green build for a component React does not support. Do not write async Client Components; load data in a Server Component and pass it down, or use useEffect/use.

5. "use client" on app/layout.tsx — a file that exports metadata: You are attempting to export "metadata" from a component marked with "use client", which is disallowed. This is what happens when someone adds "use client" to a layout to use a hook. The fix: keep the layout a Server Component and move the interactive part into its own file.

One rule for choosing

Start with Server Components everywhere, and add "use client" only at the leaves that need interaction: a button, a form, a search box, a tab. Put the directive as low as possible — every file that carries it drags its whole import tree with it.

Three things cannot cross the boundary, so design around them: functions (except Server Functions), class instances, and Date in the shape you might expect. Pass flat data, or let the child fetch on the server.

A Client Component should receive only the fields it renders. Every prop you pass travels in the payload to the browser — even when it is only used to compute a number. That is the same reason server-only earns its place: the boundary does not defend itself, it only splits bundles.

And check with the build output, not with a feeling. Two commands are enough: grep -rl "marker-string" .next/static to see whether a string reaches the browser, and next build to see whether the boundary was crossed. Those are the two measurements this page is built on.

How this page was measured

Every number on this page comes from one script: it copies the sample app to a work directory, installs the pinned versions, builds and runs next start. The check list below is printed from that run.

The result holds for the version named at the top of the page. A new Next.js release does not change this page by itself: we change the pinned version and measure again, and a page whose checks fail is not published.

Checked

  • 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

Measuring script (not public yet): bun scripts/guides/verify-server-client.ts

Related

Sources

Our summary and wording; the code is rewritten from the examples in the Next.js docs. Where they differ, the Next.js docs win.