DevLift
Back to Blog

Next.js Server Components vs Client Components: Push the Boundary Down, Not Up

One 'use client' at the top of your layout wipes out every performance benefit RSC offers. Learn how to push the boundary to leaf components and keep your server tree intact.

Admin
April 17, 20268 min read2 views

Next.js Server Components vs Client Components: Push the Boundary Down, Not Up

A new Next.js App Router project boots up. You add a useState for a dropdown menu. The compiler complains. You add "use client" to the top of layout.tsx. The error goes away. You ship.

Six months later your dashboard ships every component you've ever written to the browser and your Largest Contentful Paint is embarrassing. The culprit is that one "use client" at the top of your layout file, which silently opted your entire application out of server rendering.

This is the most common Server Components mistake, and it's completely avoidable once you understand where the "use client" boundary actually belongs.

The Mental Model

In the App Router, every component is a Server Component by default. Server Components render on the server, produce HTML, and ship zero JavaScript to the browser. They can read databases, call internal APIs, and access secrets directly — with no exposure to the client.

"use client" doesn't mark a single component as a client component. It marks a boundary. Everything on the client side of that boundary — the component itself and every module it imports — becomes part of the client JavaScript bundle.

That means this:

// app/layout.tsx
"use client"; // ← this one directive pulls EVERYTHING below it into the bundle
 
import { useState } from "react";
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  const [menuOpen, setMenuOpen] = useState(false);
 
  return (
    <html>
      <body>
        <nav>
          <button onClick={() => setMenuOpen(!menuOpen)}>Menu</button>
          {menuOpen && <MobileNav />}
        </nav>
        {children}  {/* Your entire page tree is now client-side */}
      </body>
    </html>
  );
}

...bundles your nav, your page content, every component imported anywhere in the tree, and every third-party library they use. One useState for a hamburger menu triggered a complete client takeover.

The Problem It Solves

Here's a realistic course platform page. The first version that developers usually write:

// app/courses/[slug]/page.tsx — common first pass
"use client"; // needed for the enrollment button's onClick
 
import { useRouter } from "next/navigation";
import { CourseHeader } from "@/components/course-header";
import { CurriculumList } from "@/components/curriculum-list";
import { EnrollButton } from "@/components/enroll-button";
import { ReviewList } from "@/components/review-list";
import { db } from "@/lib/db";
 
export default function CoursePage({ params }: { params: Promise<{ slug: string }> }) {
  const router = useRouter();
  // ❌ Can't fetch from db here — this is running in the browser
  // ❌ Can't await params either — a Client Component can't be async,
  //    so you need React's use(params) just to read the slug
  // ❌ CourseHeader, CurriculumList, ReviewList all bundled client-side
  // ❌ db import would expose credentials
}

The page can't even fetch its own data because "use client" moved it to the browser. The developer works around this with useEffect + fetch, adding loading states, error states, and a waterfall where none was necessary.

Note the params type. Since Next.js 15 params and searchParams are Promises, and Next.js 16 removed the synchronous access path entirely. Worth knowing exactly how this fails, because it is quieter than you'd hope: on 16.1.6 I annotated a page's props as { params: { slug: string } } and both next build and tsc --noEmit passed clean. Nothing in the default build path compares your hand-written annotation against the real prop type, so you get no error — just a params that is a Promise at runtime and a params.slug that is undefined.

What does catch it is the generated helper. PageProps<'/courses/[slug]'> resolves to params: Promise<{ slug: string }>, and assigning it to the plain-object shape is an immediate Property 'slug' is missing in type 'Promise<{ slug: string; }>'. Use the generated type rather than writing the shape out, and the compiler is on your side. Then a Server Component just awaits it. A Client Component can't, which is one more reason the page shouldn't be one.

The Pattern: Push the Boundary Down

The fix is to make the page a Server Component and move "use client" to the smallest component that actually needs it.

// app/courses/[slug]/page.tsx — Server Component
import { getCourseBySlug } from "@/lib/dal/courses";
import { getEnrollmentStatus } from "@/lib/dal/enrollments";
import { CourseHeader } from "@/components/course-header";
import { CurriculumList } from "@/components/curriculum-list";
import { EnrollButton } from "@/components/enroll-button"; // ← client
import { ReviewList } from "@/components/review-list";
import { auth } from "@/lib/auth";
import { notFound } from "next/navigation";
 
