Next.js Server Components vs Client Components
A clear mental model for Next.js Server Components vs Client Components — what runs where, and how to decide.
The question I get asked most often by teams moving from the Pages Router to the App Router isn’t “how do I fetch data” — it’s “wait, where does this code actually run?” That confusion is reasonable. React Server Components change the default execution model for the entire tree, and most of the bugs I’ve seen in App Router migrations come from not having a clear picture of the boundary between server and client.
Here’s the short version: in the App Router, every component is a Server Component by default. It renders on the server, never ships its JavaScript to the browser, and can’t use state, effects, or browser APIs. You opt a component (and everything it renders as a child, unless you’re careful about composition) into the client world with a "use client" directive at the top of the file.
What Server Components Actually Are
A Server Component runs once, on the server, during the request (or at build time for static routes). It produces a serialized description of UI that gets streamed to the client — not JavaScript, just the rendered output plus instructions for where Client Components need to be hydrated. Because it never runs in the browser, it never ships its code or its dependencies in the JS bundle.
// app/products/page.tsx (Server Component by default — no directive needed)
import { db } from '@/lib/db';
export default async function ProductsPage() {
const products = await db.product.findMany({ take: 20 });
return (
<main>
<h1>Products</h1>
<ul>
{products.map((p) => (
<li key={p.id}>{p.name} — ${p.price}</li>
))}
</ul>
</main>
);
}
Notice what’s missing compared to the old getServerSideProps pattern: no separate data-fetching function, no prop drilling from a page-level fetch into every component that needs the data. You just await directly inside the component. You can also import server-only packages — a database client, a filesystem module, an API key read from process.env without the NEXT_PUBLIC_ prefix — because none of it needs to be safe to ship to a browser.
When You Need "use client"
You need a Client Component the moment you need any of the following:
- State (
useState,useReducer) - Effects (
useEffect,useLayoutEffect) - Browser-only APIs (
window,localStorage,IntersectionObserver) - Event handlers (
onClick,onChange, etc.) - Third-party libraries that assume a browser environment (most charting libraries, drag-and-drop libraries, etc.)
- React context providers and consumers
// app/components/QuantityPicker.tsx
'use client';
import { useState } from 'react';
export function QuantityPicker({ max }: { max: number }) {
const [quantity, setQuantity] = useState(1);
return (
<div className="quantity-picker">
<button onClick={() => setQuantity((q) => Math.max(1, q - 1))}>-</button>
<span>{quantity}</span>
<button onClick={() => setQuantity((q) => Math.min(max, q + 1))}>+</button>
</div>
);
}
The "use client" directive doesn’t mean “this component only runs in the browser.” It still renders on the server for the initial HTML (so you get fast first paint and SEO-friendly markup), but it also ships its JavaScript to the browser and gets hydrated there so the interactivity works. That’s the real distinction: Server Components render on the server only; Client Components render on the server and hydrate on the client.
Composition: Don’t Let "use client" Eat the Tree
The mistake I see constantly is putting "use client" at the top of a high-level layout or page component because one piece of it needs interactivity. Once a file has that directive, every component it imports and renders directly becomes part of the client bundle too — even if those child components don’t need any client features themselves.
// Bad: the whole page becomes a Client Component
'use client';
import { useState } from 'react';
import { ProductGallery } from './ProductGallery'; // doesn't need to be client
import { ProductReviews } from './ProductReviews'; // doesn't need to be client
export default function ProductPage({ product }) {
const [selectedTab, setSelectedTab] = useState('details');
return (
<div>
<ProductGallery images={product.images} />
<TabButtons selected={selectedTab} onSelect={setSelectedTab} />
<ProductReviews reviews={product.reviews} />
</div>
);
}
The fix is to push the client boundary as far down the tree as possible, and to use the “pass Server Components as children” pattern when a Client Component needs to wrap server-rendered content:
// app/products/[id]/page.tsx — stays a Server Component
import { TabsClient } from './TabsClient';
import { ProductGallery } from './ProductGallery';
import { ProductReviews } from './ProductReviews';
import { getProduct } from '@/lib/products';
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id);
return (
<div>
<ProductGallery images={product.images} />
<TabsClient>
<ProductReviews reviews={product.reviews} />
</TabsClient>
</div>
);
}
// app/products/[id]/TabsClient.tsx
'use client';
import { useState, type ReactNode } from 'react';
export function TabsClient({ children }: { children: ReactNode }) {
const [tab, setTab] = useState('details');
return (
<div>
<button onClick={() => setTab('details')}>Details</button>
<button onClick={() => setTab('reviews')}>Reviews</button>
{tab === 'reviews' && children}
</div>
);
}
This works because children passed into a Client Component from a Server Component parent is still rendered on the server — the Client Component just receives it as an opaque slot. ProductReviews never becomes part of the client bundle even though it’s rendered inside a component marked "use client". This pattern is the single most useful trick for keeping bundle size down in a large App Router codebase.
Common Mistakes
Importing server-only code into a Client Component. If you try to import your Prisma client or a module that reads server-side secrets into a file marked "use client", you’ll either get a build error or, worse, leak code into the browser bundle that was never meant to be there. Keep data access in Server Components or in route handlers / server actions, and pass plain serializable data down as props.
Marking a component client “just in case.” Every unnecessary "use client" adds to the JS the browser has to download, parse, and hydrate. Default to Server Components and only add the directive when you hit a feature that genuinely requires it.
Forgetting that props crossing the server-to-client boundary must be serializable. You can’t pass a function, a Date object expecting methods, or a class instance as a prop into a Client Component from a Server Component — only plain serializable values (strings, numbers, plain objects, arrays). Functions like onClick handlers have to be defined inside the Client Component itself.
Assuming Client Components don’t render on the server. They do, for the initial HTML. If your Client Component throws trying to access window during that server render, you’ll get a hydration error. Guard browser-only logic inside useEffect so it only runs after mount.
When to Use Each
Use a Server Component when the component’s job is primarily to fetch and display data, when it doesn’t need interactivity, or when it needs access to server-only resources. Use a Client Component when you need hooks, event handlers, or browser APIs — and keep those components as small and as low in the tree as the interactivity actually requires.
This default-to-server model pairs directly with how you think about rendering strategy more broadly — see Next.js SSR vs SSG vs ISR Explained for how Server Components interact with caching and revalidation. It also affects performance-sensitive decisions like image loading, covered in Next.js Image Optimization and Performance. And since props crossing the server/client boundary need to be typed carefully to avoid runtime surprises, it’s worth pairing this with solid typing discipline — see TypeScript Best Practices for Large Applications.
FAQ
Can a Server Component import a Client Component? Yes, that’s the normal composition pattern — a Server Component page importing and rendering a small interactive Client Component (like a form or a dropdown) is exactly how most App Router pages are structured.
Can a Client Component import a Server Component directly?
No — you can’t import a Server Component into a Client Component file and render it as a normal child, because Client Components can re-render on the client and Server Components can’t run there. The workaround is the children/composition pattern shown above: pass the Server Component down from a parent Server Component.
Does marking a layout "use client" affect its child pages?
No, only the components actually imported and rendered inside that client-marked file become part of the client boundary. A page.tsx rendered inside a client layout via the children slot stays a Server Component.
Related reading
Written by
Tayyab Roy
Senior Frontend / Full-Stack Engineer
Senior Frontend & Full-Stack Engineer specializing in Angular, React, Next.js and enterprise eCommerce (Shopify, SAP Commerce / Hybris, Spartacus). I write about practical, production-tested frontend engineering.
Related Articles
Next.js Image Optimization and Performance
How the Next.js Image component handles optimization automatically, and the mistakes that quietly hurt LCP and CLS.
Next.js SSR vs SSG vs ISR Explained
Server-side rendering, static generation, and incremental static regeneration in Next.js — what each is for and how to choose.
JSON to TypeScript Converter: A Practical Guide (Plus C# and Node.js)
How to convert JSON into TypeScript interfaces, C# classes, or Node.js types the right way — free online tools, real examples, and the pitfalls auto-generated types miss.