Next.js Server Components — How They Work and When to Use Them
Advertisement
Introduction
React Server Components (RSC) are components that render exclusively on the server. They send HTML (and a serialized component tree) to the browser — no JavaScript bundle shipped for the component itself. In the Next.js App Router, every component is a Server Component by default unless you add 'use client' at the top.
Why This Matters
Before Server Components, every React component ran in the browser, meaning all the code for data fetching, formatting, and rendering was bundled and sent to the client — even for content that never changes after page load. Server Components break this assumption.
With RSC, a component that fetches blog posts from a database runs entirely on the server. The browser receives rendered HTML. No useEffect, no loading spinner, no API route needed. This directly improves Largest Contentful Paint (LCP) and reduces Time to Interactive (TTI).
Server Components also keep secrets safe. Database credentials, API keys, and business logic stay on the server — they are never accessible to the client, even through browser DevTools.
Server Components vs Client Components
| Capability | Server Component | Client Component |
|---|---|---|
async/await | Yes | No (use useEffect) |
| Access databases | Yes | No |
| Browser APIs | No | Yes |
| React hooks | No | Yes |
| Event handlers | No | Yes |
| JavaScript sent to browser | No | Yes |
'use client' directive | Not needed | Required |
Writing Your First Server Component
// app/posts/page.tsx — Server Component by default
import { db } from '@/lib/db'
export default async function PostsPage() {
// Direct database access — never exposed to browser
const posts = await db.post.findMany({
orderBy: { createdAt: 'desc' },
take: 10,
})
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</li>
))}
</ul>
)
}No API route. No fetch('/api/posts'). The component queries the database directly and Next.js renders the HTML.
Secure Data Access Pattern
// app/dashboard/page.tsx
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { db } from '@/lib/db'
export default async function DashboardPage() {
const session = await auth()
if (!session?.user) {
redirect('/login')
}
// API key and DB query stay on server
const userData = await db.user.findUnique({
where: { id: session.user.id },
include: { subscription: true },
})
return (
<div>
<h1>Welcome, {userData?.name}</h1>
<p>Plan: {userData?.subscription?.plan ?? 'Free'}</p>
</div>
)
}Mixing Server and Client Components
Server Components can import and render Client Components — but NOT the reverse. Pass data from server to client as serializable props:
// app/dashboard/page.tsx — Server Component
import { StatsChart } from '@/components/StatsChart' // Client Component
import { db } from '@/lib/db'
export default async function DashboardPage() {
const stats = await db.analytics.getMonthly() // runs on server
// Pass serializable data to client component
return (
<div>
<h1>Analytics</h1>
<StatsChart data={stats} /> {/* Interactive chart on client */}
</div>
)
}// components/StatsChart.tsx — Client Component
'use client'
import { useState } from 'react'
export function StatsChart({ data }: { data: MonthlyStats[] }) {
const [metric, setMetric] = useState<'views' | 'clicks'>('views')
return (
<div>
<select onChange={(e) => setMetric(e.target.value as 'views' | 'clicks')}>
<option value="views">Views</option>
<option value="clicks">Clicks</option>
</select>
{/* render chart with data[metric] */}
</div>
)
}Data Fetching with fetch and cache
// Server Component with caching
async function getProduct(id: string) {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: { revalidate: 3600, tags: [`product-${id}`] },
})
if (!res.ok) throw new Error('Failed to fetch product')
return res.json()
}
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id)
return (
<article>
<h1>{product.name}</h1>
<p>${product.price}</p>
</article>
)
}Parallel Data Fetching
Avoid waterfalls by fetching in parallel with Promise.all:
export default async function DashboardPage() {
// All three requests fire simultaneously
const [users, revenue, orders] = await Promise.all([
fetch('/api/users').then((r) => r.json()),
fetch('/api/revenue').then((r) => r.json()),
fetch('/api/orders').then((r) => r.json()),
])
return (
<div className="grid grid-cols-3 gap-4">
<StatCard title="Users" value={users.total} />
<StatCard title="Revenue" value={revenue.total} />
<StatCard title="Orders" value={orders.total} />
</div>
)
}Streaming with Suspense
Wrap slow Server Components in Suspense to stream HTML progressively:
import { Suspense } from 'react'
import { SlowComponent } from './SlowComponent'
export default function Page() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<p>Loading stats...</p>}>
<SlowComponent /> {/* Streams in after server data resolves */}
</Suspense>
</div>
)
}The shell renders immediately; the slow component streams in once the server resolves its data.
Common Mistakes
- Adding
'use client'to a parent component when only a child needs interactivity — this unnecessarily bundles the parent - Passing non-serializable values (Date objects, functions, class instances) as props to Client Components — use
.toISOString()for dates - Trying to use
useState,useEffect, oronClickin Server Components — these are browser-only APIs - Importing a Client Component into a Server Component then re-exporting it without
'use client'— causes hydration errors - Not wrapping slow Server Components in
Suspense, causing the entire page to wait
Best Practices
- Default to Server Components for all pages and layouts
- Add
'use client'only at the leaf level — the smallest component that actually needs interactivity - Use
Promise.allfor parallel data fetching to avoid sequential request waterfalls - Pass only serializable data (strings, numbers, plain objects, arrays) to Client Components
- Use
unstable_cacheorcache()from React for deduplicating expensive server operations - Keep sensitive API keys and DB credentials in Server Components — never in Client Components
Key Takeaways
- Server Components are the default in the Next.js App Router — no
'use client'needed - They render on the server and send HTML to the browser — zero JavaScript bundle cost for the component
- Server Components can be
asyncand useawaitdirectly — no hooks oruseEffectrequired - Direct database access is safe in Server Components — credentials never reach the browser
- Client Components must be marked with
'use client'— they support hooks, events, and browser APIs - Props passed from Server to Client Components must be JSON-serializable (no functions, Dates, or class instances)
- Wrapping slow Server Components in
<Suspense>enables streaming — the page shell loads instantly - Next.js deduplicates identical
fetch()calls made in the same render pass automatically
Advertisement