React Server Components (RSC) introduced a new mental model: instead of every component shipping JavaScript to the browser, some components now render entirely on the server and send only HTML (plus a compact serialized description) to the client. If you’ve worked with Next.js App Router, you’ve already been using Server Components by default — every component is a Server Component unless you explicitly opt out. That default is powerful, but it also means the old habit of “just make it a component” no longer works without thinking about where the code actually runs.
This guide walks through the practical differences, how to decide which type to use, and the mistakes that trip up most teams migrating from the old Pages Router or from Create React App.
What actually changes at runtime
A traditional React app ships as one big client-side bundle. The server (if there is one) renders an initial HTML shell, then the browser downloads the JavaScript, hydrates, and takes over. Every component’s code, including logic that never needs to run in the browser, ends up in that bundle.
Server Components flip that. They run once, on the server, during the request. They can read files, query a database, or call an internal API directly — no fetch endpoint required — and their output is serialized and streamed to the client as part of the React tree. Crucially, none of their code or its dependencies are sent to the browser. If a Server Component imports a 200KB parsing library, that library never touches your client bundle.
Client Components are the React you already know: they run in the browser, can use useState, useEffect, event handlers, and browser-only APIs, and they hydrate on the client after the initial HTML loads.
The rule of thumb
Default to Server Components. Only add "use client" when a component genuinely needs one of these:
- State or effects (
useState,useReducer,useEffect) - Event handlers (
onClick,onChange, etc.) - Browser-only APIs (
window,localStorage,IntersectionObserver) - Custom hooks that depend on any of the above
- Third-party libraries that assume a browser environment
Everything else — data fetching, layout, formatting, markdown rendering, most presentational UI — can stay a Server Component.
A concrete example
Say you’re building a product page that fetches data and includes an “Add to cart” button. The naive approach makes the whole page a Client Component so the button’s onClick works. That drags the data-fetching logic, any formatting utilities, and React itself into the client bundle unnecessarily.
Instead, keep the page as a Server Component and isolate interactivity in a small child:
// app/products/[id]/page.tsx (Server Component by default)
import { AddToCartButton } from "./add-to-cart-button";
import { getProduct } from "@/lib/products";
export default async function ProductPage({
params,
}: {
params: { id: string };
}) {
const product = await getProduct(params.id);
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>${product.price.toFixed(2)}</p>
<AddToCartButton productId={product.id} />
</article>
);
}
// app/products/[id]/add-to-cart-button.tsx
"use client";
import { useState } from "react";
import { addToCart } from "@/lib/cart-actions";
export function AddToCartButton({ productId }: { productId: string }) {
const [pending, setPending] = useState(false);
async function handleClick() {
setPending(true);
await addToCart(productId);
setPending(false);
}
return (
<button onClick={handleClick} disabled={pending}>
{pending ? "Adding..." : "Add to cart"}
</button>
);
}
getProduct, the formatting logic, and the async data fetch all stay server-side. Only the small, focused button component ships JavaScript to the browser.
Passing data across the boundary
Props passed from a Server Component into a Client Component must be serializable — think JSON: strings, numbers, plain objects, arrays. You can’t pass functions, class instances, or React context values directly. This trips people up when they try to pass a database client or a Date-heavy object straight through:
// Won't work: functions aren't serializable across the RSC boundary
<ClientWidget onSave={() => db.save(data)} />
// Works: pass primitive data, let the client component call a Server Action
<ClientWidget itemId={data.id} />
For the “call server logic from a client event handler” case, use a Server Action instead of a prop function:
// lib/cart-actions.ts
"use server";
export async function addToCart(productId: string) {
// runs on the server, safe to touch the database directly
await db.cart.add(productId);
}
Composition: Server Components can wrap Client Components
A common misconception is that once you go client-side, everything below it must also be client-side. That’s not true for children passed via props.children. A Client Component can accept a Server Component as a child, because the parent doesn’t need to know how the child renders — it just needs a React node to slot in:
// Client Component, but its children can still be Server Components
"use client";
export function Modal({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return open ? <div className="modal">{children}</div> : null;
}
// Server Component composing the above
import { Modal } from "./modal";
import { ServerRenderedDetails } from "./server-rendered-details";
export default function Page() {
return (
<Modal>
<ServerRenderedDetails />
</Modal>
);
}
This pattern — keep the interactive shell thin, pass server-rendered content as children — is the single most useful technique for minimizing client bundle size in a mixed app.
Common mistakes
1. Marking a whole page “use client” because of one input field
Extract the input into its own small Client Component instead of converting the entire page. The directive applies to a module and everything it imports, so a top-level "use client" silently pulls the whole subtree into the client bundle.
2. Trying to use hooks in a Server Component
If you see “useState only works in Client Components” errors, it usually means a component tree needs a "use client" boundary drawn closer to where the state actually lives — not at the top of the file that happens to import it.
3. Fetching data in a Client Component that didn’t need to be one
Client-side useEffect fetches mean a waterfall: HTML loads, JS hydrates, then the fetch fires. A Server Component fetch happens during rendering, before anything is sent to the browser, which is almost always faster for initial page load.
Wrapping up
The Server/Client Component split isn’t just a Next.js implementation detail — it’s a shift toward treating “runs in the browser” as something you opt into deliberately, not something you get by default. Start every component as a Server Component, push "use client" as far down the tree as possible, and use the children-composition pattern to keep interactive shells thin. The payoff is smaller client bundles, faster initial loads, and a clearer separation between UI logic and data access.