Server Components and Full-Stack React NEXT.JS 13+
1. React Server Components Fundamentals
| Concept | Description | Characteristic | Use Case |
|---|---|---|---|
| Server Components | Components that run only on server | No JavaScript sent to client, async by default | Data fetching, database queries, backend logic |
| Client Components | Traditional React components | Run on both server (SSR) and client, need 'use client' | Interactivity, hooks, browser APIs, event handlers |
| Zero Bundle Impact | Server component code not in bundle | Smaller JavaScript payload | Large dependencies, utilities, formatters |
| Direct Backend Access | Access databases, file system directly | No API route needed | Database queries, file reads, server-only logic |
| Async Components | Can use async/await in component | Natural data fetching pattern | Fetch data directly in component body |
| Streaming | Stream HTML progressively | Works with Suspense boundaries | Faster initial page load, progressive rendering |
Example: Server Component basics
// app/page.tsx - Server Component by default (Next.js 13+)
// No 'use client' directive = Server Component
import { db } from '@/lib/database';
// Server components can be async!
async function ProductList() {
// Direct database access - no API route needed
const products = await db.product.findMany({
include: { category: true }
});
return (
<div>
<h1>Products</h1>
<ul>
{products.map(product => (
<li key={product.id}>
{product.name} - ${product.price}
<span>{product.category.name}</span>
</li>
))}
</ul>
</div>
);
}
export default ProductList;
// Server Component characteristics:
// ✓ Can use async/await
// ✓ Direct database/filesystem access
// ✓ No JavaScript sent to client
// ✓ Can import server-only code
// ✓ Runs only on server
// ✗ Cannot use hooks (useState, useEffect, etc.)
// ✗ Cannot use browser APIs
// ✗ Cannot have event handlers
Example: Client Component with 'use client'
// components/Counter.tsx - Client Component
'use client'; // This directive marks it as a Client Component
import { useState } from 'react';
export function Counter() {
// Can use hooks - runs on client
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
{/* Event handlers work */}
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}
// Client Component characteristics:
// ✓ Can use hooks (useState, useEffect, etc.)
// ✓ Can use browser APIs
// ✓ Can have event handlers
// ✓ Interactive features
// ✗ Cannot be async
// ✗ JavaScript sent to client
// ✗ Cannot directly access server resources
Server vs Client Components: Server Components are the default in Next.js App Router, use 'use
client' for interactivity, server components have zero bundle impact, can mix both in same app, server
components can import client components (but not vice versa).
2. Client vs Server Component Boundaries
| Rule | Allowed | Not Allowed | Why |
|---|---|---|---|
| Importing | Server can import Client | Client cannot import Server | Server code shouldn't run on client |
| Props | Pass serializable props to Client | Pass functions, class instances | Props must cross network boundary |
| Children Pattern | Pass Server as children to Client | Import Server into Client | Composition pattern preserves boundaries |
| Context | Client Components can provide context | Server Components cannot use context | Context requires client-side state |
| Third-party | Mark packages as 'use client' if needed | Import packages without checking | Many packages assume client environment |
Example: Component composition patterns
// ✓ CORRECT: Server Component imports Client Component
// app/page.tsx (Server Component)
import { Counter } from '@/components/Counter'; // Client Component
export default function Page() {
return (
<div>
<h1>My Page</h1>
<Counter /> {/* Client Component works here */}
</div>
);
}
// ✗ WRONG: Client Component cannot import Server Component
// components/ClientComponent.tsx
'use client';
import { ServerData } from './ServerComponent'; // ERROR!
// ✓ CORRECT: Use composition with children prop
// components/ClientWrapper.tsx
'use client';
export function ClientWrapper({ children }) {
const [state, setState] = useState(false);
return (
<div onClick={() => setState(!state)}>
{children} {/* Server Component passed as children */}
</div>
);
}
// app/page.tsx (Server Component)
import { ClientWrapper } from '@/components/ClientWrapper';
async function ServerData() {
const data = await fetchFromDatabase();
return <div>{data}</div>;
}
export default function Page() {
return (
<ClientWrapper>
<ServerData /> {/* Server Component passed to Client via children */}
</ClientWrapper>
);
}
Example: Serializable props requirement
// ✓ CORRECT: Serializable props
// app/page.tsx (Server Component)
<ClientComponent
name="John"
age={30}
items={['a', 'b', 'c']}
config={{ theme: 'dark', locale: 'en' }}
/>
// ✗ WRONG: Non-serializable props
<ClientComponent
onClick={() => console.log('click')} // Functions can't be serialized
date={new Date()} // Dates lose type information
callback={myFunction} // Functions not allowed
/>
// ✓ CORRECT: Define handlers in Client Component
'use client';
export function ClientComponent({ name, age }) {
// Define event handlers in client component
const handleClick = () => {
console.log(`${name} clicked, age: ${age}`);
};
return <button onClick={handleClick}>{name}</button>;
}
// ✓ CORRECT: Server Actions for server-side logic
// app/actions.ts
'use server';
export async function updateUser(formData) {
// Server-side logic
const name = formData.get('name');
await db.user.update({ name });
}
// Client Component can call Server Action
'use client';
import { updateUser } from './actions';
export function Form() {
return (
<form action={updateUser}>
<input name="name" />
<button type="submit">Save</button>
</form>
);
}
Warning: Server Components cannot import Client Components as modules - use children prop
pattern. Props must be serializable (no functions, class instances). Third-party packages may need 'use client'
wrapper. Test boundaries carefully.
3. Data Fetching in Server Components
| Pattern | Implementation | Benefit | Use Case |
|---|---|---|---|
| Async Components | async function Component() |
Natural async/await syntax | Database queries, API calls, file reads |
| Parallel Fetching | Promise.all([fetch1, fetch2]) |
Multiple requests simultaneously | Independent data sources |
| Sequential Fetching | Await each fetch separately | Dependent requests | Second request needs first's result |
| Streaming with Suspense | Wrap slow components in Suspense | Progressive page rendering | Show fast content first, stream slow data |
| Request Memoization | React dedupes identical requests | Automatic caching during render | Multiple components need same data |
| Server-only Code | import 'server-only' |
Prevent client-side import | Database clients, secret keys, server utilities |
Example: Data fetching patterns
// Async Server Component
async function UserProfile({ userId }) {
// Direct database access
const user = await db.user.findUnique({
where: { id: userId },
include: { posts: true, followers: true }
});
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<p>Posts: {user.posts.length}</p>
<p>Followers: {user.followers.length}</p>
</div>
);
}
// Parallel data fetching
async function Dashboard() {
// Fetch multiple resources in parallel
const [user, posts, stats] = await Promise.all([
db.user.findFirst(),
db.post.findMany({ take: 10 }),
db.analytics.getStats()
]);
return (
<div>
<UserCard user={user} />
<PostList posts={posts} />
<StatsWidget stats={stats} />
</div>
);
}
// Sequential data fetching (when dependent)
async function PostWithComments({ postId }) {
// First, get the post
const post = await db.post.findUnique({
where: { id: postId }
});
// Then, get comments (needs post.id)
const comments = await db.comment.findMany({
where: { postId: post.id }
});
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<CommentList comments={comments} />
</article>
);
}
Example: Streaming with Suspense
// app/page.tsx
import { Suspense } from 'react';
// Fast component
async function QuickData() {
const data = await fetch('https://api.example.com/quick');
return <div>Quick: {data}</div>;
}
// Slow component
async function SlowData() {
const data = await fetch('https://api.example.com/slow');
return <div>Slow: {data}</div>;
}
export default function Page() {
return (
<div>
<h1>Dashboard</h1>
{/* Quick data renders immediately */}
<Suspense fallback={<div>Loading quick data...</div>}>
<QuickData />
</Suspense>
{/* Slow data streams in later */}
<Suspense fallback={<div>Loading slow data...</div>}>
<SlowData />
</Suspense>
</div>
);
}
// Result: Page streams progressively
// 1. Shell with "Loading..." fallbacks sent immediately
// 2. QuickData arrives and replaces first fallback
// 3. SlowData arrives later and replaces second fallback
Example: Server-only code protection
// lib/database.ts
import 'server-only'; // Prevents client-side import
import { PrismaClient } from '@prisma/client';
// This will only run on server
export const db = new PrismaClient();
// Server-only utilities
export async function getSecretData() {
// Safe to use environment variables
const apiKey = process.env.SECRET_API_KEY;
return fetchWithKey(apiKey);
}
// If you try to import this in a Client Component:
// 'use client';
// import { db } from '@/lib/database'; // ERROR at build time!
// lib/utils.ts - Shared utilities
// No 'server-only' import = can be used anywhere
export function formatDate(date) {
return date.toLocaleDateString();
}
// Use 'server-only' package for:
// - Database clients
// - API clients with secrets
// - Server-only utilities
// - Code that must never be exposed to client
Data Fetching Best Practices: Fetch data at component level (not page level), use Suspense for
streaming, parallelize independent requests, use request memoization (React dedupes), protect server-only code
with 'server-only' package, cache appropriately with Next.js caching strategies.
4. Server Component Performance Benefits
| Benefit | Impact | Measurement | Example |
|---|---|---|---|
| Reduced Bundle Size | Server code not sent to client | Smaller JavaScript payloads | 100KB library → 0KB on client |
| Faster Initial Load | Less JavaScript to parse/execute | Better Time to Interactive (TTI) | Heavy formatters, utilities stay on server |
| Improved FCP | HTML streams immediately | First Contentful Paint | Static content shows instantly |
| Better SEO | Fully rendered HTML for crawlers | Complete content in initial HTML | No waiting for client-side fetch |
| Reduced Waterfalls | Fetch data on server in parallel | Fewer round trips | No client → server → database chain |
| Direct Backend Access | No API routes needed | Fewer HTTP requests | Direct database queries |
Example: Bundle size comparison
// ❌ Client Component approach (old way)
'use client';
import moment from 'moment'; // 67KB gzipped!
import { remark } from 'remark'; // 50KB gzipped!
export function BlogPost({ content, date }) {
const formattedDate = moment(date).format('MMMM Do, YYYY');
const html = remark().processSync(content);
return (
<article>
<time>{formattedDate}</time>
<div dangerouslySetInnerHTML={{ __html: html }} />
</article>
);
}
// Total bundle increase: ~117KB just for this component!
// ✅ Server Component approach (new way)
// No 'use client' = Server Component
import moment from 'moment'; // Runs on server only
import { remark } from 'remark'; // Runs on server only
export async function BlogPost({ postId }) {
const post = await db.post.findUnique({ where: { id: postId } });
const formattedDate = moment(post.date).format('MMMM Do, YYYY');
const html = remark().processSync(post.content);
return (
<article>
<time>{formattedDate}</time>
<div dangerouslySetInnerHTML={{ __html: html }} />
</article>
);
}
// Client bundle increase: 0KB! All processing on server.
Example: Performance optimization strategies
// Optimize with strategic Client/Server split
// Server Component - heavy processing
async function ProductGrid() {
const products = await db.product.findMany({
include: { reviews: true }
});
// Heavy computation on server
const productsWithRatings = products.map(product => ({
...product,
averageRating: calculateAverage(product.reviews),
reviewCount: product.reviews.length
}));
return (
<div className="grid">
{productsWithRatings.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
// Client Component - only interactive parts
'use client';
export function ProductCard({ product }) {
const [isFavorite, setIsFavorite] = useState(false);
return (
<div className="card">
{/* Static content from server */}
<h3>{product.name}</h3>
<p>${product.price}</p>
<div>⭐ {product.averageRating} ({product.reviewCount})</div>
{/* Interactive part on client */}
<button onClick={() => setIsFavorite(!isFavorite)}>
{isFavorite ? '❤️' : '🤍'} Favorite
</button>
</div>
);
}
// Benefits:
// ✓ Heavy computation (calculateAverage) runs on server
// ✓ Database query stays on server
// ✓ Only small interactive component sent to client
// ✓ Better performance overall
Performance Impact: Server Components significantly reduce bundle size, improve initial load
times, enable better caching strategies, reduce network waterfalls, improve Core Web Vitals (LCP, FCP, TTI),
benefit SEO with complete HTML.
5. Hydration and Client-side State Management
| Concept | Description | Consideration | Solution |
|---|---|---|---|
| Selective Hydration | Hydrate client components only | Server component HTML stays static | Faster hydration, less JavaScript execution |
| State Management | Client components manage state | Server components are stateless | Use Context, Zustand, Redux in client components |
| Server → Client Data | Pass data via props | Props must be serializable | JSON-serializable data only |
| Client State Hydration | Initialize client state from server data | Avoid hydration mismatches | Use useEffect for client-only state |
| Progressive Enhancement | Works without JavaScript first | Server Components always work | Add interactivity with Client Components |
Example: State management patterns
// Server Component fetches data
async function UserDashboard({ userId }) {
const user = await db.user.findUnique({
where: { id: userId },
include: { preferences: true }
});
// Pass data to Client Component
return (
<div>
<h1>Welcome, {user.name}</h1>
<UserSettings initialSettings={user.preferences} />
</div>
);
}
// Client Component manages state
'use client';
export function UserSettings({ initialSettings }) {
// Initialize state from server data
const [settings, setSettings] = useState(initialSettings);
const [isSaving, setIsSaving] = useState(false);
const handleSave = async () => {
setIsSaving(true);
await fetch('/api/settings', {
method: 'POST',
body: JSON.stringify(settings)
});
setIsSaving(false);
};
return (
<div>
<label>
Theme:
<select
value={settings.theme}
onChange={(e) => setSettings({...settings, theme: e.target.value})}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</label>
<button onClick={handleSave} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save Settings'}
</button>
</div>
);
}
Example: Context providers in Client Components
// app/layout.tsx - Server Component
import { Providers } from './providers';
export default function RootLayout({ children }) {
return (
<html>
<body>
{/* Providers must be Client Component */}
<Providers>
{children}
</Providers>
</body>
</html>
);
}
// app/providers.tsx - Client Component
'use client';
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext();
export function Providers({ children }) {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}
// Any Client Component can now use theme
'use client';
import { useTheme } from './providers';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Current: {theme}
</button>
);
}
Example: Avoiding hydration mismatches
// ❌ WRONG: Causes hydration mismatch
'use client';
export function TimeDisplay() {
// Server renders one time, client hydrates with different time
return <div>Current time: {new Date().toLocaleTimeString()}</div>;
// ERROR: Server HTML doesn't match client hydration
}
// ✅ CORRECT: Client-only rendering
'use client';
export function TimeDisplay() {
const [time, setTime] = useState(null);
useEffect(() => {
// Only runs on client
setTime(new Date().toLocaleTimeString());
const interval = setInterval(() => {
setTime(new Date().toLocaleTimeString());
}, 1000);
return () => clearInterval(interval);
}, []);
// Show nothing during SSR, then show time on client
if (!time) return <div>Loading time...</div>;
return <div>Current time: {time}</div>;
}
// ✅ ALTERNATIVE: Suppress hydration warning (use sparingly)
'use client';
export function TimeDisplay() {
return (
<div suppressHydrationWarning>
Current time: {new Date().toLocaleTimeString()}
</div>
);
}
Hydration Warning: Client and server HTML must match during hydration. Use useEffect for
client-only code, avoid Date.now() or random values in render, use suppressHydrationWarning sparingly, test
hydration carefully.
6. Next.js App Router and Server Components
| Feature | File Convention | Purpose | Component Type |
|---|---|---|---|
| Page | app/page.tsx |
Route page component | Server by default |
| Layout | app/layout.tsx |
Shared layout wrapper | Server by default |
| Loading | app/loading.tsx |
Loading UI (Suspense fallback) | Server by default |
| Error | app/error.tsx |
Error boundary UI | Must be Client ('use client') |
| Template | app/template.tsx |
Re-rendered on navigation | Server by default |
| Route Handler | app/api/route.ts |
API endpoint | Server-only |
Example: Next.js App Router file structure
app/
├── layout.tsx # Root layout (Server Component)
├── page.tsx # Home page (Server Component)
├── loading.tsx # Loading UI (Server Component)
├── error.tsx # Error boundary (Client Component)
├── providers.tsx # Context providers (Client Component)
│
├── dashboard/
│ ├── layout.tsx # Dashboard layout
│ ├── page.tsx # Dashboard page
│ └── loading.tsx # Dashboard loading
│
├── blog/
│ ├── page.tsx # Blog list
│ └── [slug]/
│ ├── page.tsx # Blog post (dynamic route)
│ └── loading.tsx
│
└── api/
└── users/
└── route.ts # API route handler
// app/layout.tsx - Root Layout
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<header>
<nav>...</nav>
</header>
<main>{children}</main>
<footer>...</footer>
</body>
</html>
);
}
// app/page.tsx - Home Page (Server Component)
export default async function HomePage() {
const posts = await db.post.findMany({ take: 5 });
return (
<div>
<h1>Latest Posts</h1>
<PostList posts={posts} />
</div>
);
}
// app/loading.tsx - Loading UI
export default function Loading() {
return <div>Loading...</div>;
}
// app/error.tsx - Error Boundary (must be Client)
'use client';
export default function Error({ error, reset }) {
return (
<div>
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}
Example: Dynamic routes and data fetching
// app/blog/[slug]/page.tsx - Dynamic route
export default async function BlogPost({ params }) {
// Params are automatically typed and available
const post = await db.post.findUnique({
where: { slug: params.slug }
});
if (!post) {
notFound(); // Shows 404 page
}
return (
<article>
<h1>{post.title}</h1>
<time>{post.publishedAt}</time>
<div>{post.content}</div>
</article>
);
}
// Generate static params for SSG
export async function generateStaticParams() {
const posts = await db.post.findMany();
return posts.map(post => ({
slug: post.slug
}));
}
// Generate metadata
export async function generateMetadata({ params }) {
const post = await db.post.findUnique({
where: { slug: params.slug }
});
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.coverImage]
}
};
}
Example: Route handlers (API routes)
// app/api/users/route.ts - API Route Handler
import { NextResponse } from 'next/server';
// GET /api/users
export async function GET(request) {
const { searchParams } = new URL(request.url);
const page = searchParams.get('page') || '1';
const users = await db.user.findMany({
skip: (parseInt(page) - 1) * 10,
take: 10
});
return NextResponse.json(users);
}
// POST /api/users
export async function POST(request) {
const body = await request.json();
const user = await db.user.create({
data: body
});
return NextResponse.json(user, { status: 201 });
}
// app/api/users/[id]/route.ts - Dynamic route
export async function GET(request, { params }) {
const user = await db.user.findUnique({
where: { id: params.id }
});
if (!user) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json(user);
}
// PATCH /api/users/[id]
export async function PATCH(request, { params }) {
const body = await request.json();
const user = await db.user.update({
where: { id: params.id },
data: body
});
return NextResponse.json(user);
}
// DELETE /api/users/[id]
export async function DELETE(request, { params }) {
await db.user.delete({
where: { id: params.id }
});
return NextResponse.json({ success: true });
}
Next.js App Router Features: File-based routing, Server Components by default, automatic code
splitting, built-in loading states, error boundaries, metadata API, route handlers for APIs, streaming with
Suspense, TypeScript support.