export default async function CoursePage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const [course, session] = await Promise.all([
    getCourseBySlug(slug),
    auth(),
  ]);
 
  if (!course) notFound();
 
  const isEnrolled = session?.user
    ? await getEnrollmentStatus(session.user.id, course.id)
    : false;
 
  return (
    <div className="max-w-4xl mx-auto py-12 px-4">
      {/* Pure display — stays server-side */}
      <CourseHeader
        title={course.title}
        author={course.author.name}
        rating={course.rating}
        enrollmentCount={course.enrollmentCount}
      />
 
      {/* Interactive leaf — only this crosses the boundary */}
      <EnrollButton
        courseId={course.id}
        isEnrolled={isEnrolled}
        price={course.price}
      />
 
      {/* Pure display — stays server-side */}
      <CurriculumList modules={course.modules} />
      <ReviewList reviews={course.reviews} />
    </div>
  );
}
// components/enroll-button.tsx — Client Component
"use client";
 
import { useState, useTransition } from "react";
import { enrollInCourse } from "@/actions/enrollments";
 
interface EnrollButtonProps {
  courseId: string;
  isEnrolled: boolean;
  price: number;
}
 
export function EnrollButton({ courseId, isEnrolled, price }: EnrollButtonProps) {
  const [enrolled, setEnrolled] = useState(isEnrolled);
  const [isPending, startTransition] = useTransition();
 
  const handleEnroll = () => {
    startTransition(async () => {
      const result = await enrollInCourse(courseId);
      if (result.success) setEnrolled(true);
    });
  };
 
  if (enrolled) {
    return (
      <a href={`/learn/${courseId}`} className="btn-primary w-full text-center">
        Continue Learning
      </a>
    );
  }
 
  return (
    <button onClick={handleEnroll} disabled={isPending} className="btn-primary w-full">
      {isPending ? "Processing..." : `Enroll for $${price}`}
    </button>
  );
}

What changed: CourseHeader, CurriculumList, and ReviewList produce zero client JavaScript. The database is queried directly in the server component. Secrets stay server-side. Only EnrollButton — the one component that actually needs useState — crosses the boundary.

How the Boundary Works

Rendering diagram...

The page fetches data, renders HTML, and streams it to the browser. Of the code you wrote, only EnrollButton's JavaScript is shipped — a per-page chunk on top of the framework runtime every App Router page loads anyway. Compare that to the "use client" at the top version, which would ship everything: your header, your curriculum list, your review list, and whatever those import.

The Composition Pattern: Server Components as Children

There's a constraint that trips up almost every developer new to the App Router: importing a "Server Component" into a Client Component file doesn't keep it on the server.

// ⚠️ This compiles — and that's the problem
"use client";
import { UserProfile } from "./user-profile"; // meant to be a Server Component
 
export function Sidebar() {
  return <UserProfile />; // now part of the client bundle
}

Once a file is marked "use client", everything it imports and everything it renders directly belongs to the client module graph. So UserProfile doesn't error — it silently stops being a Server Component and gets bundled and hydrated like any other client code.

You find out later, and indirectly: if UserProfile is async, React fails because async components aren't supported on the client. If it queries the database, the query fails, because only NEXT_PUBLIC_-prefixed variables are inlined into the client bundle — I grepped .next/static after a build and the DATABASE_URL value is nowhere in it. If it imports a module marked with the server-only package, then you get the clean build-time error you wanted in the first place — which is a good argument for putting import 'server-only' at the top of your data-access layer.

But you can pass Server Components as props — specifically as children. This is how you interleave server and client rendering without losing either:

// components/modal.tsx — Client Component
"use client";
 
import { useState, type ReactNode } from "react";
 
interface ModalProps {
  trigger: ReactNode;
  children: ReactNode; // ← Server-rendered content can go here
}
 
