DevStacked
Next.js FundamentalsAugust 9, 202617 min read

Server vs Client Components in Next.js 16 (2026 Guide)

You add "use client" to the top of a file, TypeScript stops complaining, and everything just... works. So you start adding it everywhere — every component, just in case. A few weeks later your bundle size has crept up, your pages feel sluggish, and you're not sure why, because "it's just React, right?"

It's not just React. The App Router split every component into one of two worlds — Server Components and Client Components — and that split is the single biggest architectural decision in a modern Next.js app. Get it right and you ship less JavaScript, load faster, and keep secrets like API keys off the browser entirely. Get it wrong and you end up with a Next.js app that behaves like a slower, more confusing version of a plain React SPA.

By the end of this guide, you'll know exactly what each type of component can and can't do, how they talk to each other, and — most importantly — a simple decision framework so you stop guessing where "use client" belongs.


The Quick Answer

If you only remember one rule: start every component as a Server Component, and only add "use client" when you hit something that genuinely requires the browser — state, effects, event handlers, or browser-only APIs.

NeedUse
Fetching data from a database or APIServer Component
Reading environment variables / secretsServer Component
useState, useReducer, useEffectClient Component
onClick, onChange, any event handlerClient Component
window, localStorage, documentClient Component
Context providers (theme, auth state)Client Component
Large third-party UI libraries (charts, rich text editors)Client Component
Static or data-driven markup with no interactivityServer Component

Keep that table in mind — everything below is really just explaining why it's true.


Why This Split Exists

Before the App Router, every component in a Next.js (or Create React App) project shipped to the browser as JavaScript, got hydrated, and ran client-side — even if it never did anything interactive. A blog post's paragraph tags didn't need useState, but they got bundled and hydrated like they did.

Server Components (RSCs) fix that. They render entirely on the server, and Next.js sends the browser the rendered result through the React Server Component Payload. Client Components are represented by references to their JavaScript so React can hydrate the interactive parts in the browser.

💡 Think of it like this: a Server Component is a chef cooking in the kitchen — you never see the knives, the raw ingredients, or the mess. You just get the finished plate (HTML). A Client Component is more like a tabletop grill brought out to your table — it needs its own equipment (JavaScript) shipped out to you so you can interact with it directly.

This matters for three concrete reasons:

  1. Smaller bundles. Every component that doesn't need interactivity costs zero client-side JavaScript.
  2. Safer secrets. Server Components can read environment variables, call your database, or hit an internal API directly — that code never reaches the browser, so there's nothing to leak in dev tools.
  3. Faster data fetching. A Server Component can await a database call directly in the component body — no client-side loading spinner, no waterfall of useEffectfetch → re-render.

Server Components: The Default

In the App Router, every component is a Server Component unless you say otherwise. No directive, no import, nothing special — just a normal .tsx file.

// app/products/page.tsx
import { getProducts } from "@/lib/products";

const ProductsPage = async () => {
  const products = await getProducts();

  return (
    <ul className="grid grid-cols-2 gap-4">
      {products.map((product) => (
        <li key={product.id} className="rounded-lg border p-4">
          <h2 className="font-semibold">{product.name}</h2>
          <p className="text-muted-foreground">${product.price}</p>
        </li>
      ))}
    </ul>
  );
};

export default ProductsPage;

What's happening here: ProductsPage is async, and it awaits getProducts() directly inside the component — something you could never do in a regular React component. Next.js runs this on the server, resolves the data, renders the final HTML, and streams that HTML to the browser. There's no client-side fetch, no loading state to manage manually, and none of getProducts()'s implementation (database credentials, query logic) ever ships to the browser.

What Server Components Are Good At

  • Data fetching close to the source — call your database or a private API directly, with full access to server-only environment variables.
  • Keeping secrets server-side — an API key used inside a Server Component never appears in the browser's network tab or JavaScript bundle.
  • Reducing client JavaScript — a Server Component's code, and everything it imports, is stripped from the client bundle entirely.

