Next.js Server vs Client Components: Push the Boundary Down, Not Up
Most teams hit 'use client' everywhere and wonder why their bundle explodes. The fix is a single mental model: keep the boundary as low as possible, and use composition to thread server-rendered content through client islands.
Next.js Server vs Client Components: Push the Boundary Down, Not Up
Here's a scene I've watched play out on a dozen Next.js App Router migrations. Developer opens a page file, adds onClick, gets the "Event handlers cannot be passed to Client Component props" error, slaps 'use client' at the top of the file, and moves on. Works fine. Except that file imports a data-fetching component, which imports a chart, which imports a date picker, and now the entire subtree is client-side JavaScript.
Three weeks later, the bundle is several hundred kilobytes bigger than it needs to be, and nobody knows why.
The mental model that prevents this is simple: push the client boundary as far down the tree as possible. Server Components are the default. Client Components are the exception. The 'use client' directive isn't a toggle — it's a boundary declaration that pulls everything it can reach into the client bundle.
What the Boundary Actually Does
When you add 'use client' to a file, you're not just marking that component. You're declaring a module graph boundary. Every import inside that file — every component, every utility, every library — gets bundled for the client.
// ❌ This one directive poisons the whole subtree
'use client';
import { DataGrid } from './DataGrid'; // now client-side
import { fetchUsers } from '@/lib/dal/users'; // now client-side (can't use db here anyway)
import { formatDate } from 'date-fns'; // now client-side
import { HeavyChart } from './HeavyChart'; // now client-side
export function UsersPage() {
const [selected, setSelected] = useState<string[]>([]);
// ...
}If UsersPage only needs useState for a checkbox selection state, you've just dragged your entire data layer intention, a chart library, and all their transitive dependencies into the browser — for one state variable.
The right call: keep UsersPage as a Server Component, and extract only the interactive piece.
// ✅ Server Component — fetches data, renders layout, stays on server
import { fetchUsers } from '@/lib/dal/users';
import { DataGrid } from './DataGrid';
import { UserRowSelector } from './UserRowSelector'; // ← the only client component
export default async function UsersPage() {
const users = await fetchUsers();
return (
<div>
<h1>Users</h1>
<DataGrid rows={users}>
<UserRowSelector /> {/* tiny client island */}
</DataGrid>
</div>
);
}// ✅ UserRowSelector.tsx — the only file with 'use client'
'use client';
import { useState } from 'react';
export function UserRowSelector() {
const [selected, setSelected] = useState<string[]>([]);
// ...
}The bundle cost is now proportional to what actually needs to be interactive.
The Component Tree Mental Model
Think of your component tree as two zones with a hard boundary between them:
Green is the server. Blue is the client boundary. The interactive islands are small, isolated, and don't pull in the data-fetching logic around them.
The Composition Trick: Server Inside Client
Here's the part that trips people up. You can't import a Server Component inside a Client Component — but you can pass one as a prop, including as children. This is the composition pattern that lets you thread server-rendered content through client components.
// ❌ This doesn't work — importing a Server Component inside a Client Component
'use client';
import { useState } from 'react';
import { ServerComponent } from './ServerComponent'; // ← breaks the boundary
export function ClientWrapper() {
const [isOpen, setIsOpen] = useState(false);
return isOpen ? <ServerComponent /> : null;
}// ✅ This works — Server Component passed as a prop from a Server Component above
'use client';
import { useState, type ReactNode } from 'react';
export function Modal({ children, trigger }: { children: ReactNode; trigger: string }) {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>{trigger}</button>
{isOpen && (
<div className="modal">
<button onClick={() => setIsOpen(false)}>Close</button>
{children} {/* server-rendered content lives here */}
</div>
)}
</>
);
}// Server Component that owns the composition
import { Modal } from './Modal';
import { UserDetails } from './UserDetails'; // ← Server Component, fetches from DB
export async function UserRow({ userId }: { userId: string }) {
return (
<Modal trigger="View details">
<UserDetails userId={userId} /> {/* ← rendered on server, passed as prop */}
</Modal>
);
}Why does this work? Because children is just a prop. When UserRow (a Server Component) renders, it passes already-rendered server output down as children. The Modal client component receives the rendered React tree — it never needs to import or invoke UserDetails itself.
The key insight: props flow down. Server Components render first and produce output. That output can be passed as props to Client Components without breaking the server/client separation.
Real Pattern: Data Fetching + Interactive Shell
One of the most common patterns in App Router apps is an async Server Component that fetches data wrapped by a Client Component that handles interactivity like tabs, filters, or modals.
// app/dashboard/page.tsx — Server Component, owns data fetching
import { Suspense } from 'react';
import { DashboardShell } from '@/components/dashboard/DashboardShell';
import { MetricsGrid } from '@/components/dashboard/MetricsGrid';
import { RecentActivity } from '@/components/dashboard/RecentActivity';
import { fetchDashboardMetrics } from '@/lib/dal/metrics';
export default async function DashboardPage() {
const metrics = await fetchDashboardMetrics();
return (
<DashboardShell> {/* Client Component — handles sidebar collapse, responsive layout */}
<MetricsGrid metrics={metrics} /> {/* Server Component */}
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity /> {/* Server Component — slow query, deferred */}
</Suspense>
</DashboardShell>
);
}// components/dashboard/DashboardShell.tsx
'use client';
import { useState } from 'react';
import { ReactNode } from 'react';
export function DashboardShell({ children }: { children: ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(true);
return (
<div className={`layout ${sidebarOpen ? 'sidebar-open' : 'sidebar-closed'}`}>
<aside>
<button onClick={() => setSidebarOpen(!sidebarOpen)}>Toggle</button>
{/* nav items */}
</aside>
<main>{children}</main>
</div>
);
}DashboardShell manages UI state. MetricsGrid and RecentActivity run on the server with direct database access. Neither knows about the other — the page composes them.
Props Must Be Serializable
When you pass data from a Server Component to a Client Component, it has to cross a wire — it gets serialized. React's supported prop types are broader than most people assume: primitives, plain objects and arrays, Map, Set, typed arrays and ArrayBuffer, Date, Promises, JSX elements, symbols registered via Symbol.for, and Server Functions. A Date arrives on the client as a real Date instance.
What it can't serialize: arbitrary functions, classes, instances of your own classes, objects with a null prototype, and symbols created with Symbol('x').
// ✅ Fine — plain data
<UserCard
name={user.name}
email={user.email}
joinedAt={user.createdAt} // Date — serializable
plan={user.subscription.plan} // string — fine
/>
// ❌ Won't work — functions can't cross the server/client boundary as props
<UserCard
onUpgrade={() => router.push('/billing')} // ← can't serialize
formatter={new Intl.DateTimeFormat('en-US')} // ← class instance
/>For event handlers in Client Components, define them inside the component itself — they don't need to come from the server. For formatters and utilities, either use them server-side before passing the result, or import them inside the Client Component.
Prisma model instances aren't plain objects — they have methods attached. Always map to plain objects before passing them from Server Components to Client Components. Prefer explicit field selection over spreading the whole record.
// ✅ Map to plain object first
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
return (
<UserCard
user={{
id: user.id,
name: user.name,
email: user.email,
role: user.role,
}}
/>
);When to Use Server Actions Instead
Server Actions are the mutation story that pairs with Server Components for data fetching. If a Client Component needs to trigger a mutation — form submission, button click that writes to the database — you don't need an API route. You can call a Server Action directly.
// actions/users.ts
'use server';
import { prisma } from '@/lib/prisma';
import { revalidatePath } from 'next/cache';
import { requireAdmin } from '@/lib/authz';
export async function deleteUser(userId: string) {
// A Server Action is a public HTTP endpoint. Authorize FIRST, every time.
const guard = await requireAdmin();
if (!guard.ok) return guard.response;
await prisma.user.delete({ where: { id: userId } });
revalidatePath('/admin/users');
}That authorization check is not optional garnish — it is the whole ballgame. Next.js compiles every Server Action into a callable POST endpoint with a generated ID. Anyone who can find that ID can invoke your action directly, with whatever arguments they like, without ever loading the page that renders the button. Hiding the delete button from non-admins in the UI protects nothing.
Every example of a Server Action you see without an auth check — including most of the ones in tutorials — is teaching you an unauthenticated mutation endpoint. Treat the boundary the same way you'd treat an API route, because it is one.
// Client Component — can call server actions directly
'use client';
import { deleteUser } from '@/actions/users';
export function DeleteUserButton({ userId }: { userId: string }) {
return (
<button
onClick={async () => {
await deleteUser(userId);
}}
>
Delete
</button>
);
}Server Actions are serializable functions — they're essentially RPC calls with a generated endpoint under the hood. They're the exception to the "you can't pass functions from server to client" rule because Next.js handles the serialization for you.
The Decision Flow
Start server, go client only when you have to.
Common Mistakes
Mistake 1: 'use client' on layout files. Your layout probably has a ThemeProvider or a nav with a mobile menu toggle. Don't put 'use client' on the layout — extract the interactive nav into its own component and keep the layout as a Server Component.
// ❌ Poisons everything under this layout
'use client';
export default function RootLayout({ children }: { children: ReactNode }) {
return <html><body>{children}</body></html>;
}// ✅ Extract the interactive bit — RootLayout stays a Server Component
import type { ReactNode } from 'react';
import { MobileNav } from '@/components/MobileNav'; // 'use client' lives here
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html>
<body>
<MobileNav />
<main>{children}</main>
</body>
</html>
);
}Mistake 2: Fetching in Client Components when you don't need to. If data doesn't change based on user interaction, fetch it in a Server Component and pass it down. You eliminate a loading state, a useEffect, and the round-trip from the browser.
Mistake 3: Forgetting that context is client-only. createContext and useContext only work in Client Components. This trips people up when they try to create a provider in a Server Component. Providers must be Client Components — which is fine, as long as you pass the rest of your tree as children so it stays on the server.
// app/providers.tsx
'use client';
import { ThemeProvider } from 'next-themes';
import { type ReactNode } from 'react';
export function Providers({ children }: { children: ReactNode }) {
return <ThemeProvider attribute="class">{children}</ThemeProvider>;
}
// app/layout.tsx — Server Component
import { Providers } from './providers';
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html suppressHydrationWarning>
<body>
<Providers>
{children} {/* ← still rendered on server */}
</Providers>
</body>
</html>
);
}When Server Components Are the Wrong Call
Not everything belongs on the server. A few cases where Client Components are clearly the right default:
- Highly interactive UIs — drag-and-drop editors, real-time collaborative tools, canvas-based apps. The constant user interaction makes server-round-trips impractical.
- Pages that depend entirely on client state — a multi-step wizard where each step depends on previous inputs that haven't been persisted. Fetching from the server on each step adds latency for no gain.
- Components that use browser APIs unavoidably — geolocation, clipboard, media recorder,
window.matchMedia. There's no server-side equivalent. - Third-party libraries that haven't been updated — plenty of popular component libraries still assume they run in the browser. Check for
'use client'in their entry points. If it's not there, you'll hitlocalStorage is not definedat runtime.
Practical Checklist
Before you add 'use client' to any file, run through this:
- Can I extract just the interactive part into a smaller component?
- Would passing the interactive component as
childrenfrom a Server Component above work? - Am I adding it because of an error from a third-party library? Check if that library has a client-only version or wrapper.
- Is there data fetching in this file that could move to a Server Component parent?
If you answer yes to any of the first three, you probably don't need to add 'use client' to the file you're looking at.
The 80/20 rule here is real: in most Next.js apps, 80% of the UI should be Server Components. The 20% that needs to be client-side should be small, leaf-node components. When you get that ratio right, your Time to First Byte drops, your JavaScript bundle shrinks, and the parts that need to be interactive stay fast because they're not competing with a mountain of unnecessary client code.
Comments (0)
No comments yet. Be the first to share your thoughts!