Next.js

Server Components vs Client Components

35 min Lesson 6 of 40

Understanding Server Components vs Client Components

Next.js 13 introduced the React Server Components (RSC) architecture, fundamentally changing how we think about component rendering. This lesson explores the differences between Server Components and Client Components, when to use each, and how they work together.

What Are Server Components?

Server Components are React components that render exclusively on the server. They never send JavaScript to the client, making them incredibly efficient for data fetching and reducing bundle sizes.

Key Concept: By default, all components in the Next.js App Router are Server Components. This is a fundamental shift from the traditional React model where everything runs on the client.

Benefits of Server Components

  • Zero JavaScript Bundle: Server Components don't add any JavaScript to the client bundle
  • Direct Backend Access: Can directly access databases, file systems, and server-only resources
  • Improved Security: Sensitive data and API keys never reach the client
  • Better Performance: Data fetching happens on the server, closer to data sources
  • Automatic Code Splitting: Only Client Components are split and loaded on demand

Server Component Example

// app/posts/page.tsx (Server Component by default) import { prisma } from '@/lib/prisma'; export default async function PostsPage() { // Direct database access - no API route needed const posts = await prisma.post.findMany({ orderBy: { createdAt: 'desc' }, take: 10 }); return ( <div> <h1>Recent Posts</h1> <ul> {posts.map(post => ( <li key={post.id}> <h2>{post.title}</h2> <p>{post.excerpt}</p> </li> ))} </ul> </div> ); }

What Are Client Components?

Client Components are traditional React components that render on the client side. They enable interactivity, browser APIs, and React hooks like useState and useEffect.

When to Use Client Components

You need Client Components when your component requires:

  • Interactivity: Click handlers, form inputs, event listeners
  • State Management: useState, useReducer, useContext
  • Effects: useEffect for side effects, subscriptions, timers
  • Browser APIs: localStorage, window, navigator, geolocation
  • Custom Hooks: Any hooks that depend on client-side features
  • Class Components: Lifecycle methods (though rare in modern React)
Best Practice: Use Server Components by default and only opt into Client Components when you need interactivity or browser-specific features. This keeps your bundle size minimal.

The 'use client' Directive

To mark a component as a Client Component, add the 'use client' directive at the top of the file:

'use client'; import { useState } from 'react'; export default function Counter() { const [count, setCount] = useState(0); return ( <div> <p>Count: {count}</p> <button onClick={() => setCount(count + 1)}> Increment </button> </div> ); }
Important: The 'use client' directive only needs to be added to files that use client-side features. Any components imported into a Client Component automatically become Client Components.

Composition Patterns

Understanding how to compose Server and Client Components is crucial for building efficient Next.js applications.

Pattern 1: Server Component with Client Component Children

// app/dashboard/page.tsx (Server Component) import { getUser } from '@/lib/auth'; import Counter from '@/components/Counter'; // Client Component export default async function DashboardPage() { const user = await getUser(); // Server-side data fetching return ( <div> <h1>Welcome, {user.name}!</h1> {/* Server-rendered content */} <p>Your email: {user.email}</p> {/* Client Component for interactivity */} <Counter /> </div> ); }

Pattern 2: Passing Server Components as Props

You can pass Server Components as children or props to Client Components:

// app/layout.tsx (Server Component) import Sidebar from '@/components/Sidebar'; // Client Component import Feed from '@/components/Feed'; // Server Component export default function Layout({ children }) { return ( <html> <body> {/* Pass Server Component as children */} <Sidebar> <Feed /> {/* This stays a Server Component */} </Sidebar> {children} </body> </html> ); } // components/Sidebar.tsx (Client Component) 'use client'; import { useState } from 'react'; export default function Sidebar({ children }) { const [collapsed, setCollapsed] = useState(false); return ( <aside className={collapsed ? 'collapsed' : ''}> <button onClick={() => setCollapsed(!collapsed)}> Toggle </button> {children} {/* Server Component rendered here */} </aside> ); }
Why This Works: The Client Component doesn't need to know what 'children' is. It just renders it. The Server Component is serialized and passed as props, maintaining its server-side nature.

Pattern 3: Sharing Data Between Components