What Server Components Can't Do

  • No useState, useReducer, or any React hook that depends on the client render lifecycle.
  • No event handlers — onClick, onChange, onSubmit handlers all require the browser's event system.
  • No useEffect, window, document, or any browser-only API — none of that exists on the server.
  • No React Context consumption via useContext (though a Server Component can be rendered inside a Client Component's context provider — more on that below).

⚠️ Common Mistake: Trying to add an onClick to a Server Component and getting a build error like "Event handlers cannot be passed to Client Component props." This isn't a bug — it's Next.js telling you this piece of UI needs to become a Client Component.


Client Components: Opting Into the Browser

The moment a component needs state, an event handler, or a browser API, you mark it explicitly with the "use client" directive at the very top of the file — before any imports.

// components/like-button.tsx
"use client";

import { useState } from "react";

interface LikeButtonProps {
  initialLikes: number;
}

export const LikeButton = ({ initialLikes }: LikeButtonProps) => {
  const [data, setData] = useState({ likes: initialLikes, liked: false });

  const handleClick = () => {
    setData((prev) => ({ likes: prev.liked ? prev.likes - 1 : prev.likes + 1, liked: !prev.liked  }))
  };

  return (
    <button
      onClick={handleClick}
      className="flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm"
    >
      {data.liked ? "❤️" : "🤍"} {data.likes}
    </button>
  );
};

What's happening here: "use client" tells Next.js "this file, and everything it exports, needs to run in the browser too." The component still renders on the server for the first paint (Next.js server-renders Client Components as well, for fast initial HTML), but its JavaScript is also sent to the browser so React can hydrate it — attach event listeners and make useState actually work after the page loads.

💡 Tip: "use client" doesn't mean "this only runs in the browser." It means "this component needs the browser eventually." Next.js still renders it on the server first for a fast initial page load — that's what makes the App Router different from a plain client-side React app.

What Client Components Are Good At

  • Anything interactive: forms, toggles, dropdowns, modals, drag-and-drop.
  • State that changes in response to user input.
  • Browser-only APIs — localStorage, navigator.geolocation, IntersectionObserver.
  • Real-time updates — WebSocket connections, polling, live subscriptions.
  • Third-party libraries that rely on React hooks internally (most chart libraries, rich text editors, and animation libraries fall into this category).

How They Actually Work Together

This is the part that trips most beginners up: Server and Client Components aren't two separate apps — they're one component tree, and you compose them together. The trick is understanding the direction data and imports are allowed to flow.

Rule 1: Server Components Can Import Client Components

This is the easy, common direction. A Server Component (like a page) can freely render a Client Component as a normal child.

// app/blog/[slug]/page.tsx
import { getPostBySlug } from "@/lib/posts";
import { LikeButton } from "@/components/like-button";

interface Props {
  params: Promise<{ slug: string }>;
}

const BlogPostPage = async ({ params }: Props) => {
  const { slug } = await params;
  const post = await getPostBySlug(slug);

  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>

      {/* A Client Component rendered inside a Server Component — totally normal */}
      <LikeButton initialLikes={post.likeCount} />
    </article>
  );
};

export default BlogPostPage;

What's happening here: BlogPostPage stays a Server Component — it fetches post directly from the database with no client-side JavaScript for that part. LikeButton is the one interactive piece, so only its JavaScript ships to the browser. Everything else on the page — the title, the article body — costs nothing.

⚠️ Common Mistake: Adding "use client" to the whole page just because one small piece (like a like button) needs interactivity. That drags every Server Component this page imports into the client bundle too. Keep the directive on the smallest possible component.

Rule 2: Client Components Can't Import Server Components Directly

This is the one that catches people off guard. Once you're inside a "use client" file, you establish a boundary; everything imported into that file automatically becomes part of the client bundle and runs in the browser. If a Server Component containing backend-specific logic (like direct database queries or API keys) is directly imported, it will either crash the build or fail at runtime because the browser environment lacks server-side capabilities.

// ❌ This will not work as expected
"use client";

import { UserProfile } from "@/components/user-profile"; // a Server Component

export const Sidebar = () => {
  return (
    <aside>
      <UserProfile /> {/* This gets converted into a Client Component too */}
    </aside>
  );
};

If you attempt to import a Server Component directly into a Client Component, it forces the Server Component to transform into a Client Component.

Rule 3: The Fix — Pass Server Components as children

Instead of importing a Server Component inside a Client Component, render the Server Component in a parent Server Component, and pass it down as children (or any prop) to the Client Component.

// components/theme-provider.tsx
"use client";

import { createContext, useState, type ReactNode } from "react";

export const ThemeContext = createContext<"light" | "dark">("light");

interface ThemeProviderProps {
  children: ReactNode;
}

export const ThemeProvider = ({ children }: ThemeProviderProps) => {
  const [theme] = useState<"light" | "dark">("light");

  return (
    <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>
  );
};
// app/layout.tsx
import { ThemeProvider } from "@/components/theme-provider";
import { UserProfile } from "@/components/user-profile"; // still a Server Component

