
Composition Patterns for Mixing Server and Client Components in Next.js
Knowing the rules of Server and Client Components is one thing. Structuring a real page with them is another. A modal that contains server-fetched data, a tab bar where each tab's content comes from the database, a theme provider that wraps the whole app, a filterable list whose items are rendered on the server: each of these needs the two kinds of components to cooperate, and the obvious first attempt often pulls far too much into the client bundle or simply doesn't compile.
The good news is that a small set of composition patterns covers almost every case. They all come from one idea: a Client Component can display server-rendered output it never imported. Server Components render first, and their output is passed into Client Components as props, most often children.
This post walks through those patterns with complete examples, plus the anti-patterns they replace.
The Core Constraint
Two rules shape everything below:
- Code crosses the boundary through imports. Whatever a
"use client"file imports becomes client code. A Client Component can't import a Server Component and keep it on the server. - Data crosses through serializable props. Strings, numbers, plain objects, arrays, dates, promises, Server Functions, and rendered JSX elements can be passed from server to client. Ordinary functions can't.
That last item, rendered JSX, is what makes composition work. A Server Component can render <Cart /> and hand the result to a Client Component as a prop. The client side gets the output, not the code.
If either rule is new to you, read the "use client" directive explained first. The rest of this post assumes it.
Pattern 1: Interactive Leaves
The simplest pattern, and the one you'll use most: keep the page and its structure on the server, and make only the interactive bits Client Components.
// app/recipes/[slug]/page.tsx
import { notFound } from "next/navigation";
import { getRecipe } from "@/data/recipes";
import { SaveButton } from "./save-button";
import { ServingsAdjuster } from "./servings-adjuster";
export default async function RecipePage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const recipe = await getRecipe(slug);
if (!recipe) notFound();
return (
<article className="space-y-6">
<header className="flex items-center justify-between">
<h1 className="text-3xl font-bold">{recipe.title}</h1>
<SaveButton recipeId={recipe.id} />
</header>
<p>{recipe.intro}</p>
<ServingsAdjuster
baseServings={recipe.servings}
ingredients={recipe.ingredients}
/>
<ol className="list-decimal pl-6">
{recipe.steps.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
</article>
);
}
SaveButton and ServingsAdjuster are small Client Components. Everything else, including the steps list and the data fetch, stays on the server. When you find yourself reaching for "use client" on a page, ask which leaf actually needs it.
Pattern 2: The children Slot
When a Client Component needs to wrap content (a modal, collapsible panel, drawer, carousel) let the content come in through children.
// app/ui/collapsible.tsx
"use client";
import { useState, type ReactNode } from "react";
export function Collapsible({
title,
defaultOpen = false,
children,
}: {
title: string;
defaultOpen?: boolean;
children: ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<section className="rounded border">
<button
className="flex w-full justify-between p-4 font-medium"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
>
{title}
<span>{open ? "−" : "+"}</span>
</button>
{open && <div className="border-t p-4">{children}</div>}
</section>
);
}
// app/account/page.tsx
import { Collapsible } from "@/app/ui/collapsible";
import { BillingHistory } from "./billing-history"; // async Server Component
export default function AccountPage() {
return (
<Collapsible title="Billing history">
<BillingHistory />
</Collapsible>
);
}
BillingHistory can be async, query the database, and use server-only modules. It's rendered by the page (its owner), so it runs on the server. Collapsible is only its parent in the tree, so it receives rendered output and decides where to put it.
One detail: the server renders BillingHistory even when the panel starts closed, because the server doesn't know about client state. For cheap content that's fine. For expensive content that's rarely opened, consider loading it on demand instead (for example, navigating to a route or using a Server Action when the panel opens).
Pattern 3: Named Slots
children is just a prop. Any prop typed as ReactNode can carry server-rendered content, which lets a Client Component lay out several server-rendered regions.
A tabbed interface is a good example. The tab state is client-side, but each panel's content can come from the server:
// app/ui/tabs.tsx
"use client";
import { useState, type ReactNode } from "react";
type Tab = { id: string; label: string; content: ReactNode };
export function Tabs({ tabs }: { tabs: Tab[] }) {
const [active, setActive] = useState(tabs[0]?.id);
return (
<div>
<div role="tablist" className="flex gap-2 border-b">
{tabs.map((tab) => (
<button
key={tab.id}
role="tab"
aria-selected={active === tab.id}
onClick={() => setActive(tab.id)}
className={active === tab.id ? "border-b-2 border-black" : ""}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab) => (
<div key={tab.id} role="tabpanel" hidden={active !== tab.id}>
{tab.content}
</div>
))}
</div>
);
}
// app/products/[id]/page.tsx
import { Tabs } from "@/app/ui/tabs";
import { Description } from "./description";
import { Specs } from "./specs";
import { Reviews } from "./reviews";
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return (
<Tabs
tabs={[
{
id: "desc",
label: "Description",
content: <Description productId={id} />,
},
{ id: "specs", label: "Specs", content: <Specs productId={id} /> },
{
id: "reviews",
label: "Reviews",
content: <Reviews productId={id} />,
},
]}
/>
);
}
All three panels are Server Components. I render inactive panels with hidden rather than unmounting them, so switching tabs is instant and the content is in the initial HTML for search engines.
The same technique works for layout shells: a Client Component with sidebar, header, and children props, where each region is server-rendered and the client only manages things like collapse state.
Pattern 4: Providers as Thin Client Wrappers
React context doesn't work in Server Components, but providers are often needed app-wide. Wrap the provider in a Client Component that accepts children, then render it from your layout:
// app/providers/theme-provider.tsx
"use client";
import { createContext, useContext, useState, type ReactNode } from "react";
type Theme = "light" | "dark";
const ThemeContext = createContext<{
theme: Theme;
setTheme: (t: Theme) => void;
} | null>(null);
export function ThemeProvider({
initialTheme,
children,
}: {
initialTheme: Theme;
children: ReactNode;
}) {
const [theme, setTheme] = useState<Theme>(initialTheme);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<div data-theme={theme}>{children}</div>
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used inside ThemeProvider");
return ctx;
}
// app/layout.tsx
import { cookies } from "next/headers";
import { ThemeProvider } from "./providers/theme-provider";
import "./globals.css";
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const cookieStore = await cookies();
const initialTheme =
cookieStore.get("theme")?.value === "dark" ? "dark" : "light";
return (
<html lang="en">
<body>
<ThemeProvider initialTheme={initialTheme}>{children}</ThemeProvider>
</body>
</html>
);
}
Wrapping children in a provider does not turn your pages into Client Components. They're still rendered on the server and passed through as output. Any Client Component deeper in the tree can call useTheme().
Two guidelines: place providers as deep as practical (wrap only children, not the whole <html> document, and move section-specific providers into section layouts), and pass providers minimal values. Whatever goes into the provider's props is serialized to the browser.
Pattern 5: Streaming Data into Client Components with Promises
Sometimes a Client Component needs server data, but you don't want the whole page to wait for it. Start the request in a Server Component without awaiting it, pass the promise down, and unwrap it with use() on the client:
// app/dashboard/page.tsx
import { Suspense } from "react";
import { getActivity } from "@/data/activity";
import { ActivityFeed } from "./activity-feed";
export default function DashboardPage() {
const activityPromise = getActivity(); // not awaited
return (
<div className="grid gap-6 lg:grid-cols-3">
<section className="lg:col-span-2">
<h1 className="text-2xl font-bold">Dashboard</h1>
</section>
<Suspense fallback={<p>Loading activity...</p>}>
<ActivityFeed activityPromise={activityPromise} />
</Suspense>
</div>
);
}
// app/dashboard/activity-feed.tsx
"use client";
import { use, useState } from "react";
type Activity = { id: string; message: string; createdAt: string };
export function ActivityFeed({
activityPromise,
}: {
activityPromise: Promise<Activity[]>;
}) {
const activity = use(activityPromise);
const [showAll, setShowAll] = useState(false);
const visible = showAll ? activity : activity.slice(0, 5);
return (
<aside>
<ul className="space-y-2">
{visible.map((item) => (
<li key={item.id}>{item.message}</li>
))}
</ul>
{activity.length > 5 && (
<button onClick={() => setShowAll((s) => !s)}>
{showAll ? "Show less" : `Show all ${activity.length}`}
</button>
)}
</aside>
);
}
The request starts on the server before any client code runs, so there's no client-side waterfall. The page shell renders immediately, the Suspense fallback shows, and the feed appears when the promise resolves. Make sure the promise resolves to a plain, minimal object, since its value is serialized like any other prop.
Sharing a Promise Through Context
If many Client Components need the same data, put the promise in context:
// app/providers/user-provider.tsx
"use client";
import { createContext, useContext, use, type ReactNode } from "react";
type User = { id: string; name: string };
const UserContext = createContext<Promise<User | null> | null>(null);
export function UserProvider({
userPromise,
children,
}: {
userPromise: Promise<User | null>;
children: ReactNode;
}) {
return (
<UserContext.Provider value={userPromise}>{children}</UserContext.Provider>
);
}
export function useUser() {
const promise = useContext(UserContext);
if (!promise) throw new Error("useUser must be used inside UserProvider");
return use(promise);
}
The layout calls getUser() without awaiting and passes the promise to UserProvider. Any Client Component can call useUser() inside a Suspense boundary. Wrap getUser in React's cache so server code that also needs the user shares the same request.
Pattern 6: Server Actions as Props
Ordinary functions can't be passed to Client Components, but Server Functions can. That lets a reusable Client Component handle the interaction while the Server Component decides what the action does:
// app/posts/[id]/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { getCurrentUser } from "@/data/auth";
import { toggleLike } from "@/data/likes";
export async function toggleLikeAction(postId: string) {
const user = await getCurrentUser();
if (!user) throw new Error("Unauthorized");
await toggleLike({ postId, userId: user.id });
revalidatePath(`/posts/${postId}`);
}
// app/ui/like-button.tsx
"use client";
import { useOptimistic, useTransition } from "react";
export function LikeButton({
liked,
count,
toggleAction,
}: {
liked: boolean;
count: number;
toggleAction: () => Promise<void>;
}) {
const [isPending, startTransition] = useTransition();
const [optimistic, setOptimistic] = useOptimistic(
{ liked, count },
(state) => ({
liked: !state.liked,
count: state.count + (state.liked ? -1 : 1),
}),
);
return (
<button
disabled={isPending}
onClick={() =>
startTransition(async () => {
setOptimistic(null);
await toggleAction();
})
}
>
{optimistic.liked ? "Unlike" : "Like"} ({optimistic.count})
</button>
);
}
// app/posts/[id]/page.tsx
import { LikeButton } from "@/app/ui/like-button";
import { getPostWithLikes } from "@/data/posts";
import { toggleLikeAction } from "./actions";
export default async function PostPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const post = await getPostWithLikes(id);
return (
<article>
<h1>{post.title}</h1>
<LikeButton
liked={post.likedByViewer}
count={post.likeCount}
toggleAction={toggleLikeAction.bind(null, id)}
/>
</article>
);
}
.bind(null, id) pre-fills the post ID and still produces a Server Function, so it can cross the boundary. The Action suffix on the prop name also tells the Next.js TypeScript plugin this function prop is intended to be a Server Function. Remember that the action is reachable by direct POST, so it re-checks the user itself rather than trusting the page.
Pattern 7: Server-Rendered Items in a Client List
A tricky case: a client-side filter over a list where each item is expensive to render (syntax-highlighted code, Markdown, rich cards). You want filtering in the browser but item rendering on the server.
Pass the items as data plus pre-rendered elements:
// app/snippets/page.tsx
import { getSnippets } from "@/data/snippets";
import { SnippetCard } from "./snippet-card"; // Server Component, uses a highlighter
import { FilterableList } from "./filterable-list";
export default async function SnippetsPage() {
const snippets = await getSnippets();
return (
<FilterableList
items={snippets.map((s) => ({
id: s.id,
searchText: `${s.title} ${s.language}`.toLowerCase(),
node: <SnippetCard snippet={s} />,
}))}
/>
);
}
// app/snippets/filterable-list.tsx
"use client";
import { useState, type ReactNode } from "react";
type Item = { id: string; searchText: string; node: ReactNode };
export function FilterableList({ items }: { items: Item[] }) {
const [query, setQuery] = useState("");
const q = query.trim().toLowerCase();
const visible = q ? items.filter((i) => i.searchText.includes(q)) : items;
return (
<div className="space-y-4">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter snippets"
className="w-full rounded border p-2"
/>
{visible.map((item) => (
<div key={item.id}>{item.node}</div>
))}
</div>
);
}
The highlighter never ships to the browser, and filtering is instant. This works well for lists in the dozens or low hundreds. For very large lists, filter on the server through searchParams instead.
Anti-Patterns and Their Fixes
| Anti-pattern | Why it hurts | Fix |
|---|---|---|
"use client" on a page to use one hook | Whole page and its imports ship to the browser | Extract the hook user into a leaf (Pattern 1) |
| Client Component imports a Server Component | Server code is pulled into the client graph, often failing | Pass it in as children or a slot (Patterns 2–3) |
| Render props from server to client | Functions can't be serialized | Pass rendered elements instead (Pattern 7) |
"use client" layout for an active nav link | The whole layout becomes client code | Small NavLink Client Component using usePathname |
Fetching in useEffect data the server already has | Extra round trip and loading flicker | Pass data or a promise from the server (Pattern 5) |
Provider wrapping the entire html element | Harder to optimize static parts | Wrap only children, as deep as practical |
Compound components like Tabs.Panel used from the server | Static members are undefined across the boundary | Export each piece as a named export |
Conclusion
Mixing Server and Client Components comes down to one move, repeated in different shapes: render on the server, then hand the output to the client as a prop. children covers wrappers, named ReactNode props cover multi-region layouts, providers are thin client wrappers around server-rendered trees, promises stream data into client state, and Server Functions let client UI trigger server work.
Start with the page as a Server Component, find the smallest piece that needs interactivity, and pick the pattern that lets everything around it stay on the server. For the foundation these patterns rest on, see a practical mental model for React Server Components.


