React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

compositionserver-client-boundarychildren-patternslotsinterleaving

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) via children props
  • Server Components fetch data with await and are rendered on the server; their output is passed as pre-rendered JSX to the Client Component
  • Each Suspense boundary 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 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.
  • 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 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.

Alternatives

PatternUse WhenDon't Use When
Children patternClient Component wraps Server Component outputAll children are client-only anyway
Slot props (sidebar, header)Multiple independent Server Component regionsA single children prop suffices
Context providers wrapperYou need React Context at the root without making the layout a Client ComponentNo context is needed
dynamic(import, { ssr: false })A third-party library cannot render on the server at allNormal SSR + hydration works fine
Server Actions as propsA Client Component needs to trigger server-side logicThe 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 children or 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 Suspense boundary 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>
  );
}