React Components Composition: How-to
Composition—the art of building complex UIs from simpler pieces—changes in the world of Server Components. You cannot nest server components arbitrarily like traditional React. You must understand the server-client boundary, async component limitations, and how to structure files for maximum reusability. This article teaches you the composition patterns that scale: layout composition, children as props, wrapper patterns, and how to share logic between server and client.
The Composition Boundary
Server components and client components compose differently than traditional React components. The fundamental rule: a server component can render a client component as a child, but a client component cannot directly render a server component.
This creates a specific composition pattern:
// ✅ Correct: server parent, client child
async function ServerLayout() {
const data = await fetchData();
return (
<div>
<h1>{data.title}</h1>
<ClientInteractiveWidget /> {/* OK: server renders client child */}
</div>
);
}
// ❌ Incorrect: client parent, server child
'use client';
function ClientLayout() {
return (
<div>
<ServerComponent /> {/* Error: cannot render server component from client */}
</div>
);
}
To work around this, use the children pattern: pass a server component as a child prop to a client component through the server parent.
Pattern 1: Layout Composition with Children
Layouts in Next.js are server components by default. They render children passed from the router. You can inject client wrappers (for context, providers, styling) without breaking the server-first architecture.
// app/layout.tsx — root layout (server component)
import { ClientThemeProvider } from './client-theme-provider';
import { ClientAnalytics } from './client-analytics';
export default function RootLayout({ children }) {
// children: all the server components from the route handler
// passed as props through the router
return (
<html>
<body>
<ClientAnalytics />
<ClientThemeProvider>
{children} {/* Client wraps server components */}
</ClientThemeProvider>
</body>
</html>
);
}
// components/client-theme-provider.tsx
'use client';
import { createContext, useState } from 'react';
const ThemeContext = createContext();
export function ClientThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}
// components/client-analytics.tsx
'use client';
import { useEffect } from 'react';
export function ClientAnalytics() {
useEffect(() => {
// Initialize analytics on client
window.gtag?.('config', 'GA_ID');
}, []);
return null;
}
The router passes server-component children through the layout. The layout wraps them with client context and providers. The server components inside children can use the client context through useContext hooks in client sub-components.
Pattern 2: Async Leaf Components
Compose async server components as leaf components (no children). This is the simplest pattern and avoids composition complexity.
// ✅ Leaf component: async, standalone
async function BlogPostCard({ postId }) {
const post = await db.posts.findById(postId);
return (
<div className="card">
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</div>
);
}
// Use it in a server parent
async function BlogList() {
const postIds = [1, 2, 3, 4, 5];
return (
<div>
{postIds.map(id => (
<BlogPostCard key={id} postId={id} />
))}
</div>
);
}
Each card fetches its own data. For independent, leaf-level components, this is efficient and simple. If you need to fetch data for all cards together (to batch database queries), move the fetch to the parent.
Pattern 3: Data Fetching at the Parent Level
To avoid N+1 queries and enable efficient data loading, fetch data in the parent component and pass it to children as props.
// ✅ Fetch once at the parent level
async function BlogList() {
// Fetch all posts at once
const posts = await db.posts.findAll();
return (
<div>
{posts.map(post => (
<BlogPostCard key={post.id} post={post} />
))}
</div>
);
}
// Child receives data as a prop
function BlogPostCard({ post }) {
return (
<div className="card">
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</div>
);
}
This pattern is more efficient than leaf-level fetches when children share a data dependency. The parent fetches once; children receive the serialized data as props.
Pattern 4: Shared Logic with Module Exports
Extract data-fetching and business logic into shared utility modules. Both server and client components can import and use these utilities.
// lib/db-queries.ts — shared data-fetching logic
// No 'use client' directive; this is server-side only
export async function getUser(userId: number) {
return db.users.findById(userId);
}
export async function getPostsByUser(userId: number) {
return db.posts.findMany({ where: { authorId: userId } });
}
export async function getCommentsByPost(postId: number) {
return db.comments.findMany({ where: { postId } });
}
// app/user/[id]/page.tsx — server component
import { getUser, getPostsByUser } from '@/lib/db-queries';
async function UserPage({ params }) {
const [user, posts] = await Promise.all([
getUser(params.id),
getPostsByUser(params.id),
]);
return (
<div>
<h1>{user.name}</h1>
<UserPosts posts={posts} />
</div>
);
}
// components/user-posts.tsx — can be server or client
function UserPosts({ posts }) {
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
Shared utilities are pure functions with no state. They can be called from any context—server components, server actions, or utility functions.
Pattern 5: Wrapper Components for Styling and Structure
Create simple wrapper components (server or client) to apply consistent styling and structure without fetching data or managing state.
// components/card.tsx — simple server component wrapper
interface CardProps {
title: string;
children: React.ReactNode;
}
export function Card({ title, children }: CardProps) {
return (
<div className="card">
<h2 className="card-title">{title}</h2>
<div className="card-content">
{children}
</div>
</div>
);
}
// Usage in a server component
async function BlogPage() {
const post = await db.posts.findById(1);
return (
<Card title={post.title}>
<p>{post.content}</p>
</Card>
);
}
These wrapper components are lightweight and reusable. They don't fetch data or manage state; they apply structure and styling.
Pattern 6: Client Components for Conditional Rendering
Use client components to conditionally render server components passed as children.
// components/lazy-loader.tsx — client component
'use client';
import { useState } from 'react';
export function LazyLoader({ children, label = 'Load more' }) {
const [isLoaded, setIsLoaded] = useState(false);
return (
<div>
{isLoaded && children}
{!isLoaded && <button onClick={() => setIsLoaded(true)}>{label}</button>}
</div>
);
}
// Usage in a server component
async function HeavyData() {
const data = await db.heavyQuery();
return (
<LazyLoader label="Load details">
<div>{data.details}</div>
</LazyLoader>
);
}
The client component controls visibility; the server component passes data as children. This pattern is useful for progressive disclosure and infinite scroll.
Pattern 7: Prop Drilling vs. Context in Server Components
Server components cannot use React context (it's a client feature). For shared data, either pass props or use server-only utilities.
// ❌ Context doesn't work in server components
const ThemeContext = createContext();
async function Layout() {
const theme = 'dark';
return (
<ThemeContext.Provider value={theme}>
<Header /> {/* Error: context not available in server component */}
</ThemeContext.Provider>
);
}
// ✅ Pass theme as a prop
async function Layout() {
const theme = 'dark';
return <Header theme={theme} />;
}
For client-side theme switching, combine server props with client context:
// app/layout.tsx — server component
import { ClientThemeProvider } from './client-theme-provider';
const defaultTheme = 'light';
export default function Layout({ children }) {
return (
<ClientThemeProvider defaultTheme={defaultTheme}>
{children}
</ClientThemeProvider>
);
}
// components/client-theme-provider.tsx
'use client';
import { createContext, useState } from 'react';
const ThemeContext = createContext();
export function ClientThemeProvider({ defaultTheme, children }) {
const [theme, setTheme] = useState(defaultTheme);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
Server components receive default values; client sub-components manage state and provide context to other client components.
Key Takeaways
- Server components can render client components as children; client components cannot render server components.
- Use layout composition to inject client context and providers around server-component content.
- Fetch data at the parent level to avoid N+1 queries; pass data to children as serializable props.
- Extract shared logic into utility modules that server components and server actions can import.
- Use wrapper components for styling and structure; use client components for interactivity.
- For shared data in server components, pass props; for client-side state, use client context.
Frequently Asked Questions
Can an async server component have async children?
No. A server component's children must be either other server components or client components. An async server component can contain another async server component, but the children must be known at render time (not created dynamically within async code).
How do I fetch data for multiple children without prop drilling?
Fetch in the parent and pass data as props. If prop drilling becomes unwieldy, consider restructuring the component tree or creating a shared utility module that children can import directly.
Can I use a layout for both server and client components?
Yes. A Next.js layout is a server component, but it can wrap client components and server components together. Client sub-components can use hooks and context; server components can fetch data.
How do I share authentication state between server and client components?
Fetch the authenticated user in the server layout and pass it as a prop to a client provider. The client provider can create a context that all client sub-components can access.
What if I need to conditionally render components based on server-side data?
Do the conditional logic in the server component and render one of several server or client components based on the condition. The decision is made on the server; the client receives the chosen component.