← All articles
🖥️ ArchitectureAdvanced · 13 min read

Server vs Client Components: The Boundary in Practice

The boundary between server and client components is the single most consequential architectural decision in a modern Next.js app. It is also the one most teams get wrong by treating "use client" as the default.

By Kishan Vaghani · Reviewed by Kajal Pansuriya · Published May 26, 2026

The App Router gives you two kinds of components, and the choice between them controls how much JavaScript ships to the user. Treating every component as a client component — the path of least resistance for teams migrating from Pages Router — defeats the whole architecture and ships a bundle the size of the old one. This post is six concrete patterns for where to put the boundary and what each pattern is worth at build time.

What server components actually are

A server component runs only on the server. It renders to HTML and to a serialised React tree, and it ships zero JavaScript to the client for itself. It can await a database call directly. It cannot use useState, useEffect, or any browser API. Every file in the App Router is a server component by default — you do not have to opt in.

// app/users/[id]/page.js — server component (no directive)
import { db } from "@/lib/db";

export default async function UserPage({ params }) {
  const user = await db.users.findUnique({ where: { id: params.id } });
  return <h1>{user.name}</h1>;
}

What client components actually are

A client component is a file that starts with the directive "use client". It ships JavaScript to the browser, can use hooks, can attach event handlers, can read window. The directive marks the entry point of a client subtree: everything that file imports (that is not already a server component passed in as a child) becomes part of the client bundle.

The compounding mistake

The mistake is putting "use client" at the top of a page or layout, "just to make hooks work." That directive turns the entire imported subtree into client code. A page that wraps its layout in "use client" ships every component on the page, including the static ones, to the browser.

// BEFORE — entire page is client
"use client";
import { useState } from "react";
import { HeavyChart } from "./HeavyChart";
import { ArticleBody } from "./ArticleBody"; // static content, but shipped to client

export default function Page({ articleId }) {
  const [isOpen, setOpen] = useState(false);
  return (
    <main>
      <ArticleBody id={articleId} />
      <button onClick={() => setOpen(!isOpen)}>Toggle</button>
      {isOpen && <HeavyChart />}
    </main>
  );
}
// AFTER — server shell, small client island
// app/page.js — server component
import { ArticleBody } from "./ArticleBody";
import { ToggleableChart } from "./ToggleableChart";

export default async function Page({ params }) {
  const article = await getArticle(params.id);
  return (
    <main>
      <ArticleBody article={article} />
      <ToggleableChart />
    </main>
  );
}

// app/ToggleableChart.js
"use client";
import { useState } from "react";
import { HeavyChart } from "./HeavyChart";

export function ToggleableChart() {
  const [isOpen, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(!isOpen)}>Toggle</button>
      {isOpen && <HeavyChart />}
    </>
  );
}

The article body — the largest visual chunk of the page — is now rendered on the server and shipped as HTML. The chart, which the user might never open, is still client-side, but its parent surface is small. On a real Next.js page, this pattern typically drops First Load JS by 40 to 60 percent.

Six concrete patterns

Pattern 1. Server data as props to a client component

The common case. A page fetches data on the server and passes the result into a client component that handles the interactive bits. The fetched data is serialised once; the client gets the props, not the fetch.

// app/comments/page.js — server
import { CommentList } from "./CommentList";

export default async function Page() {
  const initialComments = await db.comments.findMany();
  return <CommentList initial={initialComments} />;
}

// app/comments/CommentList.js — client
"use client";
import { useState } from "react";

export function CommentList({ initial }) {
  const [comments, setComments] = useState(initial);
  // interactive sorting, filtering, adding…
}

Pattern 2. Client component needs server-only data

A client component cannot import a database driver. The fix is to fetch in the nearest server parent and pass down — or, if the data changes during the user's session, fetch on the client via TanStack Query against an API route.

Pattern 3. Context providers as thin client wrappers

A React context provider must be a client component, because contexts use hooks. Put providers in a single file marked "use client", then render them in your server layout. The provider file itself is small; the children stay server components.

// app/Providers.js
"use client";
import { ThemeProvider } from "./ThemeProvider";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";

const queryClient = new QueryClient();

export function Providers({ children }) {
  return (
    <ThemeProvider>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </ThemeProvider>
  );
}

