Next.js Server Components — How They Work and When to Use Them

Sanjeev SharmaSanjeev Sharma
6 min read

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

CapabilityServer ComponentClient Component
async/awaitYesNo (use useEffect)
Access databasesYesNo
Browser APIsNoYes
React hooksNoYes
Event handlersNoYes
JavaScript sent to browserNoYes
'use client' directiveNot neededRequired

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, or onClick in 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.all for parallel data fetching to avoid sequential request waterfalls
  • Pass only serializable data (strings, numbers, plain objects, arrays) to Client Components
  • Use unstable_cache or cache() 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 async and use await directly — no hooks or useEffect required
  • 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

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading