Composition Patterns
Mix Server and Client Components effectively using the children pattern, slots, and boundary-aware architecture.
Search across all documentation pages
Mix Server and Client Components effectively using the children pattern, slots, and boundary-aware architecture.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Quick-reference recipe card -- copy-paste ready.
// Pattern 1: Pass Server Components as children to Client Components
// app/components/client-sidebar.tsx
"use client";
import { useState } from "react";
export function Sidebar({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(true);
return (
<aside className={open ? "w-64" : "w-0"}>
<button onClick={() => setOpen(!open)}>Toggle</button>
{open && children}
</aside>
);
}
// app/page.tsx (Server Component)
import { Sidebar } from "./components/client-sidebar";
import { ServerNav } from "./components/server-nav";
export default function Page() {
return (
<Sidebar>
{/* ServerNav renders on the server, passed as pre-rendered JSX */}
<ServerNav />
</Sidebar>
);
}When to reach for this: You need a Client Component (for interactivity) that wraps or contains Server Components (for data fetching or zero-JS rendering).
// app/components/accordion.tsx
"use client";
import { useState } from "react";
type AccordionProps = {
title: string;
children: React.ReactNode;
};
export function Accordion({ title, children }: AccordionProps) {
const [expanded, setExpanded] = useState(false);
return (
<div className="border rounded mb-2">
<button
onClick={() => setExpanded(!expanded)}
className="w-full text-left px-4 py-3 font-medium flex justify-between"
>
{title}
<span>{expanded ? "-" : "+"}</span>
</button>
{expanded && <div className="px-4 pb-4">{children}</div>}
</div>
);
}// app/components/product-details.tsx (Server Component -- no directive)
import { db } from "@/lib/db";
export async function ProductDetails({ productId }: { productId: string }) {
const product = await db.product.findUnique({
where: { id: productId },
include: { specs: true },
});
if (!product) return <p>Product not found</p>;
return (
<dl className="grid grid-cols-2 gap-2 text-sm">
{product.specs.map((spec) => (
<div key={spec.id}>
<dt className="font-medium text-gray-600">{spec.label}</dt>
<dd>{spec.value}</dd>
</div>
))}
</dl>
);
}// app/components/product-reviews.tsx (Server Component)
import { db } from "@/lib/db";
export async function ProductReviews({ productId }: { productId: string }) {
const reviews = await db.review.findMany({
where: { productId },
orderBy: { createdAt: "desc" },
take: 5,
});
return (
<ul className="space-y-3">
{reviews.map((r) => (
<li key={r.id} className="border-b pb-3">
<p className="font-medium">{r.author}</p>
<p className="text-gray-600">{r.body}</p>
</li>
))}
</ul>
);
}// app/products/[id]/page.tsx (Server Component orchestrates everything)
import { Suspense } from "react";
import { Accordion } from "@/app/components/accordion";
import { ProductDetails } from "@/app/components/product-details";
import { ProductReviews } from "@/app/components/product-reviews";
import { AddToCartButton } from "@/app/components/add-to-cart";
type Props = { params: Promise<{ id: string }> };
export default async function ProductPage({ params }: Props) {
const { id } = await params;
return (
<main className="max-w-2xl mx-auto p-6">
<Accordion title="Specifications">
{/* Server Component rendered on server, passed as children */}
<Suspense fallback={<p>Loading specs...</p>}>
<ProductDetails productId={id} />
</Suspense>
</Accordion>
<Accordion title="Reviews">
<Suspense fallback={<p>Loading reviews...</p>}>
<ProductReviews productId={id} />
</Suspense>
</Accordion>
{/* Client Component for interactivity */}
<AddToCartButton productId={id} />
</main>
);
}// app/components/add-to-cart.tsx
"use client";
import { useTransition } from "react";
import { addToCart } from "@/app/actions/cart";
export function AddToCartButton({ productId }: { productId: string }) {
const [isPending, startTransition] = useTransition();
return (
<button
onClick={() => startTransition(() => addToCart(productId))}
disabled={isPending}
className="mt-4 w-full bg-blue-600 text-white py-3 rounded font-medium disabled:opacity-50"
>
{isPending ? "Adding..." : "Add to Cart"}
</button>
);
}What this demonstrates:
Accordion) wraps Server Components (ProductDetails, ProductReviews) via children propsawait and are rendered on the server; their output is passed as pre-rendered JSX to the Client ComponentSuspense boundary allows independent streaming of server data"use client" boundary makes everything in its dependency tree client-only.children or any other React.ReactNode prop. The Server Component is already rendered on the server; the Client Component receives pre-rendered JSX, not a module reference."use client" directive. Everything above it (in the import tree) is server; everything below is client.children).Multiple slot pattern:
// Client Component with named slots
"use client";
export function DashboardLayout({
sidebar,
header,
children,
}: {
sidebar: React.ReactNode;
header: React.ReactNode;
children: React.ReactNode;
}) {
const [collapsed, setCollapsed] = useState(false);
return (
<div className="flex">
<aside className={collapsed ? "w-16" : "w-64"}>{sidebar}</aside>
<div className="flex-1">
<header>{header}</header>
<main>{children}</main>
</div>
</div>
);
}// Server Component orchestrator
import { DashboardLayout } from "./dashboard-layout";
import { ServerSidebar } from "./server-sidebar";
import { ServerHeader } from "./server-header";
export default async function DashboardPage() {
return (
<DashboardLayout
sidebar={<ServerSidebar />}
header={<ServerHeader />}
>
<ServerMainContent />
</DashboardLayout>
);
}Context provider pattern:
// app/providers.tsx
"use client";
import { ThemeProvider } from "next-themes";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class">
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</ThemeProvider>
);
}// app/layout.tsx (Server Component)
import { Providers } from "./providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}Extracting interactive parts into a thin Client Component:
// Instead of making the whole card a Client Component...
// Extract only the interactive part
"use client";
export function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked(!liked)}>
{liked ? "Liked" : "Like"}
</button>
);
}
// Keep the card as a Server Component
export default async function PostCard({ id }: { id: string }) {
const post = await fetchPost(id);
return (
<article>
<h2>{post.title}</h2>
<p>{post.body}</p>
<LikeButton postId={id} />
</article>
);
}// Use React.ReactNode for any prop that receives JSX from the server
type LayoutProps = {
children: React.ReactNode;
sidebar: React.ReactNode;
modal: React.ReactNode;
};
// Server Component output is already-rendered JSX -- not a component reference
// TypeScript treats it as ReactNode, which includes JSX.Element, string, number, etc.
// Server Action props are valid across the boundary
type FormProps = {
submitAction: (formData: FormData) => Promise<{ error?: string }>;
};Importing a Server Component in a "use client" file -- The import silently converts the Server Component into a Client Component. No error is thrown, but it loses server-only behavior. Fix: Pass it as children or a JSX prop from a Server Component parent.
Context providers must be Client Components -- React Context requires "use client". But putting providers in the layout makes the layout a client component and all children become client too. Fix: Create a separate providers.tsx file with "use client" and wrap {children} in the Server Component layout.
Over-using "use client" -- Marking a high-level component as "use client" pulls all children into the client bundle. Fix: Push the "use client" boundary as low as possible in the component tree. Extract only the interactive pieces.
Passing non-serializable props -- Passing a function, class instance, or Symbol from a Server Component to a Client Component fails silently or throws. Fix: Pass only serializable data. Use Server Actions for function-like behavior.
Shared state between server and client -- There is no shared state. Server Components run on the server; Client Components hydrate on the client. Fix: Pass initial data as props from server to client. Use Server Actions to sync state back.
| Pattern | Use When | Don't Use When |
|---|---|---|
| Children pattern | Client Component wraps Server Component output | All children are client-only anyway |
| Slot props (sidebar, header) | Multiple independent Server Component regions | A single children prop suffices |
| Context providers wrapper | You need React Context at the root without making the layout a Client Component | No context is needed |
dynamic(import, { ssr: false }) | A third-party library cannot render on the server at all | Normal SSR + hydration works fine |
| Server Actions as props | A Client Component needs to trigger server-side logic | The interaction is purely client-side |
children (or another JSX prop) to a Client Component.The "use client" boundary makes everything in its dependency tree client-only. Importing a Server Component inside a "use client" file silently converts it to a Client Component, losing all server-only behavior.
A Client Component accepts multiple React.ReactNode props (e.g., sidebar, header, children). A Server Component orchestrator passes different Server Components into each slot:
<DashboardLayout
sidebar={<ServerSidebar />}
header={<ServerHeader />}
>
<ServerMainContent />
</DashboardLayout>Create a separate providers.tsx file with "use client" that wraps {children}. Import it in your Server Component layout:
// app/layout.tsx (Server Component)
import { Providers } from "./providers";
export default function RootLayout({ children }) {
return (
<html><body>
<Providers>{children}</Providers>
</body></html>
);
}All children and imports of that component are pulled into the client bundle. This defeats the purpose of Server Components. Fix: push the "use client" boundary as low as possible and extract only the interactive pieces.
children or a JSX prop from a Server Component parent instead.No. Regular functions, class instances, and Symbols are not serializable and will fail. Use Server Actions (async functions with "use server") for function-like behavior across the boundary.
Use React.ReactNode for any prop that receives JSX from the server:
type LayoutProps = {
children: React.ReactNode;
sidebar: React.ReactNode;
modal: React.ReactNode;
};type FormProps = {
submitAction: (formData: FormData) => Promise<{ error?: string }>;
};Server Actions are the only functions that can cross the server-client boundary as props.
Suspense boundary allows its content to stream independently.No. Server Components run on the server; Client Components hydrate on the client. Pass initial data as props from server to client. Use Server Actions to sync state back to the server.
Keep the data-fetching component as a Server Component and extract only the interactive piece (e.g., a button) into a small Client Component:
// Server Component
export default async function PostCard({ id }) {
const post = await fetchPost(id);
return (
<article>
<h2>{post.title}</h2>
<LikeButton postId={id} /> {/* Client Component */}
</article>
);
}Reviewed by Chris St. John·Last updated Jul 7, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