32blogby Studio Mitsu

Minimize 'use client' in React Server Components

Design patterns to keep 'use client' boundaries small in Next.js App Router. Island Architecture, Composition Pattern, and practical refactoring examples.

by omitsu12 min read
On this page

To minimize "use client" in React Server Components, extract only the interactive parts into small Client Component "islands" and keep layouts, data-fetching, and static content as Server Components. Use the Composition Pattern — pass server-rendered content through children props — so that "use client" doesn't propagate up the component tree.

After migrating to App Router, I noticed "use client" creeping into more and more files. Eventually almost everything was a Client Component, and I wondered — what's actually different from the Pages Router?

Getting the most out of RSC (smaller bundles, direct server-side data access, security) requires keeping "use client" boundaries as small as possible. This article covers Island Architecture and Composition Patterns to localize "use client" to where it's truly needed.

Why Does "use client" Keep Spreading?

The reason for writing "use client" is always clear: you need useState, you need onClick, you need useEffect. Every interactive feature requires it.

The problem is "use client" propagation.

page.tsx (Server Component)
└── Layout.tsx ← "use client" added here
    └── Header.tsx ← automatically becomes Client Component
        └── Nav.tsx ← automatically becomes Client Component
            └── NavItem.tsx ← automatically becomes Client Component

"use client" includes that component and all its children in the client bundle. One directive can turn an entire subtree into Client Components.

The Typical Failure Pattern

tsx
// ❌ Entire layout becomes a Client Component
"use client"; // ← Added because MobileMenu needs it

import { useState } from "react";
import { Header } from "./Header"; // Intended to be a Server Component...
import { Footer } from "./Footer"; // Now fully client too

export function Layout({ children }: { children: React.ReactNode }) {
  const [menuOpen, setMenuOpen] = useState(false);

  return (
    <div>
      <Header />
      <MobileMenu isOpen={menuOpen} onClose={() => setMenuOpen(false)} />
      <main>{children}</main>
      <Footer />
    </div>
  );
}

Layout became a Client Component just because MobileMenu needs useState. Now Header and Footer are in the client bundle too.

Island Architecture

Island Architecture is the idea that an "ocean" of static HTML has interactive "islands" embedded within it.

┌─────────────────────────────────────────────────────┐
│  Server Component (static HTML)                      │
│                                                      │
│  ┌──────────────┐          ┌──────────────────────┐  │
│  │ "use client"  │          │  "use client"         │  │
│  │  (Island)    │          │   (Island)             │  │
│  │ SearchBox    │          │  LikeButton            │  │
│  └──────────────┘          └──────────────────────┘  │
│                                                      │
│  ┌──────────────┐                                    │
│  │ "use client"  │                                    │
│  │  (Island)    │                                    │
│  │ MobileMenu   │                                    │
│  └──────────────┘                                    │
└─────────────────────────────────────────────────────┘

Only the interactive elements become "use client". Everything else stays Server Components. Instead of converting the entire React tree to client, you isolate the minimum necessary islands.

Composition Patterns to Localize "use client"

Pattern 1: Extract Only the Interactive Part

tsx
// ❌ Before: entire Layout is a Client Component
"use client";
import { useState } from "react";

export function Layout({ children }: { children: React.ReactNode }) {
  const [menuOpen, setMenuOpen] = useState(false);
  return (
    <div>
      <nav>
        <a href="/">Home</a>
        <a href="/about">About</a>
        <button onClick={() => setMenuOpen(o => !o)}>Menu</button>
        {menuOpen && <MobileMenuItems />}
      </nav>
      <main>{children}</main>
    </div>
  );
}
tsx
// ✅ After: only MobileMenu is "use client"

// components/MobileMenu.tsx
"use client"; // ← only here
import { useState } from "react";

export function MobileMenu() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(o => !o)}>Menu</button>
      {open && (
        <div className="mobile-menu">
          <a href="/">Home</a>
          <a href="/about">About</a>
        </div>
      )}
    </>
  );
}
tsx
// app/layout.tsx — stays a Server Component
import { MobileMenu } from "@/components/MobileMenu";

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <div>
      <nav>
        <a href="/">Home</a>
        <a href="/about">About</a>
        <MobileMenu /> {/* Client Component, but Layout itself is Server */}
      </nav>
      <main>{children}</main>
    </div>
  );
}

Pattern 2: Keep Parents as Servers with children

tsx
// ❌ Before: data-fetching component becomes a Client Component
"use client";
import { useEffect, useState } from "react";

