Server Component và Client Component trong Next.js

1. Tổng quan

Next.js App Router mặc định render component ở server, khác hẳn với mô hình “tất cả đều client” của Pages Router trước đây. Bài này giúp bạn hiểu rõ Server Component và Client Component khác nhau chỗ nào, khi nào bắt buộc phải dùng "use client", và cách kết hợp hai loại component này trong cùng một cây render mà không dính lỗi. Đọc xong, bạn sẽ tự quyết định được một component nên nằm ở server hay client, và biết cách truyền dữ liệu qua ranh giới đó đúng cách.

2. Server Component là gì

Server Component là loại component mặc định trong App Router (thư mục app/). Nó chạy hoàn toàn trên server, không gửi JavaScript của bản thân nó về browser.

// app/products/page.tsx
async function getProducts() {
  const res = await fetch("https://api.shop.local/products", { cache: "no-store" });
  return res.json();
}

export default async function ProductsPage() {
  const products = await getProducts();

  return (
    <ul>
      {products.map((p: { id: number; name: string }) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}

Vài điểm cần chú ý:

  • ProductsPage là async function — Server Component được phép await trực tiếp, không cần useEffect hay useState để fetch data.
  • Lệnh fetch chạy trên server, dữ liệu API key hay logic nhạy cảm không bao giờ lộ ra browser.
  • Không có dòng "use client" ở đầu file nên Next.js tự hiểu đây là Server Component.

2.1. Vì sao nên ưu tiên Server Component

Server Component giúp giảm bundle JavaScript gửi về client, vì chính component đó (và các import của nó, nếu không bị client hóa) không được đóng gói vào bundle phía browser. Nó cũng truy cập trực tiếp database, file system, hay biến môi trường bí mật mà không cần expose qua API route.

Request → Server render (fetch, DB query) → HTML + RSC payload → Browser

Browser chỉ nhận HTML đã render sẵn cùng một payload mô tả cây component (RSC payload), không nhận mã nguồn JS của Server Component.

3. Client Component là gì

Client Component là component cần chạy ở browser — để dùng state, effect, event handler, hay các Web API như localStorage, window. Bạn khai báo bằng directive "use client" ở đầu file.

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

import { useState } from "react";

export default function LikeButton() {
  const [liked, setLiked] = useState(false);

  return (
    <button onClick={() => setLiked(!liked)}>
      {liked ? "❤️ Đã thích" : "🤍 Thích"}
    </button>
  );
}

Giải thích:

  • "use client" phải là dòng đầu tiên của file (trước cả import), nó đánh dấu ranh giới — mọi thứ import từ file này cũng được bundle cho client.
  • useState chỉ hoạt động được trong Client Component vì cần JavaScript chạy ở browser để quản lý re-render.
  • onClick là event handler — một dấu hiệu chắc chắn bạn cần Client Component.

Lưu ý: "use client" không có nghĩa là component đó chỉ chạy ở client. Next.js vẫn render nó lần đầu ở server (SSR) để có HTML nhanh, sau đó “hydrate” ở browser để component trở nên tương tác.

4. Khi nào bắt buộc dùng Client Component

Có vài trường hợp bạn phải thêm "use client", nếu không sẽ gặp lỗi build hoặc runtime:

Error: useState can not be used in Server Components.

Các trường hợp đó gồm:

  • Dùng React hook: useState, useEffect, useReducer, useContext…
  • Gắn event handler: onClick, onChange, onSubmit…
  • Dùng Web API chỉ có ở browser: window, document, localStorage.
  • Dùng custom hook mà bên trong có các thứ trên.
  • Dùng thư viện chỉ chạy ở client (ví dụ một số charting library dựa vào window).

Ngược lại, nếu component chỉ nhận props rồi render JSX thuần, không hook, không event — cứ để nó là Server Component, dù nó nằm sâu trong cây lồng với Client Component khác.

5. Kết hợp Server và Client Component

Điểm hay nhất của mô hình này là bạn có thể lồng Server Component vào Client Component thông qua children (composition), thay vì import trực tiếp.

// app/components/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)}>Mở chi tiết</button>
      {open && <div className="modal">{children}</div>}
    </>
  );
}
// app/orders/[id]/page.tsx
import Modal from "@/app/components/Modal";

async function getOrder(id: string) {
  const res = await fetch(`https://api.shop.local/orders/${id}`);
  return res.json();
}

export default async function OrderPage({ params }: { params: { id: string } }) {
  const order = await getOrder(params.id);

  return (
    <Modal>
      {/* OrderDetail vẫn là Server Component, chỉ được "truyền vào" như children */}
      <p>Mã đơn: {order.id}</p>
      <p>Tổng tiền: {order.total.toLocaleString("vi-VN")} đ</p>
    </Modal>
  );
}

Giải thích cơ chế:

  • Modal là Client Component quản lý state đóng/mở.
  • Nội dung bên trong (<p>Mã đơn... ) được render ở OrderPage — một Server Component — rồi truyền vào Modal dưới dạng children.
  • Next.js render phần children này ở server trước, sau đó “cắm” kết quả vào vị trí {children} của Client Component khi hydrate. Nhờ vậy, OrderService.getOrder không cần client hóa.

Lưu ý: Bạn không thể import trực tiếp một async Server Component rồi dùng bên trong Client Component (ví dụ import OrderDetail from "./OrderDetail" trong file có "use client"). Next.js sẽ coi OrderDetail bị kéo vào client bundle và báo lỗi vì async function component không hợp lệ ở client. Composition qua children hoặc props là cách đúng.

6. Truyền dữ liệu từ Server Component sang Client Component

Props truyền từ Server Component xuống Client Component phải serializable — tức là có thể biến thành JSON. Bạn không thể truyền function, class instance, hay Date object phức tạp.

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

export default function PriceTag({ amount, currency }: { amount: number; currency: string }) {
  return <span>{amount.toLocaleString("vi-VN")} {currency}</span>;
}
// app/products/[id]/page.tsx
import PriceTag from "@/app/components/PriceTag";

export default async function ProductDetailPage({ params }: { params: { id: string } }) {
  const product = await fetch(`https://api.shop.local/products/${params.id}`).then(r => r.json());

  return (
    <div>
      <h1>{product.name}</h1>
      <PriceTag amount={product.price} currency="VND" />
    </div>
  );
}

Ở đây amount (number) và currency (string) đều là dữ liệu nguyên thủy, serialize được. Nếu bạn thử truyền một hàm callback từ Server Component xuống PriceTag, Next.js sẽ báo lỗi tại build/runtime:

Error: Functions cannot be passed directly to Client Components
unless you explicitly expose it by marking it with "use server".

Muốn gọi logic ở server từ phía client (ví dụ submit form), bạn dùng Server Action — đánh dấu bằng "use server" — thay vì truyền function thường.

7. Server Action — cầu nối ngược lại

Khi Client Component cần gọi lại server (ghi dữ liệu, gọi DB), dùng Server Action thay vì tạo API route riêng.

// app/actions/order-actions.ts
"use server";

export async function cancelOrder(orderId: string) {
  await fetch(`https://api.shop.local/orders/${orderId}/cancel`, { method: "POST" });
}
// app/components/CancelButton.tsx
"use client";

import { cancelOrder } from "@/app/actions/order-actions";
import { useState } from "react";

export default function CancelButton({ orderId }: { orderId: string }) {
  const [pending, setPending] = useState(false);

  async function handleClick() {
    setPending(true);
    await cancelOrder(orderId);
    setPending(false);
  }

  return (
    <button onClick={handleClick} disabled={pending}>
      {pending ? "Đang hủy..." : "Hủy đơn"}
    </button>
  );
}
  • cancelOrder được đánh dấu "use server", Next.js tự sinh một endpoint ẩn để Client Component gọi về server mà bạn không cần viết route handler.
  • CancelButton import và gọi nó như một hàm bình thường — code vẫn đọc tự nhiên dù thực chất là một network call.

8. Viết test cho logic tách biệt

Vì Server Component thường chứa logic fetch/transform dữ liệu, tách phần logic thuần (không JSX) ra để test dễ hơn là test cả component render.

// app/lib/pricing.ts
export function formatPrice(amount: number, currency: string): string {
  if (amount < 0) {
    throw new Error("amount không được âm");
  }
  return `${amount.toLocaleString("vi-VN")} ${currency}`;
}
// app/lib/pricing.test.ts
import { describe, it, expect } from "vitest";
import { formatPrice } from "./pricing";

describe("formatPrice", () => {
  it("định dạng số tiền hợp lệ", () => {
    expect(formatPrice(150000, "VND")).toBe("150.000 VND");
  });

  it("ném lỗi khi amount âm", () => {
    expect(() => formatPrice(-1, "VND")).toThrow("amount không được âm");
  });
});

Nếu dự án dùng Jest + Testing Library cho phần Client Component (ví dụ LikeButton), bạn test như một React component bình thường — Jest không cần biết gì về "use client", vì directive đó chỉ có ý nghĩa với bundler của Next.js, không ảnh hưởng runtime test.

// app/components/LikeButton.test.tsx
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import LikeButton from "./LikeButton";

describe("LikeButton", () => {
  it("đổi trạng thái khi click", () => {
    render(<LikeButton />);
    const button = screen.getByRole("button");

    expect(button).toHaveTextContent("Thích");
    fireEvent.click(button);
    expect(button).toHaveTextContent("Đã thích");
  });
});

9. Lỗi thường gặp

Vài lỗi hay gặp khi mới chuyển sang App Router:

  • Quên "use client" khi dùng hook: lỗi useState can not be used in Server Components — thêm directive vào đầu file chứa hook đó, không phải file gọi nó.
  • Đặt "use client" quá cao trong cây component: nếu bạn đánh dấu cả layout.tsx là client, mọi thứ bên trong (kể cả Server Component con) cũng bị kéo vào client bundle nếu import trực tiếp, làm bundle phình to vô ích. Chỉ nên client hóa component nhỏ nhất cần thiết (ví dụ nút bấm, form input), không phải cả page.
  • Truyền function hoặc object không serialize được qua props từ Server sang Client Component — dùng Server Action ("use server") thay vì truyền callback.
  • Import Server Component bên trong file "use client" rồi mong nó vẫn chạy ở server — không được, phải dùng composition qua children/props.
  • Gọi fetch với secret key trong Client Component — API key sẽ lộ ra bundle JS gửi về browser. Những thao tác cần secret luôn phải nằm ở Server Component hoặc Server Action.

Lưu ý: Không phải cứ “chuyển hết thành Client Component cho giống code cũ” là an toàn. Mỗi Client Component không cần thiết làm tăng kích thước bundle, chậm thời gian tương tác (TTI), và mất luôn lợi ích render ở server.

10. Kết luận

Server Component là mặc định trong App Router, phù hợp cho fetch dữ liệu, truy cập tài nguyên nhạy cảm, và giảm bundle JS. Client Component chỉ cần dùng khi có state, effect, hoặc event handler. Hai loại này kết hợp qua composition (children, props serializable) và Server Action là cầu nối để Client Component gọi lại logic server. Nắm chắc ranh giới này giúp bạn tối ưu bundle size và giữ dữ liệu nhạy cảm an toàn ở phía server.

nextjsreactserver-componentclient-componentapp-router