// app/layout.js — still a server component
import { Providers } from "./Providers";

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

Pattern 4. Interactive widgets at the leaves

A page that is mostly static — a documentation page, a product page, a marketing surface — should have its "use client" boundary at the leaves: a search box, a video player, a copy-to-clipboard button. The page shell stays server-rendered; the islands are small and lazy.

Pattern 5. Forms with server actions

A form needs to be a client component for validation, focus handling, and submit state. The action it submits to is a server function. The form code stays small; the mutation logic lives on the server where the database is.

// app/profile/actions.js
"use server";
export async function updateProfile(formData) {
  const name = formData.get("name");
  await db.users.update({ where: { id: userId }, data: { name } });
  revalidatePath("/profile");
}

// app/profile/ProfileForm.js
"use client";
import { useFormStatus } from "react-dom";
import { updateProfile } from "./actions";

export function ProfileForm({ initialName }) {
  return (
    <form action={updateProfile}>
      <input name="name" defaultValue={initialName} />
      <SubmitButton />
    </form>
  );
}

Pattern 6. Server-side data fetching by default

Prefer server components for the first paint of any data. The server has lower-latency access to your database, the result is cacheable at the CDN level, and there is no loading flash. Use client-side data fetching only for data that changes during the user's session — chat messages, live counters, optimistic updates after mutations.

The boundary rule

A server component can import another server component, and it can also import a client component (which becomes a boundary marker). A client component can import other client components. A client component cannot directly import a server component — but it can receive a server component as a child or slot. The server-component-as-children pattern is how you put server-rendered content inside a client-managed wrapper.

// app/SidebarLayout.js — client (manages open/closed)
"use client";
import { useState } from "react";

export function SidebarLayout({ children }) {
  const [open, setOpen] = useState(true);
  return (
    <div>
      <button onClick={() => setOpen(!open)}>Toggle</button>
      {open && <aside>{children}</aside>}
    </div>
  );
}

// app/page.js — server, passes server-rendered children into a client wrapper
import { SidebarLayout } from "./SidebarLayout";
import { ServerSidebarContent } from "./ServerSidebarContent";

export default function Page() {
  return (
    <SidebarLayout>
      <ServerSidebarContent />
    </SidebarLayout>
  );
}

What to measure

Run next build on the current main branch and write down the First Load JS for the three or four most-trafficked routes. Apply the patterns above — move "use client" toward the leaves, wrap providers in a thin client file, push fetches into the server parent. Run next build again and write the new numbers next to the old ones. The typical improvement on a Next.js page that has been refactored carefully is between 40 and 60 percent. The numbers are the proof that the boundary is doing its job.

The habit that compounds

Treat "use client" as a cost, not as a default. Every PR that adds the directive should answer one question in the description: which leaf needs this, and is the boundary as close to that leaf as it could be? Teams that ask that question in code review ship pages with small bundles. Teams that do not ship the same bundles they shipped on the Pages Router, with extra build complexity on top.

Related reading

The boundary discussion connects directly to bundle and hydration cost. The hydration-cost piece covers what the user actually feels when the client tree is too big, and the state-ownership post explains why pulling server data out of client state shrinks the bundle further. For the related production bug, the hydration-mismatch guide is the troubleshooting companion, and the frontend-architecture topic gathers the rest of the series.

About the writers

Author

Kishan Vaghani

Founder & Lead Engineer, ShareCode

Founder of ShareCode. Writes the engineering deep-dives on this site — WebRTC, Firebase Auth, real-time sync, and the production patterns behind the editor itself.

Real-time collaboration & CRDTsWebRTC & low-latency mediaFirebase authentication & security rulesNext.js & full-stack JavaScript
Reviewed by

Kajal Pansuriya

Developer Educator, ShareCode

Developer educator at ShareCode. Writes the tutorial track — Python, JavaScript debugging, coding-interview prep, and the everyday code-quality habits that hold up in real codebases.

Python fundamentals & teaching beginnersJavaScript debugging & DevToolsCoding-interview preparationClean code & code review

Auditing a 'use client' that grew too large?

Paste the offending page and its imports into a code space, share the link with a teammate, and walk through the six patterns together. Most overgrown client subtrees can be cut in half by moving the directive one or two levels closer to the leaves.

Open a code space