Server Component and Client Component in Next.js

1. Overview

Next.js App Router renders components on the server by default, unlike the “everything is client” model of the previous Pages Router. This article helps you understand how Server Components and Client Components differ, when you must use "use client", and how to combine these two types of components in the same render tree without running into errors. After reading, you’ll be able to decide yourself whether a component should run on the server or the client, and know how to pass data across that boundary correctly.

2. What is a Server Component

A Server Component is the default type of component in the App Router (the app/ directory). It runs entirely on the server and doesn’t send its own JavaScript to the 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>
  );
}

A few points to note:

  • ProductsPage is an async function — Server Components are allowed to await directly. You don’t need useEffect or useState to fetch data.
  • The fetch call runs on the server, so API keys or sensitive logic never leak to the browser.
  • There’s no "use client" line at the top of the file, so Next.js automatically understands this is a Server Component.

2.1. Why you should prefer Server Components

Server Components help reduce the JavaScript bundle sent to the client, because the component itself (and its imports, if not hydrated to the client) aren’t bundled for the browser. It can also directly access the database, file system, or secret environment variables without needing to expose them via an API route.

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

The browser only receives pre-rendered HTML along with a payload describing the component tree (RSC payload); it doesn’t receive the JavaScript source code of the Server Component.

3. What is a Client Component

A Client Component is a component that needs to run in the browser — to use state, effects, event handlers, or Web APIs like localStorage or window. You declare it using the "use client" directive at the top of the 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 ? "❤️ Liked" : "🤍 Like"}
    </button>
  );
}

Explanation:

  • "use client" must be the first line of the file (before any imports). It marks the boundary — everything imported from this file is also bundled for the client.
  • useState only works in Client Components because you need JavaScript running in the browser to manage re-renders.
  • onClick is an event handler — a sure sign you need a Client Component.

Note: "use client" doesn’t mean the component runs only on the client. Next.js still renders it first on the server (SSR) to get HTML quickly, then ‘hydrates’ it in the browser to make the component interactive.

4. When You Must Use a Client Component

There are a few cases where you must add "use client", or you’ll get a build or runtime error:

Error: useState can not be used in Server Components.

These cases include:

  • Using React hooks: useState, useEffect, useReducer, useContext, etc.
  • Attaching event handlers: onClick, onChange, onSubmit, etc.
  • Using Web APIs that only exist in the browser: window, document, localStorage.
  • Using a custom hook that uses any of the above.
  • Using libraries that only run on the client (for example, some charting libraries that rely on window).

Conversely, if a component just accepts props and renders plain JSX, with no hooks and no event handlers — just leave it as a Server Component, even if it’s nested deep within another Client Component.

5. Combining Server and Client Components

The best part of this model is that you can nest Server Components inside Client Components via children (composition), instead of importing them directly.

// 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)}>Open details</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 is still a Server Component, just "passed in" as children */}
      <p>Order ID: {order.id}</p>
      <p>Total: {order.total.toLocaleString("vi-VN")} đ</p>
    </Modal>
  );
}

How it works:

  • Modal is a Client Component managing the open/closed state.
  • The content inside (<p>Order ID... ) is rendered in OrderPage — a Server Component — and then passed into Modal as children.
  • Next.js renders this children part on the server first, then ‘inserts’ the result into the {children} position of the Client Component during hydration. Thanks to this, getOrder doesn’t need to be hydrated to the client.

Note: You cannot directly import an async Server Component and use it inside a Client Component (for example, import OrderDetail from "./OrderDetail" in a file with "use client"). Next.js will treat OrderDetail as being pulled into the client bundle and report an error because async function component is not valid on the client. Composition via children or props is the correct way.

6. Passing Data from Server Components to Client Components

Props passed from Server Components to Client Components must be serializable — meaning they can be converted to JSON. You cannot pass functions, class instances, or complex Date objects.

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

Here, amount (number) and currency (string) are both primitive data that can be serialized. If you try to pass a callback function from a Server Component to PriceTag, Next.js will report an error at build/runtime:

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

To call server logic from the client (for example, submitting a form), use a Server Action — marked with "use server" — instead of passing a regular function.

7. Server Actions — The Bridge Back to the Server

When a Client Component needs to call back to the server (to write data, call the database), use a Server Action instead of creating a separate API route.

// 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 ? "Canceling..." : "Cancel Order"}
    </button>
  );
}
  • cancelOrder is marked with "use server". Next.js automatically creates a hidden endpoint for the Client Component to call back to the server without you writing a route handler.
  • CancelButton imports and calls it like a regular function — the code still reads naturally even though it’s actually a network call.

8. Writing Tests for Isolated Logic

Because Server Components often contain data fetching/transformation logic, separate the pure logic (no JSX) to make testing easier than testing the whole component render.

// app/lib/pricing.ts
export function formatPrice(amount: number, currency: string): string {
  if (amount < 0) {
    throw new Error("amount cannot be negative");
  }
  return `${amount.toLocaleString("vi-VN")} ${currency}`;
}
// app/lib/pricing.test.ts
import { describe, it, expect } from "vitest";
import { formatPrice } from "./pricing";

describe("formatPrice", () => {
  it("formats valid amount", () => {
    expect(formatPrice(150000, "VND")).toBe("150.000 VND");
  });

  it("throws error when amount is negative", () => {
    expect(() => formatPrice(-1, "VND")).toThrow("amount cannot be negative");
  });
});

If your project uses Jest + Testing Library for Client Components (for example, LikeButton), test it like a regular React component — Jest doesn’t need to know anything about "use client", because that directive only has meaning for Next.js’s bundler; it doesn’t affect the test runtime.

// 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("toggles state on click", () => {
    render(<LikeButton />);
    const button = screen.getByRole("button");

    expect(button).toHaveTextContent("Like");
    fireEvent.click(button);
    expect(button).toHaveTextContent("Liked");
  });
});

9. Common Mistakes

A few common mistakes when switching to the App Router:

  • Forgetting "use client" when using hooks: The error useState can not be used in Server Components — add the directive to the top of the file containing the hook, not the file that calls it.
  • Putting "use client" too high in the component tree: If you mark the entire layout.tsx as client, everything inside (including child Server Components) also gets pulled into the client bundle if imported directly, unnecessarily inflating the bundle. You should only hydrate the smallest necessary components to the client (for example, buttons, form inputs), not entire pages.
  • Passing functions or non-serializable objects as props from a Server to a Client Component — use Server Actions ("use server") instead of passing callbacks.
  • Importing a Server Component inside a file with "use client" and expecting it to still run on the server — doesn’t work; you must use composition via children/props.
  • Calling fetch with a secret key in a Client Component — the API key will leak into the JavaScript bundle sent to the browser. Any operation that requires a secret must always be in a Server Component or Server Action.

Note: Just ‘converting everything to Client Components to match old code’ isn’t safe. Each unnecessary Client Component increases the bundle size, slows down time to interactive (TTI), and loses the benefits of server rendering.

10. Conclusion

Server Components are the default in the App Router, suitable for fetching data, accessing sensitive resources, and reducing the JavaScript bundle. Client Components should only be used when you have state, effects, or event handlers. These two types combine via composition (children, serializable props) and Server Actions bridge the way for Client Components to call back to server logic. Understanding this boundary clearly helps you optimize bundle size and keep sensitive data safe on the server.

nextjsreactserver-componentclient-componentapp-router