const RootLayout = ({ children }: { children: React.ReactNode }) => {
  return (
    <html lang="en">
      <body>
        <ThemeProvider>
          {/* UserProfile is rendered by the (server) RootLayout, then
              *slotted into* the Client Component as children. It never
              becomes a Client Component itself. */}
          <UserProfile />
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
};

export default RootLayout;

What's happening here: RootLayout is a Server Component, so it's allowed to render UserProfile (also a Server Component) normally. It then hands the already-rendered result to ThemeProvider as children. From ThemeProvider's point of view, children is just a slot — it doesn't care what rendered it, so UserProfile never has to become a Client Component. This "children as a slot" pattern is the standard fix any time you need to wrap Server Component content in a Client Component like a context provider, a modal, or an animated wrapper.


Passing Data Between Server and Client Components

Props passed from a Server Component to a Client Component must be serializable by React's Server Component protocol. Plain data such as strings, numbers, booleans, arrays, and objects is the safest approach. Functions generally cannot cross the boundary, except for supported Server Actions — see below.

// app/dashboard/page.tsx
import { getUser } from "@/lib/user";
import { ProfileCard } from "@/components/profile-card";

const DashboardPage = async () => {
  const user = await getUser();

  return <ProfileCard name={user.name} email={user.email} />;
};

export default DashboardPage;
// components/profile-card.tsx
"use client";

import { useState } from "react";

interface ProfileCardProps {
  name: string;
  email: string;
}

export const ProfileCard = ({ name, email }: ProfileCardProps) => {
  const [expanded, setExpanded] = useState(false);

  return (
    <div
      onClick={() => setExpanded((prev) => !prev)}
      className="cursor-pointer rounded-lg border p-4"
    >
      <p className="font-medium">{name}</p>
      {expanded && <p className="text-sm text-muted-foreground">{email}</p>}
    </div>
  );
};

What's happening here: DashboardPage fetches user on the server and passes only the plain string fields it needs (name, email) down as props — not the whole user object, and definitely not a database client or ORM instance. ProfileCard receives those as regular props and manages its own expanded state locally.

Passing Functions: Server Actions Are the Exception

Regular functions can't cross from server to client as props — but Server Actions (functions marked with "use server") can, because Next.js turns them into a callable reference instead of trying to serialize the function itself.

// actions/posts.ts
"use server";

export const createPost = async (formData: FormData) => {
  const title = formData.get("title") as string;

  // await db.post.create({ data: { title } });
};
// app/posts/new/page.tsx
import { createPost } from "@/actions/posts";
import { PostForm } from "@/components/post-form";

const NewPostPage = () => {
  return <PostForm action={createPost} />;
};

export default NewPostPage;
// components/post-form.tsx
"use client";

interface PostFormProps {
  action: (formData: FormData) => Promise<void>;
}

export const PostForm = ({ action }: PostFormProps) => {
  return (
    <form action={action} className="flex flex-col gap-3">
      <input
        name="title"
        placeholder="Post title"
        className="rounded-lg border px-3 py-2"
      />
      <button type="submit" className="rounded-lg bg-primary px-4 py-2 text-white">
        Create
      </button>
    </form>
  );
};

What's happening here: createPost is a Server Action — Next.js compiles it into a secure, callable reference rather than shipping its actual implementation to the browser. PostForm receives it as a prop and passes it straight to the native <form action={...}> attribute, so submitting the form runs createPost on the server without you writing a single fetch call or API route.

💡 Tip: Server Actions run on the server, but you should still authenticate the user, authorize the requested operation, and validate all input inside the action.


Common Mistakes to Avoid

⚠️ Mistake 1: Marking the entire page "use client". This is the single biggest anti-pattern. It turns every Server Component that page imports into client-shipped JavaScript, even ones that never needed to be. Push "use client" down to the smallest interactive leaf component instead.

⚠️ Mistake 2: Importing server-only code (a database client, a Node.js-only package) into a file that's part of a Client Component's import tree. Use the server-only package to catch this at build time — importing it at the top of a server-only file throws a build error the moment a Client Component accidentally pulls it in.

// lib/db.ts
import "server-only"; // throws a build error if this file is ever imported client-side

import { createClient } from "@supabase/supabase-js";

export const db = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);