export function Modal({ trigger, children }: ModalProps) {
  const [open, setOpen] = useState(false);
 
  return (
    <>
      <div onClick={() => setOpen(true)}>{trigger}</div>
      {open && (
        <div className="fixed inset-0 z-50 flex items-center justify-center">
          <div className="bg-white rounded-lg shadow-xl p-6 max-w-lg w-full">
            <button onClick={() => setOpen(false)} className="absolute top-4 right-4">✕</button>
            {children}
          </div>
        </div>
      )}
    </>
  );
}
// app/courses/page.tsx — Server Component
import { Modal } from "@/components/modal";
import { CoursePreview } from "@/components/course-preview"; // Server Component
import { CourseCard } from "@/components/course-card";       // Server Component
import { getCourses } from "@/lib/dal/courses";
 
export default async function CoursesPage() {
  const courses = await getCourses();
 
  return (
    <div className="grid grid-cols-3 gap-6">
      {courses.map((course) => (
        <Modal
          key={course.id}
          trigger={<CourseCard course={course} />}
        >
          {/* CoursePreview is a Server Component — fetches its own data */}
          <CoursePreview courseId={course.id} />
        </Modal>
      ))}
    </div>
  );
}

This works because a Server Component passed as a prop is never imported into the client module graph — it's rendered on the server and handed to the Client Component as already-rendered output. Precisely, it arrives in the RSC payload: React's serialized representation of the rendered server tree, which also carries placeholders and JS references for the Client Components. By the time Modal runs in the browser, there is nothing left for it to render — the children slot is filled. The useState that drives the modal's open/closed state lives only in Modal.tsx, which is the only file of the three in the client bundle.

💡

layout.tsx works the same way, and it's worth being precise about it: your root layout is a Server Component — layouts and pages are Server Components by default, exactly like everything else — and {children} is the nested Server Component tree, filled in on the server. That's why the fix below works at all: a layout can await a session and still hand the client boundary to one small provider component.

Variations

Context Providers at the Top of the Tree

The most common place you legitimately need "use client" near the top is a Context Provider — for themes, auth state, a toast system, a shopping cart. The pattern is to wrap the provider in its own thin client file, then use it in your server layout:

// components/providers.tsx — Client Component
"use client";
 
import { ThemeProvider } from "next-themes";
import { Toaster } from "@/components/ui/sonner";
import { SessionProvider } from "next-auth/react";
import type { Session } from "next-auth";
 
interface ProvidersProps {
  children: React.ReactNode;
  session: Session | null;
}
 
export function Providers({ children, session }: ProvidersProps) {
  return (
    <SessionProvider session={session}>
      <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
        {children}
        <Toaster />
      </ThemeProvider>
    </SessionProvider>
  );
}
// app/layout.tsx — Server Component
import { Providers } from "@/components/providers";
import { auth } from "@/lib/auth";
 
export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await auth(); // ← Server-side auth check
 
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <Providers session={session}>
          {children}
        </Providers>
      </body>
    </html>
  );
}

RootLayout stays a Server Component. It fetches the session server-side and passes it down. Providers takes the boundary, but only ships ThemeProvider, SessionProvider, and Toaster to the client — not your entire page tree.

Third-Party Components That Forgot "use client"

Some npm packages ship components that use browser APIs without the "use client" directive. Wrapping them yourself fixes the build error and makes your dependency relationship explicit:

// components/ui/chart-wrapper.tsx
"use client"; // re-export with the directive the package forgot
 
export { LineChart, Line, XAxis, YAxis, Tooltip } from "recharts";
// app/dashboard/page.tsx — Server Component
import { LineChart, Line, XAxis, YAxis, Tooltip } from "@/components/ui/chart-wrapper";
import { getAnalytics } from "@/lib/dal/analytics";
 
export default async function DashboardPage() {
  const data = await getAnalytics(); // server-side query
 
  return (
    <LineChart width={720} height={280} data={data}>
      <XAxis dataKey="date" />
      <YAxis />
      <Tooltip />
      <Line type="monotone" dataKey="enrollments" />
    </LineChart>
  );
}

Note that the page stays a Server Component: the data is fetched on the server, and only the chart primitives cross the boundary. Recharts is a fair example rather than a straw man — as of recharts@3.7.0 the published bundle still carries no "use client" directive, so importing it straight into a Server Component fails and the wrapper is what makes it work.

Splitting a Component That Does Too Much

