Composing Server and Client Components — Architecture Patterns
In this tutorial, you will learn about Composing Server and Client Components. We cover key concepts, practical examples, and best practices to help you master this topic.
Composing Server and Client Components requires understanding how they interact, what crosses the boundary, and which patterns keep your architecture clean.
What You'll Learn
You will learn composition patterns for mixing Server and Client Components, how to pass data across the boundary, and how to structure component hierarchies.
Why It Matters
Poor composition leads to bloated bundles, broken hydration, and hard-to-maintain code. Clean composition patterns maximize the benefits of both component types.
Real-World Use
DodaTech's tutorial platform uses a Server Component shell for the page layout and SEO metadata, with Client Components embedded for the search bar, code sandbox, and interactive quiz elements.
flowchart TD
A[Page Server Component] --> B[Header Client]
A --> C[Content Server]
A --> D[Sidebar Client]
C --> E[Interactive Code Sandbox Client]
C --> F[Article Text Server]
C --> G[Like Button Client]
D --> H[Table of Contents Server]
D --> I[Search Client]
style A fill:#1e293b,color:#fff
style C fill:#1e293b,color:#fff
style B fill:#0f172a,color:#fff
style D fill:#0f172a,color:#fff
The Wrapper Pattern
Use a Client Component as an interactive wrapper around Server Component content.
'use client';
function ExpandableCard({ title, children }) {
const [expanded, setExpanded] = useState(false);
return (
<div style={{ border: '1px solid #ccc', borderRadius: '8px', margin: '8px 0' }}>
<button onClick={() => setExpanded(!expanded)} style={{ width: '100%', padding: '12px', textAlign: 'left' }}>
{title} {expanded ? '▲' : '▼'}
</button>
{expanded && <div style={{ padding: '12px' }}>{children}</div>}
</div>
);
}
// Server Component using the wrapper
async function FAQPage() {
const faqs = await db.faqs.findAll();
return (
<div>
<h1>Frequently Asked Questions</h1>
{faqs.map(faq => (
<ExpandableCard key={faq.id} title={faq.question}>
<p>{faq.answer}</p>
</ExpandableCard>
))}
</div>
);
}
Expected output: The FAQ page renders as a list of expandable cards. The card wrapper is a Client Component handling the toggle state. The content inside each card is static HTML from the Server Component.
Passing Client Components as Props
Server Components can pass Client Components as props to other Server Components.
'use client';
function DeleteButton({ itemId, onDelete }) {
const [confirming, setConfirming] = useState(false);
return (
<span>
<button onClick={() => setConfirming(true)}>Delete</button>
{confirming && (
<span>
<button onClick={() => onDelete(itemId)}>Confirm</button>
<button onClick={() => setConfirming(false)}>Cancel</button>
</span>
)}
</span>
);
}
// Server Component
async function ItemList({ renderActions }) {
const items = await db.items.findAll();
return (
<ul>
{items.map(item => (
<li key={item.id}>
{item.name}
{renderActions(item)}
</li>
))}
</ul>
);
}
// Page combines them
export default function Page() {
return (
<ItemList
renderActions={(item) => <DeleteButton itemId={item.id} />}
/>
);
}
Expected output: A list of items with a delete button next to each. Clicking Delete shows a confirm/cancel prompt. The DeleteButton is a Client Component passed as a render prop to the Server Component.
Lifting Interactive Boundaries
When a Server Component needs interactivity, lift the interactive part into a separate Client Component.
// Instead of making the whole page a Client Component, extract the interactive part
'use client';
function SearchSection({ initialData }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState(initialData);
async function handleSearch(e) {
const q = e.target.value;
setQuery(q);
if (q.length > 2) {
const res = await fetch(`/api/search?q=${q}`);
setResults(await res.json());
} else {
setResults(initialData);
}
}
return (
<div>
<input value={query} onChange={handleSearch} placeholder="Search..." />
<ul>
{results.map(r => <li key={r.id}>{r.title}</li>)}
</ul>
</div>
);
}
// Page stays as Server Component
async function SearchPage() {
const initialData = await db.items.findRecent();
return (
<div>
<h1>Search</h1>
<SearchSection initialData={initialData} />
</div>
);
}
Expected output: The page shell is a Server Component fetching initial data. The SearchSection is a Client Component that handles user input and filtering. Only the interactive search logic ships to the client.
Server Components Inside Client Components
Pass Server Components as children to Client Components using the children prop.
'use client';
function Modal({ isOpen, onClose, children }) {
if (!isOpen) return null;
return (
<div style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', background: 'rgba(0,0,0,0.5)' }}>
<div style={{ background: 'white', padding: '24px', margin: '10% auto', maxWidth: '500px' }}>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>
);
}
// Usage
async function ProductPage({ params }) {
const product = await db.products.findById(params.id);
return (
<div>
<h1>{product.name}</h1>
<Modal isOpen={true}>
<h2>Product Details</h2>
<p>{product.description}</p>
<p>Price: ${product.price}</p>
</Modal>
</div>
);
}
Expected output: The Server Component renders product data inside a Modal Client Component. The modal content is Server-rendered HTML passed as children. The modal's open/close state is managed client-side.
Common Mistakes
Making entire pages Client Components: Only the interactive parts need
'use client'. Keep the page shell and data-fetching sections as Server Components.Trying to import Server Components into Client Components: This is not allowed. Pass Server Components as children or props instead.
Creating unnecessary Serialization boundaries: Every boundary between Server and Client serializes props. Keep boundaries where they make sense, not everywhere.
Putting context providers in Server Components: Context providers must be in Client Components because they manage state. Wrap the app in a Client Component provider.
Not extracting interactive leaf components early: When a Server Component grows interactive needs, extract the interactive part into a separate Client Component file.
Practice Questions
- How do you pass a Server Component inside a Client Component?
Pass it as the children prop or as a render prop. The Client Component renders it without knowing it is a Server Component.
- What is the wrapper pattern?
A Client Component wraps Server Component content as children, adding interactivity (expand, collapse, toggle) without making the content client-rendered.
- Why can't you import Server Components in Client Component files?
Server Components have server-only dependencies and cannot be bundled for the client. The framework prevents this to avoid leaking server code.
- How do you add search to a Server Component page?
Extract the search input and results into a separate Client Component. The Server Component passes initial data as props.
- What happens to the serialization overhead with deep nesting?
Each server-client boundary serializes props. Deep nesting adds overhead. Keep the component tree relatively flat across boundaries.
Challenge
Build a product catalog page where the product grid is a Server Component (fetches from DB), each product card is wrapped in a Client Component (handles add-to-cart click), and there is a Client Component cart sidebar. Pass the cart data via props.
Frequently Asked Questions
Mini Project
Build a blog with a Server Component fetching the article content, a Client Component comment form, a Client Component for the share buttons, and a Client Component reading progress bar that tracks scroll position.
What's Next
Learn about Serializable Props and what can safely cross the server-client boundary.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro