Next.js Server Components Explained: What They Are and Why They Matter
A clear explanation of React Server Components in Next.js — what problem they solve, how they differ from Client Components, and the patterns that make them powerful.
Server Components are the most significant architectural change to React in years, and they're the foundation of the Next.js App Router. If you've moved from the Pages Router to the App Router, you've been using them — but do you actually understand why they behave differently?
This article explains Server Components from first principles so you understand when to use them, when not to, and what patterns to follow.
The Problem They Solve
In traditional React (before Server Components), every component ran in the browser. You'd fetch data in a useEffect, show a loading state, then render the content. The result:
- Browser downloads the JavaScript bundle
- React hydrates and renders the loading state
- Component mounts,
useEffectfires - Data fetch begins (going back to the server you just left)
- Data returns, state updates, re-render
This is called a "client-server waterfall." The user stares at a loading spinner while the browser makes a round trip to the server for data that the server already had.
Server Components eliminate this by running the rendering on the server. The component fetches data right there, on the server where the data lives, and sends the already-rendered HTML to the browser.
Server Components vs. Client Components
The difference isn't about SSR (Server-Side Rendering). Both Server and Client Components can be server-rendered. The real difference:
| Server Component | Client Component | |
|---|---|---|
| Runs on | Server only | Server (initial) + Browser |
| Can use | async/await, direct DB access | useState, useEffect, browser APIs |
| Sends to browser | HTML + data | HTML + JavaScript bundle |
| Re-renders | Never in browser | On state/prop change |
Server Components never send their JavaScript to the browser. They don't add to your bundle size at all.
A Concrete Example
// Server Component (default in App Router — no 'use client')
// app/posts/page.tsx
import { db } from '@/lib/db'
export default async function PostsPage() {
// Direct database call — no API route needed
const posts = await db.post.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' },
take: 10,
})
return (
<div>
<h1>Latest Posts</h1>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
)
}This component:
- Runs on the server during the request
- Directly queries the database
- Never ships any JavaScript to the browser
- The rendered HTML is what the browser receives
Compare to the Pages Router equivalent that required getServerSideProps — now the data fetching is just part of the component itself.
When to Use Client Components
You need 'use client' when your component:
- Uses
useStateoruseReducer - Uses
useEffector any lifecycle hook - Uses browser-only APIs (
window,document,localStorage) - Uses event listeners (
onClick,onChange, etc.) - Uses third-party libraries that require the browser
'use client'
import { useState } from 'react'
export default function LikeButton({ initialCount }: { initialCount: number }) {
const [count, setCount] = useState(initialCount)
const [liked, setLiked] = useState(false)
function handleLike() {
setCount(c => liked ? c - 1 : c + 1)
setLiked(l => !l)
}
return (
<button onClick={handleLike} className={liked ? 'text-red-500' : ''}>
♥ {count}
</button>
)
}The Composition Pattern
The key insight for using Server and Client Components together: Client Components can't import Server Components, but Server Components can render Client Components as children.
// app/post/[slug]/page.tsx — Server Component
import { getPostBySlug } from '@/lib/posts'
import LikeButton from '@/components/LikeButton' // Client Component
import CommentSection from '@/components/CommentSection' // Client Component
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug) // Server-side data fetch
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
{/* Pass server-fetched data as props to Client Components */}
<LikeButton initialCount={post.likes} postId={post.id} />
<CommentSection postId={post.id} initialComments={post.comments} />
</article>
)
}The Server Component fetches all data, then passes it as props to Client Components. The Client Components handle interactivity. The Server Component itself sends zero JavaScript.
Async Rendering and Suspense
Server Components support async/await natively. Combine them with Suspense for streaming:
// app/dashboard/page.tsx
import { Suspense } from 'react'
import RecentPosts from '@/components/RecentPosts'
import Analytics from '@/components/Analytics'
import PostSkeleton from '@/components/PostSkeleton'
export default function DashboardPage() {
return (
<div className="grid grid-cols-2 gap-6">
{/* These load independently — slow one doesn't block fast one */}
<Suspense fallback={<PostSkeleton />}>
<RecentPosts />
</Suspense>
<Suspense fallback={<div>Loading analytics...</div>}>
<Analytics />
</Suspense>
</div>
)
}// components/RecentPosts.tsx — Server Component
async function RecentPosts() {
// Even if this takes 2 seconds, Analytics loads independently
const posts = await db.post.findMany({ take: 5, orderBy: { createdAt: 'desc' } })
return (
<ul>
{posts.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
)
}Without Suspense, the slower component would block the entire page. With Suspense, each section streams independently.
Common Mistakes
1. Adding 'use client' to everything. This defeats the purpose. Only add it when you actually need browser APIs or interactivity. A navigation menu that opens on click needs 'use client'. A list of blog posts rendered from a database doesn't.
2. Prop-drilling through Client Components to Server Components. You can't import a Server Component inside a Client Component. Instead, pass Server Components as children to Client Components:
// WRONG
'use client'
import ServerDataList from './ServerDataList' // This won't work as expected
// RIGHT — pass as children
// Server Component
<ClientWrapper>
<ServerDataList /> {/* Server Component passed as children prop */}
</ClientWrapper>3. Fetching in Server Components that aren't at the top level. This can cause sequential waterfalls. Use Promise.all to parallelize:
// SLOW — sequential
const user = await getUser(id)
const posts = await getPostsByUser(user.id) // Waits for user first
// FAST — parallel
const [user, posts] = await Promise.all([
getUser(id),
getPostsByUser(id), // Starts immediately
])4. Mixing async with useState. Server Components are async. Client Components aren't. If your component needs to be async AND interactive, fetch in a Server Component parent and pass data as props to a Client Component.
Key Takeaways
- Server Components run only on the server — they never ship JavaScript to the browser
- Use Server Components by default; only add
'use client'when you need interactivity - Server Components can directly access databases, file systems, and secrets
- Combine Suspense with Server Components for progressive streaming
- Pass fetched data from Server Components to Client Components as props
- Parallel data fetching with
Promise.allprevents server-side waterfalls

Written by
Sabir KhaloufiFull-stack developer and tech blogger sharing in-depth tutorials on React, Next.js, AI, and modern web development.