export function ProductSection() {
  const [products, setProducts] = useState([]);
  const [cartOpen, setCartOpen] = useState(false);

  useEffect(() => {
    fetch("/api/products").then(r => r.json()).then(setProducts);
  }, []);

  return (
    <section>
      <div className="grid">
        {products.map(p => <ProductCard key={p.id} product={p} />)}
      </div>
      <CartButton isOpen={cartOpen} onClick={() => setCartOpen(o => !o)} />
    </section>
  );
}
tsx
// ✅ After: data fetching on server, UI control as a small island

// components/CartButton.tsx
"use client";
import { useState } from "react";

export function CartButton() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(o => !o)}>
        Cart {open ? "▲" : "▼"}
      </button>
      {open && <CartPanel />}
    </>
  );
}
tsx
// app/products/page.tsx — Server Component (data fetching)
import { db } from "@/lib/db";
import { ProductCard } from "@/components/ProductCard";
import { CartButton } from "@/components/CartButton";

export default async function ProductSection() {
  const products = await db.product.findMany(); // Direct DB access

  return (
    <section>
      <div className="grid">
        {products.map(p => <ProductCard key={p.id} product={p} />)}
      </div>
      <CartButton /> {/* Client island, but the page itself stays Server */}
    </section>
  );
}

Pattern 3: Pass Server Content Through children Slots

Client Components can't import Server Components, but they can receive them as children:

tsx
// components/Accordion.tsx
"use client";
import { useState, ReactNode } from "react";

export function Accordion({
  title,
  children,
}: {
  title: string;
  children: ReactNode;
}) {
  const [open, setOpen] = useState(false);

  return (
    <div className="border rounded-lg overflow-hidden">
      <button
        className="w-full text-left p-4 font-bold"
        onClick={() => setOpen(o => !o)}
      >
        {title} {open ? "▲" : "▼"}
      </button>
      {open && <div className="p-4">{children}</div>}
    </div>
  );
}
tsx
// app/faq/page.tsx — Server Component
import { db } from "@/lib/db";
import { Accordion } from "@/components/Accordion";

export default async function FaqPage() {
  const faqs = await db.faq.findMany({ orderBy: { order: "asc" } });

  return (
    <main>
      <h1>FAQ</h1>
      {faqs.map(faq => (
        <Accordion key={faq.id} title={faq.question}>
          {/* This content lives in Server Component land */}
          <p>{faq.answer}</p>
          {faq.relatedLinks.map(link => (
            <a key={link.url} href={link.url}>{link.label}</a>
          ))}
        </Accordion>
      ))}
    </main>
  );
}

Accordion manages open/close on the client, but the content rendered inside it is generated server-side. DB access and heavy transforms stay on the server.

Real-World Refactoring: Blog Post Page

Before: Entire Page is Client

tsx
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";

export default function BlogPostPage() {
  const { slug } = useParams();
  const [post, setPost] = useState(null);
  const [liked, setLiked] = useState(false);
  const [tocOpen, setTocOpen] = useState(false);

  useEffect(() => {
    fetch(`/api/posts/${slug}`).then(r => r.json()).then(setPost);
  }, [slug]);

  if (!post) return <div>Loading...</div>;

  return (
    <article>
      <h1>{post.title}</h1>
      <button onClick={() => setTocOpen(o => !o)}>Table of Contents</button>
      {tocOpen && <TableOfContents headings={post.headings} />}
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
      <button onClick={() => setLiked(o => !o)}>
        {liked ? "❤️" : "🤍"} Like
      </button>
    </article>
  );
}

Problems: entire page is client, article content ships as JS, client-side API request, complex loading state.

After: Minimal Islands

tsx
// components/TableOfContentsToggle.tsx
"use client";
import { useState, ReactNode } from "react";

export function TableOfContentsToggle({ children }: { children: ReactNode }) {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button
        className="text-sm text-gray-500 underline"
        onClick={() => setOpen(o => !o)}
      >
        {open ? "Hide ToC" : "Show ToC"}
      </button>
      {open && <div className="my-4 p-4 bg-gray-50 rounded-lg">{children}</div>}
    </>
  );
}
tsx
// components/LikeButton.tsx
"use client";
import { useState } from "react";

export function LikeButton({ postId }: { postId: string }) {
  const [liked, setLiked] = useState(false);

  const handleLike = async () => {
    setLiked(o => !o);
    await fetch(`/api/posts/${postId}/like`, { method: "POST" });
  };

  return (
    <button onClick={handleLike}>
      {liked ? "❤️" : "🤍"} Like
    </button>
  );
}
tsx
// app/blog/[slug]/page.tsx — Server Component
import { db } from "@/lib/db";
import { marked } from "marked"; // Heavy library — zero bundle cost
import { TableOfContentsToggle } from "@/components/TableOfContentsToggle";
import { LikeButton } from "@/components/LikeButton";
import { notFound } from "next/navigation";

