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.
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:
useEffect firesThis 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.
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.
// 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:
Compare to the Pages Router equivalent that required getServerSideProps — now the data fetching is just part of the component itself.
You need 'use client' when your component:
useState or useReduceruseEffect or any lifecycle hookwindow, document, localStorage)onClick, onChange, etc.)'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 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.
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.
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.
'use client' when you need interactivityPromise.all prevents server-side waterfalls