Sometimes a component naturally mixes server and client concerns. Split it:

// Before: one client component doing everything
"use client";
 
import { useState } from "react";
 
export function CourseCard({ courseId }: { courseId: string }) {
  const [bookmarked, setBookmarked] = useState(false);
  const course = useCourse(courseId); // client-side fetch with useEffect + fetch
 
  return (
    <div>
      <h2>{course.title}</h2>
      <button onClick={() => setBookmarked(!bookmarked)}>
        {bookmarked ? "Saved" : "Save"}
      </button>
    </div>
  );
}
// After: server shell + client leaf
// components/course-card.tsx — Server Component
import { getCourse } from "@/lib/dal/courses";
import { BookmarkButton } from "./bookmark-button";
 
export async function CourseCard({ courseId }: { courseId: string }) {
  const course = await getCourse(courseId); // ← direct DB query, no waterfall
 
  return (
    <div>
      <h2>{course.title}</h2>
      <BookmarkButton courseId={courseId} initialBookmarked={course.isBookmarked} />
    </div>
  );
}
 
// components/bookmark-button.tsx — Client Component
"use client";
 
import { useState } from "react";
 
export function BookmarkButton({
  courseId,
  initialBookmarked,
}: {
  courseId: string;
  initialBookmarked: boolean;
}) {
  const [bookmarked, setBookmarked] = useState(initialBookmarked);
 
  return (
    <button onClick={() => setBookmarked(!bookmarked)}>
      {bookmarked ? "Saved" : "Save"}
    </button>
  );
}

The Server Component queries the database. The Client Component handles the toggle. Neither does the other's job.

Decision Table

NeedUse
Fetch from database / internal APIServer Component
Read environment secretsServer Component
useState, useReducer, useEffectClient Component
Event handlers (onClick, onChange)Client Component
Browser APIs (window, localStorage)Client Component
useContext (consuming a context)Client Component
Static display: text, images, layoutServer Component (default)
Third-party lib requiring browser envClient Component
Context ProviderClient Component (thin wrapper)

When NOT to Use Server Components

Highly interactive UIs. A real-time collaborative editor, a drag-and-drop board, a canvas drawing tool — these are all client-side by nature. Splitting them into server shells with client leaves adds complexity for zero gain when the entire component is interactive.

When you need to read browser state during render. window.innerWidth, localStorage, navigator.language — these don't exist during server rendering. If your initial render depends on browser state, you either need a Client Component or a two-pass render (useEffect + state). Trying to force server rendering for these causes hydration mismatches.

Frequently updating real-time data. If a component re-renders every second from a WebSocket, keeping it as a Server Component means a round-trip to the server on every update. Use a Client Component with a client-side subscription instead.

When the component tree is already shallow. A <SettingsPage> with three toggles and a save button doesn't need careful server/client splitting. The whole thing is interactive. Mark it "use client" and move on — the goal is correctness and performance, not purity.

⚠️

Don't cargo-cult the pattern. The rule is "minimize client JavaScript" — not "eliminate all client components." A page that fetches one row from the database and renders four form fields is not worth splitting. Apply the pattern where the component tree is deep and most of the tree is non-interactive display.

The Summary Rule

Every component is a Server Component until it needs to be a Client Component. When it does need to be one, make the smallest possible unit a Client Component, not the largest. Put "use client" at the leaf — the button, the input, the dropdown — not the page, the layout, or the section.

When a Client Component needs to contain server-rendered content — a modal with server-fetched data, a sidebar with DB-backed navigation — pass that server content in as children or another prop, not as an import.

The boundary is a tool. Put it where it earns its cost.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

Server Actions collapse form handling into a single server function—no API route, no manual fetch, no useState for loading. Here's the pattern that replaces 80% of your mutation boilerplate.
AdminAugust 3, 20267 min read
One file to guard every route — plus the Next.js 16 rename that moves it off the Edge runtime, the request-vs-response header trap that leaks user IDs to the browser, and the CVE that explains why this can never be your only auth layer.
AdminAugust 3, 20268 min read
The EventEmitter pattern lets components in the same process react to the same event without being directly coupled — no message broker needed.
AdminAugust 3, 20266 min read