32blogby StudioMitsu
react9 min read

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.

reactRSCserver-componentsnext.jsapp-routerarchitecture
On this page

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.

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.