export default async function BlogPostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await db.post.findUnique({ where: { slug } });
  if (!post) notFound();

  const html = marked(post.content); // Server-only — marked doesn't ship to client

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

      <TableOfContentsToggle>
        {/* ToC links generated on the server */}
        <nav>
          {post.headings.map(h => (
            <a key={h.id} href={`#${h.id}`} className="block py-1">
              {h.text}
            </a>
          ))}
        </nav>
      </TableOfContentsToggle>

      {/* Article HTML generated server-side — zero JS */}
      <div dangerouslySetInnerHTML={{ __html: html }} />

      {/* Only the like button is a client island */}
      <LikeButton postId={post.id} />
    </article>
  );
}

Context Providers

Context providers need "use client", but placing them directly in layout.tsx would make the entire layout a Client Component.

tsx
// ✅ Extract providers into a thin Client Component wrapper

// components/Providers.tsx
"use client"; // ← only this file
import { ThemeProvider } from "@/contexts/ThemeContext";
import { UserProvider } from "@/contexts/UserContext";
import { ReactNode } from "react";

export function Providers({ children }: { children: ReactNode }) {
  return (
    <ThemeProvider>
      <UserProvider>
        {children}
      </UserProvider>
    </ThemeProvider>
  );
}
tsx
// app/layout.tsx — stays a Server Component
import { Providers } from "@/components/Providers";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {/* Only Providers is client. layout.tsx stays server. */}
        <Providers>
          {children}
        </Providers>
      </body>
    </html>
  );
}

This thin Providers wrapper pattern is recommended in the official Next.js docs.

Measuring the Impact

How do you know your refactoring actually helped? Check these metrics before and after:

  • Bundle size: Run npx @next/bundle-analyzer or check the build output. Client Components show up under "First Load JS"
  • Core Web Vitals: Less client JS means better LCP and INP scores
  • React DevTools Profiler: The React DevTools "Components" tab shows which components are Server vs Client — look for unexpected Client Components

A good rule of thumb from the Next.js docs: default to Server Components and only add "use client" when you need interactivity, hooks, or browser APIs.

FAQ

Does "use client" mean the component only runs on the client?

No. Client Components are pre-rendered on the server as HTML (just like SSR in Pages Router), then hydrated on the client. The difference is that their JavaScript is included in the client bundle for interactivity.

Can a Client Component import a Server Component?

Not directly. A Client Component cannot import a Server Component because the module graph would force it into the client bundle. However, you can pass Server Components as children or other props — this is the Composition Pattern covered in this article.

What data can I pass from Server to Client Components?

Only serializable data: strings, numbers, booleans, arrays, plain objects, Date, Map, Set, TypedArrays, FormData, and React elements (JSX). Functions, class instances, and circular references cannot cross the boundary.

Should I use one big "use client" wrapper or many small ones?

Many small ones. Each "use client" directive creates a boundary — the smaller the boundary, the less JavaScript ships to the client. One large wrapper defeats the purpose of Server Components.

How do Context Providers work with Server Components?

Context requires "use client", so you can't use useContext in Server Components. The recommended pattern is to create a thin <Providers> Client Component wrapper and place it in your root layout, passing children through so the layout itself stays a Server Component.

Does "use client" affect SEO?

Not directly, since Client Components are still server-rendered as HTML. However, if "use client" forces data fetching to happen client-side (via useEffect + fetch), search engines may not see that content. Keeping data fetching in Server Components ensures content is in the initial HTML.

When should I use "use server" instead of "use client"?

"use server" marks server-side functions (Server Actions) that can be called from Client Components — it's for mutations and form handling, not for rendering. "use client" marks components that need client-side interactivity. They serve different purposes.

Wrapping Up

The key principles for minimizing "use client" boundaries:

PrincipleBadGood
Extract interactive partsEntire parent is "use client"Minimum necessary component is "use client"
Data fetching on serveruseEffect + fetch on clientDirect DB access in Server Component
Use children slotsImport Server Component inside ClientPass as children through composition
Thin provider wrappersMake layout "use client"Thin <Providers> Client Component only

The Island Architecture mental model makes decisions easier: "Does this component need to be interactive?" If no — Server Component. If yes — make it as small an island as possible.

Keep "use client" propagation in mind, and you'll maintain smaller bundles while still delivering interactive UIs.

Related articles: