Composition Patterns
Mix Server and Client Components effectively using the children pattern, slots, and boundary-aware architecture.
Recipe
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).
Working Example
// 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:
- The children pattern: A Client Component (
Accordion) wraps Server Components (ProductDetails,ProductReviews) viachildrenprops - Server Components fetch data with
awaitand are rendered on the server; their output is passed as pre-rendered JSX to the Client Component - Each
Suspenseboundary allows independent streaming of server data - The orchestrating page is a Server Component that composes everything
Deep Dive
How It Works
- A Client Component cannot import a Server Component because the
"use client"boundary makes everything in its dependency tree client-only. - However, a Client Component can receive Server Component output as
childrenor any otherReact.ReactNodeprop. The Server Component is already rendered on the server; the Client Component receives pre-rendered JSX, not a module reference. - The server-client boundary is at the
"use client"directive. Everything above it (in the import tree) is server; everything below is client. - You can pass multiple Server Components through different prop slots (not just
children).
Variations
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>
);
}TypeScript Notes
// 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 }>;
};Gotchas
-
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 aschildrenor 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 separateproviders.tsxfile 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.
Alternatives
| 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 |
FAQs
What is the "children pattern" and why is it needed?
- A Client Component cannot import a Server Component directly.
- The children pattern lets a Server Component parent pass pre-rendered Server Component output as
children(or another JSX prop) to a Client Component. - The Client Component receives already-rendered JSX, not a module reference.
Why can't a Client Component import a Server 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.
How does the multiple slot pattern work?
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>How should I set up context providers without making the layout a Client Component?
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>
);
}What happens if I mark a high-level component as "use client"?
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.
Gotcha: I imported a Server Component inside a "use client" file and got no error. What went wrong?
- The import silently converts the Server Component into a Client Component.
- No error is thrown, but it loses server-only behavior (direct DB access, zero-JS rendering, etc.).
- Fix: pass it as
childrenor a JSX prop from a Server Component parent instead.
Gotcha: Can I pass a regular function from a Server Component to a Client Component?
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.
How do I type slot props in TypeScript for a Client Component that receives server-rendered JSX?
Use React.ReactNode for any prop that receives JSX from the server:
type LayoutProps = {
children: React.ReactNode;
sidebar: React.ReactNode;
modal: React.ReactNode;
};What is the correct TypeScript type for a Server Action prop passed to a Client Component?
type FormProps = {
submitAction: (formData: FormData) => Promise<{ error?: string }>;
};Server Actions are the only functions that can cross the server-client boundary as props.
Why use Suspense boundaries around Server Components inside the children pattern?
- Each
Suspenseboundary allows its content to stream independently. - The Client Component shell renders immediately with fallbacks, and each Server Component's data streams in as it resolves.
Is there shared state between Server Components and Client Components?
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.
How do I extract only the interactive part of a component?
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>
);
}Related
- Server Components -- Default server rendering
- Client Components -- Adding interactivity
- Streaming -- Suspense with composed components
- Partial Prerendering -- Static shells with dynamic holes