Client‑Server Component Boundaries
Right boundary = smaller bundles (the JavaScript files sent to the browser), better security, faster loads. Wrong boundary = pain. Server Components execute only on the server and never ship to the browser. Client Components are prerendered to HTML on the server, then hydrate and become interactive in the browser.
VIDEO PLACEHOLDER · 5-7 min · EVERGREEN · High Priority
Decision Framework: When to Use Server vs Client Components
A visual decision tree for choosing component types. Walks through the questions you should ask: Does it need hooks? Browser APIs? User interaction? Shows how the "use client" directive creates boundaries and what that means for your bundle.
Outcome
A repeatable decision model with concrete examples in both apps.
This lesson primarily uses apps/web to demonstrate client-server boundaries. You'll create two demo routes: /demo/counter (minimal client boundary) and /demo/cards (server children pattern). At the end, you'll apply the same pattern in apps/blog to show the monorepo consistency.
Fast Track
- Identify components that need hooks or browser APIs → mark
'use client'. - Keep data fetching and heavy work in Server Components.
- Pass server-rendered children into small client wrappers.
Hands-On Exercise 2.1
Build components that demonstrate the decision model for Server vs Client Component boundaries.
Requirements:
- Convert at least one UI to a small client wrapper consuming server-rendered children.
- Document the decision for two components (why server vs client).
- Ensure no secrets are accessed in client boundaries.
- Show the performance impact of component choice (bundle size).
Implementation hints:
- Decision model: Server Components for data fetching, static content, database operations, heavy computations. Client Components for browser APIs, user interactions, React hooks, real-time features.
- Performance implications: Server Components reduce JavaScript bundle size; Client Components enable interactivity but increase bundle.
- Anti-pattern: Large Client Component boundaries force entire tree client-side, bloating bundle.
- Pattern: Extract small client wrappers (e.g., just the interactive button), keep rest as Server Components.
- Co-locate small client components; keep imports clean.
- Start with Server Components by default; only add
'use client'when needed.
Unsure whether a component should be a Server or Client Component? Use this prompt to analyze your component:
<context>
I'm building a Next.js application using App Router with Server and Client Components.
I have a component that I need to decide whether to make a Server Component (default) or Client Component ('use client').
</context>
<current-implementation>
[Paste your component code here]
</current-implementation>
<questions>
1. **Interactivity:** Does this component use React hooks (useState, useEffect, etc.) or event handlers (onClick, onChange)?
2. **Browser APIs:** Does it need access to window, localStorage, or other browser-only APIs?
3. **Data fetching:** Does it fetch data from a database or external API?
4. **Heavy dependencies:** Does it import large libraries that would bloat the client bundle?
5. **Secrets:** Does it access environment variables or API keys that must stay server-side?
6. **Children pattern:** Could I extract just the interactive parts into a small client wrapper and keep the rest as a Server Component?
</questions>
<specific-scenario>
Component purpose: [Describe what your component does]
Current dependencies: [List major imports]
Interactivity needs: [Describe user interactions]
Data requirements: [Describe data sources]
</specific-scenario>
Recommend whether this should be a Server or Client Component with rationale. If Client Component, suggest ways to minimize the client boundary by extracting Server Component children.This analysis will help you make informed boundary decisions and optimize bundle size.
Marking a component with 'use client' includes all its dependencies in the browser bundle. Keep client boundaries small and specific to avoid shipping unnecessary JavaScript.
Try It
- Interact with the client wrapper; confirm server children stream as expected.
Commit & Deploy
git add -A
git commit -m "feat(core): add server/client boundary decisions with examples"
git push -u origin feat/core-boundariesDone-When
- Navigate to
/demo/counterand click + button: counter increments without page reload - Server timestamp on
/demo/counterpage does not change when clicking counter buttons - Navigate to
/demo/cardsand click Collapse/Expand button: product cards toggle visibility without page reload - Open DevTools Network tab on
/demo/counter, click counter buttons: no network requests fire (client-side state only) - View page source on
/demo/cards: product card HTML is present (server-rendered, not client-injected)
Solution
Solution
Prerequisites
No additional dependencies required. The starter already includes everything needed.
Step 1: Create a Counter Demo Page (Server Component)
This page demonstrates the boundary pattern: a Server Component page that renders server-side content alongside a small interactive client widget.
// No 'use client' - this is a Server Component by default
// Data fetching and heavy logic stays here on the server
import { Counter } from '@/components/counter'
// Simulated server-side data
async function getServerTimestamp(): Promise<string> {
// This runs ONLY on the server
return new Date().toISOString()
}
export default async function CounterDemoPage() {
// Server-side data fetching (no JS shipped for this)
const serverTimestamp = await getServerTimestamp()
return (
<main className="flex flex-col gap-8 p-8">
<div>
<h1 className="font-bold text-3xl">Server/Client Boundary Demo</h1>
<p className="mt-2 text-muted-foreground">
This page is a Server Component. The counter below is a Client Component.
</p>
</div>
{/* Server-rendered content (no JS) */}
<section className="rounded-lg border bg-muted/50 p-6">
<h2 className="font-semibold text-lg">Server-Rendered Content</h2>
<p className="mt-2 text-sm text-muted-foreground">
Generated at: <code className="font-mono text-xs">{serverTimestamp}</code>
</p>
<p className="mt-1 text-sm text-muted-foreground">
This content ships as HTML with zero JavaScript.
</p>
</section>
{/* Client Component - interactive widget */}
<section className="rounded-lg border bg-muted/50 p-6">
<h2 className="font-semibold text-lg">Client Component (Interactive)</h2>
<p className="mb-4 text-sm text-muted-foreground">
Only this counter component ships JavaScript to the browser.
</p>
<Counter initialCount={0} />
</section>
{/* Decision documentation */}
<section className="rounded-lg border bg-muted/50 p-6">
<h2 className="font-semibold text-lg">Decision Rationale</h2>
<ul className="mt-2 list-inside list-disc space-y-1 text-sm text-muted-foreground">
<li>
<strong>Page (Server):</strong> Fetches data, renders static content, no interactivity needed
</li>
<li>
<strong>Counter (Client):</strong> Uses useState hook, handles onClick events
</li>
</ul>
</section>
</main>
)
}Step 2: Create the Counter Client Component
This is the minimal client boundary: only the interactive widget gets 'use client'.
'use client'
// Only import what's needed for this tiny component
import { useState } from 'react'
import { Button } from '@repo/ui/components/button'
interface CounterProps {
initialCount: number
}
/**
* A minimal client component demonstrating useState + onClick.
* Only this component ships JavaScript to the browser.
*/
export function Counter({ initialCount }: CounterProps) {
const [count, setCount] = useState(initialCount)
return (
<div className="flex items-center gap-4">
<Button
type="button"
variant="outline"
onClick={() => setCount((c) => c - 1)}
>
-
</Button>
<span className="min-w-[3ch] text-center font-mono text-2xl">{count}</span>
<Button
type="button"
variant="outline"
onClick={() => setCount((c) => c + 1)}
>
+
</Button>
</div>
)
}Step 3: Create a Card List Demo (Server Children Pattern)
This demonstrates passing server-rendered children through a client wrapper.
// Server Component - fetches data, renders heavy content
import { ExpandableWrapper } from '@/components/expandable-wrapper'
// Simulated products from database
const products = [
{ id: '1', name: 'Widget Pro', description: 'Professional-grade widget', price: 99 },
{ id: '2', name: 'Gadget Plus', description: 'Enhanced gadget features', price: 149 },
{ id: '3', name: 'Tool Master', description: 'Master your workflow', price: 199 },
]
export default function CardsDemoPage() {
return (
<main className="flex flex-col gap-8 p-8">
<div>
<h1 className="font-bold text-3xl">Server Children Pattern</h1>
<p className="mt-2 text-muted-foreground">
Heavy card content renders on server. Only the expand/collapse is client-side.
</p>
</div>
{/* Client wrapper with server-rendered children */}
<ExpandableWrapper title="Product Catalog">
{/* These cards are Server Components - zero JS */}
<div className="grid gap-4 md:grid-cols-3">
{products.map((product) => (
<div
key={product.id}
className="rounded-lg border bg-card p-4 shadow-sm"
>
<h3 className="font-semibold">{product.name}</h3>
<p className="mt-1 text-sm text-muted-foreground">
{product.description}
</p>
<p className="mt-2 font-mono text-lg">${product.price}</p>
</div>
))}
</div>
</ExpandableWrapper>
{/* Decision documentation */}
<section className="rounded-lg border bg-muted/50 p-6">
<h2 className="font-semibold text-lg">Decision Rationale</h2>
<ul className="mt-2 list-inside list-disc space-y-1 text-sm text-muted-foreground">
<li>
<strong>ExpandableWrapper (Client):</strong> Uses useState for expand/collapse toggle
</li>
<li>
<strong>Product Cards (Server):</strong> Static content, no interactivity, rendered as HTML
</li>
<li>
<strong>Pattern:</strong> Server children stream through client wrapper untouched
</li>
</ul>
</section>
</main>
)
}Step 4: Create the Expandable Wrapper Client Component
A small client wrapper that accepts server-rendered children.
'use client'
import { useState, type ReactNode } from 'react'
import { Button } from '@repo/ui/components/button'
interface ExpandableWrapperProps {
children: ReactNode
title: string
}
/**
* A minimal client wrapper that shows/hides server-rendered content.
* The children prop accepts Server Components that stream through unchanged.
*/
export function ExpandableWrapper({ children, title }: ExpandableWrapperProps) {
const [isExpanded, setIsExpanded] = useState(true)
return (
<div className="rounded-lg border bg-muted/30 p-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-lg">{title}</h2>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setIsExpanded((prev) => !prev)}
>
{isExpanded ? 'Collapse' : 'Expand'}
</Button>
</div>
{/* Server-rendered children passed through */}
{isExpanded && <div className="mt-4">{children}</div>}
</div>
)
}Step 5: Add the Same Pattern to Blog App
Now you'll implement the same boundary pattern in apps/blog to demonstrate monorepo consistency. The goal is to show that Next.js patterns work identically across multiple apps in your workspace. You're not building a blog-specific feature—you're proving the pattern is universal and reusable.
Why both apps? This lesson teaches a foundational pattern that applies everywhere. By implementing it in both apps/web and apps/blog, you learn that your component architecture decisions travel with you across any Next.js app in your monorepo.
Demonstrate the same boundary pattern works across apps in the monorepo.
// Server Component - demonstrates boundary pattern in blog app
import { Counter } from '@/components/counter'
export default function BoundaryDemoPage() {
return (
<main className="flex flex-col gap-8 p-8">
<div>
<h1 className="font-bold text-3xl">Blog App: Boundary Demo</h1>
<p className="mt-2 text-muted-foreground">
Same pattern works across all apps in the monorepo.
</p>
</div>
{/* Server-rendered content */}
<section className="rounded-lg border bg-muted/50 p-6">
<h2 className="font-semibold text-lg">Server Content</h2>
<p className="mt-2 text-sm text-muted-foreground">
This article preview renders server-side with zero JavaScript.
</p>
</section>
{/* Client Component - reused from web app pattern */}
<section className="rounded-lg border bg-muted/50 p-6">
<h2 className="font-semibold text-lg">Interactive Widget</h2>
<Counter initialCount={10} />
</section>
</main>
)
}'use client'
import { useState } from 'react'
import { Button } from '@repo/ui/components/button'
interface CounterProps {
initialCount: number
}
export function Counter({ initialCount }: CounterProps) {
const [count, setCount] = useState(initialCount)
return (
<div className="flex items-center gap-4">
<Button
type="button"
variant="outline"
onClick={() => setCount((c) => c - 1)}
>
-
</Button>
<span className="min-w-[3ch] text-center font-mono text-2xl">{count}</span>
<Button
type="button"
variant="outline"
onClick={() => setCount((c) => c + 1)}
>
+
</Button>
</div>
)
}File Structure
apps/
├── web/src/
│ ├── app/demo/
│ │ ├── counter/page.tsx # Server Component with client widget
│ │ └── cards/page.tsx # Server children through client wrapper
│ └── components/
│ ├── counter.tsx # Client Component (useState, onClick)
│ └── expandable-wrapper.tsx # Client wrapper for server children
└── blog/src/
├── app/demo/boundary/page.tsx # Same pattern in blog app
└── components/counter.tsx # Duplicated for blog app
Verification
- Visit
/demo/counter- Counter increments/decrements while timestamp stays static - Visit
/demo/cards- Cards expand/collapse, heavy content renders server-side - Check DevTools Network tab - Only client components ship JavaScript
- Run
pnpm build- Compare bundle sizes between server and client components
Decision Checklist Applied
| Component | Decision | Reason |
|---|---|---|
page.tsx files | Server | Data fetching, static rendering |
Counter | Client | Uses useState, onClick handlers |
ExpandableWrapper | Client | Uses useState for toggle |
| Product cards | Server | Static content, no interactivity |
References
- https://nextjs.org/docs/app/getting-started/server-and-client-components
- https://nextjs.org/docs/app/building-your-application/optimizing/bundle-analyzer
- https://react.dev/reference/rsc/server-components - React Server Components
- https://react.dev/reference/rsc/use-client - React 'use client' directive
- https://react.dev/reference/react/useState - React useState hook
- https://react.dev/reference/react/useEffect - React useEffect hook
Was this helpful?