⚠️ Mistake 3: Passing non-serializable props (a function that isn't a Server Action, a class instance, a Map/Set) from a Server Component into a Client Component. Next.js will throw a clear error at build or runtime — convert the data to a plain object, array, or primitive first.

⚠️ Mistake 4: Wrapping a large chunk of static UI in a Client Component just because it sits next to something interactive. Only the interactive piece needs "use client" — everything around it can, and should, stay a Server Component.


A Practical Example: Product Page With a Cart Button

Here's a realistic page that mixes both, following the composition pattern from earlier.

// app/products/[id]/page.tsx
import { getProduct } from "@/lib/products";
import { AddToCartButton } from "@/components/add-to-cart-button";

interface Props {
  params: Promise<{ id: string }>;
}

const ProductPage = async ({ params }: Props) => {
  const { id } = await params;
  const product = await getProduct(id);

  return (
    <div className="mx-auto max-w-2xl space-y-4 p-6">
      <h1 className="text-2xl font-semibold">{product.name}</h1>
      <p className="text-muted-foreground">{product.description}</p>
      <p className="text-xl font-bold">${product.price}</p>

      {/* Only this button ships JavaScript to the browser */}
      <AddToCartButton productId={product.id} price={product.price} />
    </div>
  );
};

export default ProductPage;
// components/add-to-cart-button.tsx
"use client";

import { useState } from "react";

interface AddToCartButtonProps {
  productId: string;
  price: number;
}

export const AddToCartButton = ({ productId, price }: AddToCartButtonProps) => {
  const [adding, setAdding] = useState(false);

  const handleAddToCart = async () => {
    setAdding(true);
    await fetch("/api/cart", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ productId, price }),
    });
    setAdding(false);
  };

  return (
    <button
      onClick={handleAddToCart}
      disabled={adding}
      className="rounded-lg bg-primary px-5 py-2.5 text-white disabled:opacity-50"
    >
      {adding ? "Adding..." : "Add to Cart"}
    </button>
  );
};

What's happening here: the product name, description, and price are all static HTML by the time they reach the browser — zero JavaScript for any of it. AddToCartButton is the only part of this page that ships client-side JavaScript, and it's scoped to exactly the props it needs (productId, price), not the entire product object.


Frequently Asked Questions

Do Server Components run on every request?

Not necessarily — it depends on how the page is rendered. A Server Component in a statically generated page (SSG) runs once at build time. One using dynamic data (like cookies() or an uncached fetch) runs fresh on every request. Next.js Rendering Strategies Explained covers this in more depth.

Can I use useContext inside a Server Component?

No — useContext, along with every other React hook, requires the client render lifecycle. But a Server Component can be rendered as children inside a Client Component that provides context, which is exactly the composition pattern shown above.

Is "use client" the same as the old Pages Router's client-side rendering?

Not quite. A Client Component still gets server-rendered first for the initial HTML, then hydrated in the browser. It's not a pure client-side render the way a traditional single-page app works — you get the best of both: fast first paint and interactivity after hydration.

Why does my Client Component still work fine with a database import?

You might not have hit the boundary yet — Next.js only errors out once that server-only code is actually part of the browser's bundle, which can depend on your import chain. Don't rely on it silently working; add the server-only package to any file that should never reach the client, so the mistake fails loudly at build time instead of leaking secrets in production.

Should layouts be Server or Client Components?

Default to Server Components for layouts — they usually just arrange other components and rarely need state themselves. If your layout needs a client-side provider (theme, auth context), extract just that provider into its own "use client" file and wrap children with it, rather than marking the whole layout as a Client Component.

Does marking a component "use client" also make its children Client Components?

"use client" defines a Client Component boundary. The component and the modules it imports are treated as part of the client module graph. However, components passed to it as children or other props aren't automatically converted into Client Components.


Wrapping Up

The Server/Client Component split isn't extra complexity for its own sake — it's what lets a Next.js 16 app ship less JavaScript, keep secrets off the browser, and fetch data without a client-side waterfall. The mental model is simple once it clicks: default to Server Components, reach for "use client" only at the smallest interactive leaf, and use the children pattern whenever a Client Component needs to wrap Server Component content.

From here, a natural next step is pairing this with Next.js Rendering Strategies Explained to see how Server Components fit into SSR, SSG, ISR, and Cache Components — or Error Handling in Next.js 16 to see how error.tsx boundaries interact with both component types.

Continue Learning

Next.jsReactServer ComponentsClient ComponentsApp RouterTypeScript
Share On