With the adoption of React Server Components (RSC) architecture, developers faced a fundamentally new challenge: dividing a single visual interface tree across server and client execution boundaries.
In traditional React, component composition was straightforward: any component could import any other component without constraints. In the Next.js App Router, strict physical network boundaries apply (Network Boundary). Flaws in component architecture lead to:
fs, database drivers, private environment variables) inside a browser bundle.useState, useEffect) or React Context without forfeiting server-side advantages.Composition patterns represent a rigorous set of architectural techniques enabling seamless interleaving of Server and Client Components, keeping client-side bundle size near zero, and passing data across the network boundary without violating RSC isolation rules.
In Next.js, the root of the component tree (layout.tsx, page.tsx) is always executed on the server.
The Turbopack compiler rule is definitive: the 'use client' directive cascades down through its entire import subtree. If you import a component into a file declared with 'use client', that component and all of its transitive dependencies are compiled into the client bundle.
Inspect React Flight serialization boundaries and understand 'use client' bundle impact
The 'use client' directive is pushed down to the leaf node (button). Heavy parsers and parent data execute exclusively on the server and never leak into the client JS bundle.
The core guideline for architects at Fullstack Agentic Lab: push 'use client' down to the leaves of the tree, and when server-rendered content must reside inside an interactive client container, employ the Donut Pattern.
Permitted directly via import. Executes strictly on the server; 0 KB in the browser JS bundle.
Permitted directly via import. Props must be serializable (JSON primitives, Promises, JSX slots).
Permitted directly via import. Standard React component tree executed in the browser.
Strictly prohibited. Direct import forces the compiler to bundle the Server Component into the client bundle.
Permitted. The Donut Pattern: the Server Component is passed from an outer server context as an evaluated ReactNode descriptor.
What happens when a Client Component (declared with 'use client') directly imports a Server Component: import { ServerFeed } from './ServerFeed'?
When an interactive Client Component (such as an animated collapsible sidebar) needs to wrap heavy server-rendered data, pass that content through the children prop:
'use client';
import { useState } from 'react';
interface CollapsibleSidebarProps {
children: React.ReactNode; // Server slot
}
export function CollapsibleSidebar({ children }: CollapsibleSidebarProps) {
const [isOpen, setIsOpen] = useState(true);
return (
<div className="flex">
<aside className={`transition-all duration-300 ${isOpen ? 'w-64' : 'w-16'}`}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="p-2 border border-border rounded bg-bg-elevated text-xs mb-4"
>
{isOpen ? 'Collapse' : 'Expand'}
</button>
{/* Evaluated server node renders here. Database code is excluded from the client bundle! */}
<div className={isOpen ? 'block' : 'hidden'}>
{children}
</div>
</aside>
</div>
);
}import { CollapsibleSidebar } from '@/components/CollapsibleSidebar';
import { ServerNavigationTree } from '@/components/ServerNavigationTree'; // Server library + DB query
export default async function DashboardPage() {
return (
<main className="p-8">
<h1 className="text-2xl font-bold mb-6">Management Dashboard</h1>
{/* Donut Pattern: pass the Server Component into the client wrapper shell */}
<CollapsibleSidebar>
<ServerNavigationTree />
</CollapsibleSidebar>
</main>
);
}When an interface requires multiple isolated server zones (e.g., header, sidebar, and main workspace), the Client Component can declare Named Slots:
'use client';
import { useState } from 'react';
interface SplitLayoutProps {
sidebarSlot: React.ReactNode;
contentSlot: React.ReactNode;
}
export function SplitLayout({ sidebarSlot, contentSlot }: SplitLayoutProps) {
const [activePane, setActivePane] = useState<'both' | 'content'>('both');
return (
<div className="flex gap-6 min-h-screen">
{activePane === 'both' && (
<aside className="w-1/3 border-r border-border p-4">
<button
type="button"
onClick={() => setActivePane('content')}
className="text-xs text-accent-cyan mb-3 block"
>
Focus on Content →
</button>
{sidebarSlot}
</aside>
)}
<section className="flex-1 p-4">
{activePane === 'content' && (
<button
type="button"
onClick={() => setActivePane('both')}
className="text-xs text-accent-cyan mb-3 block"
>
← Show Sidebar
</button>
)}
{contentSlot}
</section>
</div>
);
}The useContext hook cannot be consumed directly in Server Components. However, state libraries (Next-Themes, TanStack Query, Zustand) frequently require wrapping the component tree in a Context Provider.
Solution: isolate the provider inside a dedicated Client Component accepting {children}:
'use client';
import { ThemeProvider } from 'next-themes';
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
{/* Nested Server Components preserve their server execution lifecycle! */}
{children}
</ThemeProvider>
);
}import { AppProviders } from '@/components/providers/AppProviders';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body className="bg-bg-base text-text-primary">
{/* The root server layout remains a Server Component! */}
<AppProviders>
{children}
</AppProviders>
</body>
</html>
);
}Instead of declaring an entire page client-side because of a single interactive search input, isolate state inside a compact leaf component:
'use client';
import { useTransition } from 'react';
import { useRouter, usePathname } from 'next/navigation';
export function SearchInput({ defaultValue = '' }: { defaultValue?: string }) {
const router = useRouter();
const pathname = usePathname();
const [isPending, startTransition] = useTransition();
const handleSearch = (term: string) => {
const params = new URLSearchParams(window.location.search);
if (term) {
params.set('q', term);
} else {
params.delete('q');
}
startTransition(() => {
router.replace(`${pathname}?${params.toString()}`);
});
};
return (
<div className="relative">
<input
type="search"
defaultValue={defaultValue}
placeholder="Search documentation..."
onChange={(e) => handleSearch(e.target.value)}
className="w-full px-4 py-2 rounded-lg bg-bg-elevated border border-border text-sm focus:outline-none focus:border-accent-cyan"
/>
{isPending && <span className="absolute right-3 top-2.5 text-xs text-text-muted animate-spin">⏳</span>}
</div>
);
}import { SearchInput } from '@/components/SearchInput';
import { HeavyArticleList } from '@/components/HeavyArticleList'; // Server Component with markdown parser
interface PageProps {
searchParams: Promise<{ q?: string }>;
}
export default async function DocsPage({ searchParams }: PageProps) {
const { q } = await searchParams;
return (
<div className="max-w-4xl mx-auto p-8 space-y-6">
<h1 className="text-3xl font-bold">Knowledge Base</h1>
{/* Interactive client leaf */}
<SearchInput defaultValue={q} />
{/* Server list: 0 bytes of JS in the client bundle */}
<HeavyArticleList query={q ?? ''} />
</div>
);
}Client buttons can trigger server mutations without creating manual Route Handlers (/api/...) by accepting Server Actions as props:
'use client';
import { useTransition } from 'react';
interface ActionButtonProps {
action: () => Promise<void>; // Server Action passed through props!
label: string;
}
export function ActionButton({ action, label }: ActionButtonProps) {
const [isPending, startTransition] = useTransition();
return (
<button
type="button"
disabled={isPending}
onClick={() => startTransition(async () => { await action(); })}
className="px-4 py-2 rounded-lg bg-accent-violet text-white text-sm font-medium hover:bg-accent-violet/90 transition-colors disabled:opacity-50"
>
{isPending ? 'Processing...' : label}
</button>
);
}Why does a Server Component passed as children to a Client Component NOT re-render or lose its server-only nature when setState is called in the client parent?
Server Components cannot pass standard synchronous functions as props to Client Components because functions cannot be serialized into the JSON-like RSC Flight Payload (TypeError: Functions cannot be passed directly to Client Components).
Solution:
'use server' (serialized as an RPC reference).// ❌ ERROR: Synchronous arrow function defined in a Server Component
// return <ClientButton onClick={() => console.log('delete')} />;
// ✅ CORRECT: Use a Server Action for server-side mutations
// app/actions.ts:
// 'use server';
// export async function deleteItemAction(id: string) { await db.item.delete({ where: { id } }); }
// app/page.tsx:
// <ClientButton action={deleteItemAction} />When a Client Component imports a single utility button from a barrel file (import { Button } from '@/components'), the bundler may pull heavy server components (such as the Monaco editor) re-exported from that same file into the client bundle.
Solution: Use direct module imports in client files (import { Button } from '@/components/ui/Button') or configure optimizePackageImports in next.config.ts.
Developers often mark app/layout.tsx with 'use client' to mount a ThemeProvider or SessionProvider. This converts the entire application into a client-rendered Single Page Application, neutralizing all benefits of React Server Components.
Solution: Create an isolated Client Component AppProviders.tsx accepting {children} and wrap it inside the server-rendered RootLayout.
If a utility queries database credentials without the import 'server-only' guard, another developer can inadvertently import it into a Client Component. Turbopack will not report an error until runtime, risking private token leaks in browser code.
Solution: Install pnpm add server-only and add import 'server-only' at the top of all security-critical data service files.