// app/products/page.tsx (Server Component) import { getProducts } from '@/lib/db'; import ProductList from '@/components/ProductList'; // Client Component export default async function ProductsPage() { const products = await getProducts(); // Pass server-fetched data as props to Client Component return <ProductList initialProducts={products} />; } // components/ProductList.tsx (Client Component) 'use client'; import { useState } from 'react'; export default function ProductList({ initialProducts }) { const [products, setProducts] = useState(initialProducts); const [filter, setFilter] = useState(''); const filteredProducts = products.filter(p => p.name.toLowerCase().includes(filter.toLowerCase()) ); return ( <div> <input type="text" placeholder="Filter products..." value={filter} onChange={(e) => setFilter(e.target.value)} /> <ul> {filteredProducts.map(product => ( <li key={product.id}>{product.name}</li> ))} </ul> </div> ); }

Data Flow and Serialization

When passing data from Server Components to Client Components, Next.js serializes the data. This means only JSON-serializable data can be passed:

Cannot Pass:
  • Functions
  • Dates (will be converted to strings)
  • Class instances
  • undefined values
  • Symbols
// ❌ This will NOT work export default async function Page() { const handleClick = () => console.log('clicked'); return <ClientComponent onClick={handleClick} />; } // ✅ This works - define the handler in the Client Component // Server Component export default async function Page() { return <ClientComponent />; } // Client Component 'use client'; export default function ClientComponent() { const handleClick = () => console.log('clicked'); return <button onClick={handleClick}>Click</button>; }

Network Boundary

The 'use client' directive creates a boundary between server and client code. Everything imported into a Client Component becomes part of the client bundle:

// components/DataDisplay.tsx 'use client'; // This marks the boundary import { format } from 'date-fns'; // Will be included in client bundle import { useState } from 'react'; export default function DataDisplay({ data }) { const [expanded, setExpanded] = useState(false); return ( <div> <button onClick={() => setExpanded(!expanded)}> Toggle </button> {expanded && ( <p>{format(new Date(data.date), 'PPP')}</p> )} </div> ); }
Optimization Tip: If you only need client-side code in part of your component tree, create separate Client Components for just those parts. This keeps as much code as possible in Server Components.

Common Pitfalls

1. Importing Server-Only Code in Client Components

// ❌ This will cause an error 'use client'; import { prisma } from '@/lib/prisma'; // Server-only library export default function Component() { // This will fail - prisma can't run in the browser const data = await prisma.user.findMany(); return <div>{data}</div>; }

2. Using 'use client' Unnecessarily

// ❌ Unnecessary - no client features used 'use client'; export default function Header({ title }) { return <h1>{title}</h1>; } // ✅ Better - keep as Server Component export default function Header({ title }) { return <h1>{title}</h1>; }

3. Trying to Import Server Components into Client Components

// ❌ This will NOT work as expected 'use client'; import ServerComponent from './ServerComponent'; // Will become a Client Component export default function ClientComponent() { return <ServerComponent />; // No longer runs on server } // ✅ Better - pass as children // In parent Server Component <ClientComponent> <ServerComponent /> </ClientComponent>

Decision Tree: Server or Client Component?

Ask yourself these questions:
  1. Do I need interactivity? (clicks, inputs) → Client Component
  2. Do I need React state or effects? → Client Component
  3. Do I need browser APIs? → Client Component
  4. Am I just displaying data? → Server Component
  5. Do I need to fetch data? → Server Component (preferred)
  6. Do I need to access databases directly? → Server Component

Practice Exercise

Task: Create a blog post page that:

  1. Fetches post data from a database (Server Component)
  2. Displays the post content (Server Component)
  3. Has a "Like" button with a counter (Client Component)
  4. Has a comment form (Client Component)
  5. Displays existing comments (Server Component)

Requirements:

  • Use Server Components for all data fetching
  • Use Client Components only where interactivity is needed
  • Pass data from Server to Client Components correctly
  • Ensure proper composition of components

Bonus Challenge: Implement optimistic UI updates for the like button and comments without refetching the entire page.

Summary

  • Server Components render on the server and send zero JavaScript to the client
  • Client Components enable interactivity and use the 'use client' directive
  • By default, all components in Next.js App Router are Server Components
  • Server Components can directly access databases and server-only resources
  • Client Components are needed for state, effects, event handlers, and browser APIs
  • You can compose Server and Client Components by passing children or props
  • Data passed between Server and Client Components must be JSON-serializable
  • Use Server Components by default and opt into Client Components only when needed