Skip to main content
Advertisement

Server/Client Components — RSC Concepts, "use client", Component Boundary Design

What Are React Server Components?

React Server Components (RSC) is a paradigm introduced in React 18 and fully adopted by the Next.js App Router. Components render on the server and stream HTML to the client.

Traditional Rendering vs RSC

ApproachHow It WorksCharacteristics
CSR (Client-Side Rendering)JavaScript executes in the browserSlow initial load, large bundle size
SSR (Server-Side Rendering)Server generates HTML, then client hydratesProcesses the entire component tree on every request
RSCRenders on server, streams HTML without JSNo JS bundle, direct DB access, fast initial load

Key Benefits of RSC

  1. Reduced bundle size: Server component dependencies are not included in the client JS bundle
  2. Direct backend access: Access databases, file systems, and internal APIs directly
  3. Simplified data fetching: Use async/await at the component level directly
  4. Improved security: API keys and sensitive data are never exposed to the client
  5. Automatic code splitting: Only the necessary client code is sent at the right time

Server Component Basics

In the App Router, all components are Server Components by default. Any component written without a directive runs on the server.

Characteristics of Server Components

// app/components/UserProfile.tsx — Server Component (default)
import { db } from '@/lib/database';

// ✅ What you CAN do
// - Use async/await at the top level
// - Query the database directly
// - Access the file system
// - Access server-only environment variables
// - Render other Server Components
// - Render Client Components (passing data as props)

async function UserProfile({ userId }: { userId: string }) {
// Direct DB query — never exposed to the client
const user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
const posts = await db.query('SELECT * FROM posts WHERE user_id = ? LIMIT 5', [userId]);

return (
<div className="profile">
<h1>{user.name}</h1>
<p>{user.email}</p>
<PostList posts={posts} />
</div>
);
}

// ❌ What you CANNOT do in a Server Component
// - Use React hooks like useState, useEffect
// - Use event handlers like onClick
// - Access browser-only APIs (window, document)
// - Import a 'use client' component as a Server Component

Data Fetching in Server Components

// app/blog/[slug]/page.tsx
interface Post {
id: number;
title: string;
content: string;
author: { name: string; avatar: string };
publishedAt: string;
tags: string[];
}

async function getPost(slug: string): Promise<Post> {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { revalidate: 3600 }, // ISR: revalidate every 1 hour
});

if (!res.ok) {
if (res.status === 404) return null as any;
throw new Error(`Failed to load post: ${res.status}`);
}

return res.json();
}

export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);

return (
<article className="max-w-3xl mx-auto py-12">
<header className="mb-8">
<h1 className="text-4xl font-bold">{post.title}</h1>
<div className="flex items-center gap-3 mt-4">
<img
src={post.author.avatar}
alt={post.author.name}
className="w-10 h-10 rounded-full"
/>
<div>
<p className="font-medium">{post.author.name}</p>
<time className="text-sm text-gray-500">
{new Date(post.publishedAt).toLocaleDateString('en-US')}
</time>
</div>
</div>
</header>
<div className="prose">{post.content}</div>
</article>
);
}

Client Components

Adding the "use client" directive at the top of a file makes it a Client Component.

When You Need a Client Component

'use client';

// ✅ What you CAN use in a Client Component
// - All React hooks: useState, useReducer, useEffect, etc.
// - Event handlers: onClick, onChange, onSubmit, etc.
// - Browser APIs: window, document, navigator
// - User interaction handling
// - localStorage, sessionStorage access
// - Web Sockets, EventSource

Basic Client Component Example

// components/Counter.tsx
'use client';

import { useState } from 'react';

export default function Counter({ initialCount = 0 }: { initialCount?: number }) {
const [count, setCount] = useState(initialCount);

return (
<div className="flex items-center gap-4">
<button
onClick={() => setCount((c) => c - 1)}
className="px-3 py-1 bg-gray-200 rounded"
>
-
</button>
<span className="text-xl font-bold">{count}</span>
<button
onClick={() => setCount((c) => c + 1)}
className="px-3 py-1 bg-blue-500 text-white rounded"
>
+
</button>
</div>
);
}
// Using a Client Component inside a Server Component
// app/page.tsx — Server Component
import Counter from '@/components/Counter';

export default function HomePage() {
return (
<main>
<h1>Home</h1>
{/* Compute initialCount on the server or fetch from DB, then pass as prop */}
<Counter initialCount={42} />
</main>
);
}

Form and Event Handling

// components/SearchBar.tsx
'use client';

import { useState, useTransition } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';

export default function SearchBar() {
const router = useRouter();
const searchParams = useSearchParams();
const [query, setQuery] = useState(searchParams.get('q') ?? '');
const [isPending, startTransition] = useTransition();

const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
startTransition(() => {
const params = new URLSearchParams(searchParams.toString());
if (query) {
params.set('q', query);
} else {
params.delete('q');
}
router.push(`/search?${params.toString()}`);
});
};

return (
<form onSubmit={handleSearch} className="flex gap-2">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Enter a search term..."
className="flex-1 px-4 py-2 border rounded-lg"
/>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-blue-500 text-white rounded-lg disabled:opacity-50"
>
{isPending ? 'Searching...' : 'Search'}
</button>
</form>
);
}

Component Boundary Design

Principle: Keep as Much as Possible on the Server, Use Client Only Where Needed

Page (Server)
├── Header (Server) — fetches user info from DB
│ └── NavMenu (Client) — dropdown menu interaction
├── Main Content (Server) — fetches data from DB
│ ├── ArticleList (Server) — static rendering
│ │ └── LikeButton (Client) — like button click
│ └── Sidebar (Server)
│ └── FilterPanel (Client) — filter UI state
└── Footer (Server)

Correct Separation Example

// app/dashboard/page.tsx — Server Component
import { db } from '@/lib/database';
import StatsCard from './StatsCard';
import RecentActivity from './RecentActivity';
import QuickActions from './QuickActions'; // Client Component

async function getDashboardData() {
const [stats, activities] = await Promise.all([
db.getStats(),
db.getRecentActivities(),
]);
return { stats, activities };
}

export default async function DashboardPage() {
const { stats, activities } = await getDashboardData();

return (
<div className="grid grid-cols-3 gap-6 p-6">
{/* Fetch data on server and pass as props */}
<StatsCard title="Total Users" value={stats.totalUsers} />
<StatsCard title="Today's Visitors" value={stats.todayVisitors} />
<StatsCard title="Active Sessions" value={stats.activeSessions} />

<div className="col-span-2">
<RecentActivity activities={activities} />
</div>

{/* Client Component: requires interaction */}
<QuickActions />
</div>
);
}
// components/StatsCard.tsx — Server Component (no interaction)
interface StatsCardProps {
title: string;
value: number;
trend?: number;
}

export default function StatsCard({ title, value, trend }: StatsCardProps) {
return (
<div className="bg-white rounded-xl shadow p-6">
<h3 className="text-sm text-gray-500">{title}</h3>
<p className="text-3xl font-bold mt-2">{value.toLocaleString()}</p>
{trend !== undefined && (
<p className={`text-sm mt-1 ${trend >= 0 ? 'text-green-500' : 'text-red-500'}`}>
{trend >= 0 ? '▲' : '▼'} {Math.abs(trend)}%
</p>
)}
</div>
);
}
// components/QuickActions.tsx — Client Component (has interaction)
'use client';

import { useState } from 'react';

const actions = [
{ label: 'New Post', href: '/dashboard/posts/new' },
{ label: 'Invite User', href: '/dashboard/invite' },
{ label: 'Generate Report', href: '/dashboard/reports' },
];

export default function QuickActions() {
const [expanded, setExpanded] = useState(false);

return (
<div className="bg-white rounded-xl shadow p-6">
<button
onClick={() => setExpanded(!expanded)}
className="w-full text-left font-semibold"
>
Quick Actions {expanded ? '▲' : '▼'}
</button>
{expanded && (
<ul className="mt-4 space-y-2">
{actions.map((action) => (
<li key={action.href}>
<a href={action.href} className="block p-2 hover:bg-gray-50 rounded">
{action.label}
</a>
</li>
))}
</ul>
)}
</div>
);
}

Lifting Server Components via children Prop

You cannot directly import a Server Component inside a Client Component. However, you can render a Server Component inside a Client Component via the children prop.

// ❌ Wrong — importing a Server Component into a Client Component
'use client';

import ServerComponent from './ServerComponent'; // Error! Cannot import here

export default function ClientWrapper() {
return (
<div>
<ServerComponent /> {/* Does not work */}
</div>
);
}
// ✅ Correct — pass Server Component via children
// components/ClientWrapper.tsx — Client Component
'use client';

import { useState } from 'react';

export default function ClientWrapper({
children,
}: {
children: React.ReactNode;
}) {
const [open, setOpen] = useState(false);

return (
<div>
<button onClick={() => setOpen(!open)}>
{open ? 'Collapse' : 'Expand'}
</button>
{open && <div className="mt-4">{children}</div>}
</div>
);
}
// app/page.tsx — Server Component
import ClientWrapper from '@/components/ClientWrapper';
import ServerContent from '@/components/ServerContent';

export default function Page() {
return (
<ClientWrapper>
{/* ServerContent is rendered on the server and passed as children */}
<ServerContent />
</ClientWrapper>
);
}
// components/ServerContent.tsx — Server Component
import { db } from '@/lib/database';

export default async function ServerContent() {
const data = await db.getLatestData();
return (
<ul>
{data.map((item: any) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}

Server Actions

Server Actions are functions that run on the server but can be called directly from the client. They are ideal for form submissions and data mutations.

Basic Server Action

// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { db } from '@/lib/database';

export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;

if (!title || !content) {
return { error: 'Title and content are required' };
}

const post = await db.posts.create({
data: { title, content, publishedAt: new Date() },
});

revalidatePath('/blog'); // Invalidate the blog listing cache
redirect(`/blog/${post.slug}`);
}

export async function deletePost(id: string) {
await db.posts.delete({ where: { id } });
revalidatePath('/blog');
}

export async function updatePost(id: string, formData: FormData) {
const updates = {
title: formData.get('title') as string,
content: formData.get('content') as string,
};

await db.posts.update({ where: { id }, data: updates });
revalidatePath('/blog');
revalidatePath(`/blog/${id}`);
}

Using Server Actions in Forms

// app/blog/new/page.tsx — Server Component
import { createPost } from '@/app/actions';

export default function NewPostPage() {
return (
<div className="max-w-2xl mx-auto py-12 px-4">
<h1 className="text-2xl font-bold mb-8">Write a New Post</h1>
{/* Pass the Server Action function directly to action */}
<form action={createPost} className="space-y-6">
<div>
<label htmlFor="title" className="block font-medium mb-1">
Title
</label>
<input
id="title"
name="title"
type="text"
required
className="w-full px-4 py-2 border rounded-lg"
/>
</div>
<div>
<label htmlFor="content" className="block font-medium mb-1">
Content
</label>
<textarea
id="content"
name="content"
rows={10}
required
className="w-full px-4 py-2 border rounded-lg"
/>
</div>
<button
type="submit"
className="px-6 py-2 bg-blue-500 text-white rounded-lg"
>
Publish
</button>
</form>
</div>
);
}

Using Server Actions in Client Components

// components/DeleteButton.tsx
'use client';

import { useTransition } from 'react';
import { deletePost } from '@/app/actions';

export default function DeleteButton({ postId }: { postId: string }) {
const [isPending, startTransition] = useTransition();

const handleDelete = () => {
if (!confirm('Are you sure you want to delete this?')) return;

startTransition(async () => {
await deletePost(postId);
});
};

return (
<button
onClick={handleDelete}
disabled={isPending}
className="px-4 py-2 bg-red-500 text-white rounded disabled:opacity-50"
>
{isPending ? 'Deleting...' : 'Delete'}
</button>
);
}

Managing Form State with useActionState

// components/PostForm.tsx
'use client';

import { useActionState } from 'react';
import { createPost } from '@/app/actions';

interface FormState {
error?: string;
success?: boolean;
}

export default function PostForm() {
const [state, formAction, isPending] = useActionState<FormState, FormData>(
async (prevState, formData) => {
const result = await createPost(formData);
if (result?.error) return { error: result.error };
return { success: true };
},
{}
);

return (
<form action={formAction} className="space-y-4">
{state.error && (
<div className="p-3 bg-red-50 text-red-600 rounded">{state.error}</div>
)}
{state.success && (
<div className="p-3 bg-green-50 text-green-600 rounded">
Post published successfully!
</div>
)}
<input name="title" placeholder="Title" className="w-full border p-2 rounded" />
<textarea name="content" rows={6} className="w-full border p-2 rounded" />
<button type="submit" disabled={isPending}>
{isPending ? 'Publishing...' : 'Publish'}
</button>
</form>
);
}

Practical Example — E-Commerce Product Page

A real-world example with well-separated Server and Client Components.

// app/products/[id]/page.tsx — Server Component
import { notFound } from 'next/navigation';
import { db } from '@/lib/database';
import AddToCartButton from '@/components/AddToCartButton'; // Client
import ProductGallery from '@/components/ProductGallery'; // Client
import ReviewList from '@/components/ReviewList'; // Server
import RelatedProducts from '@/components/RelatedProducts'; // Server

interface Product {
id: string;
name: string;
price: number;
description: string;
images: string[];
stock: number;
rating: number;
reviewCount: number;
}

async function getProduct(id: string): Promise<Product | null> {
return db.products.findUnique({ where: { id } });
}

export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id);

if (!product) notFound();

return (
<div className="max-w-6xl mx-auto py-12 px-4">
<div className="grid grid-cols-2 gap-12">
{/* Image gallery: Client (slide interaction) */}
<ProductGallery images={product.images} name={product.name} />

{/* Product info: Server */}
<div>
<h1 className="text-3xl font-bold">{product.name}</h1>
<div className="flex items-center gap-2 mt-2">
<span className="text-yellow-500">{'★'.repeat(Math.round(product.rating))}</span>
<span className="text-gray-500">({product.reviewCount} reviews)</span>
</div>
<p className="text-2xl font-bold mt-4">
${product.price.toLocaleString('en-US')}
</p>
<p className="text-gray-600 mt-4">{product.description}</p>

{/* Add to cart button: Client (click event) */}
<AddToCartButton
productId={product.id}
stock={product.stock}
/>
</div>
</div>

{/* Review list: Server (direct DB query) */}
<div className="mt-16">
<h2 className="text-2xl font-bold mb-6">Reviews</h2>
<ReviewList productId={product.id} />
</div>

{/* Related products: Server */}
<div className="mt-16">
<h2 className="text-2xl font-bold mb-6">Related Products</h2>
<RelatedProducts productId={product.id} />
</div>
</div>
);
}
// components/AddToCartButton.tsx — Client Component
'use client';

import { useState, useTransition } from 'react';
import { addToCart } from '@/app/actions';

interface Props {
productId: string;
stock: number;
}

export default function AddToCartButton({ productId, stock }: Props) {
const [quantity, setQuantity] = useState(1);
const [added, setAdded] = useState(false);
const [isPending, startTransition] = useTransition();

const handleAddToCart = () => {
startTransition(async () => {
await addToCart(productId, quantity);
setAdded(true);
setTimeout(() => setAdded(false), 2000);
});
};

if (stock === 0) {
return (
<button disabled className="w-full mt-6 py-3 bg-gray-300 text-gray-500 rounded-lg">
Out of Stock
</button>
);
}

return (
<div className="mt-6 space-y-3">
<div className="flex items-center gap-3">
<label className="font-medium">Quantity</label>
<select
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
className="border rounded px-2 py-1"
>
{Array.from({ length: Math.min(stock, 10) }, (_, i) => i + 1).map((n) => (
<option key={n} value={n}>{n}</option>
))}
</select>
<span className="text-sm text-gray-500">In stock: {stock}</span>
</div>
<button
onClick={handleAddToCart}
disabled={isPending || added}
className="w-full py-3 bg-blue-500 text-white rounded-lg font-medium
hover:bg-blue-600 disabled:opacity-50 transition-colors"
>
{isPending ? 'Adding...' : added ? '✓ Added' : 'Add to Cart'}
</button>
</div>
);
}
// components/ReviewList.tsx — Server Component
import { db } from '@/lib/database';

interface Review {
id: string;
rating: number;
title: string;
content: string;
author: string;
createdAt: string;
}

export default async function ReviewList({ productId }: { productId: string }) {
const reviews: Review[] = await db.reviews.findMany({
where: { productId },
orderBy: { createdAt: 'desc' },
take: 10,
});

if (reviews.length === 0) {
return <p className="text-gray-500">No reviews yet.</p>;
}

return (
<ul className="space-y-6">
{reviews.map((review) => (
<li key={review.id} className="border-b pb-6">
<div className="flex items-center justify-between">
<span className="font-medium">{review.author}</span>
<span className="text-yellow-500">{'★'.repeat(review.rating)}</span>
</div>
<h3 className="font-semibold mt-2">{review.title}</h3>
<p className="text-gray-600 mt-1">{review.content}</p>
<time className="text-sm text-gray-400 mt-2 block">
{new Date(review.createdAt).toLocaleDateString('en-US')}
</time>
</li>
))}
</ul>
);
}

Suspense and Streaming

Server Components can be combined with <Suspense> to handle slow data fetching via streaming.

// app/dashboard/page.tsx
import { Suspense } from 'react';
import UserStats from '@/components/UserStats';
import RecentOrders from '@/components/RecentOrders';
import Notifications from '@/components/Notifications';

export default function DashboardPage() {
return (
<div className="space-y-6">
<h1>Dashboard</h1>

{/* Fast component: renders immediately without Suspense */}
<UserStats />

{/* Slow components: each streams independently */}
<div className="grid grid-cols-2 gap-6">
<Suspense fallback={<OrderSkeleton />}>
<RecentOrders />
</Suspense>

<Suspense fallback={<NotificationSkeleton />}>
<Notifications />
</Suspense>
</div>
</div>
);
}

function OrderSkeleton() {
return (
<div className="animate-pulse space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-16 bg-gray-200 rounded"></div>
))}
</div>
);
}

function NotificationSkeleton() {
return (
<div className="animate-pulse space-y-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-12 bg-gray-200 rounded"></div>
))}
</div>
);
}

Pro Tips

1. Component Boundary Decision Checklist

If the component has any of the following → Client Component:
✓ useState, useReducer, useEffect
✓ Event handlers: onClick, onChange, onSubmit
✓ window, document, navigator
✓ UI changes driven by user interaction

If it only has the following → Keep as Server Component:
✓ DB queries, API calls
✓ File system access
✓ Server-only environment variables
✓ Static rendering (no animations)

2. Push Client Components Down to the Leaves

// ❌ Inefficient — unnecessarily large range as Client Component
'use client';

export default function ArticlePage({ article }: { article: any }) {
const [liked, setLiked] = useState(false);

return (
<article>
{/* No interaction here — better rendered on the server */}
<h1>{article.title}</h1>
<p>{article.content}</p>
<img src={article.image} alt={article.title} />

{/* Only this part needs interaction */}
<button onClick={() => setLiked(!liked)}>
{liked ? '❤️' : '🤍'}
</button>
</article>
);
}
// ✅ Improved — isolate Client Component to the smallest scope
// components/LikeButton.tsx — Client
'use client';
import { useState } from 'react';

export default function LikeButton() {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked(!liked)}>
{liked ? '❤️' : '🤍'}
</button>
);
}

// app/articles/[slug]/page.tsx — mostly Server
import LikeButton from '@/components/LikeButton';

export default async function ArticlePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const article = await getArticle(slug);

return (
<article>
<h1>{article.title}</h1>
<p>{article.content}</p>
<img src={article.image} alt={article.title} />
<LikeButton /> {/* Only this is a Client Component */}
</article>
);
}

3. Context Is Client-Only — Provider Pattern

// components/providers/ThemeProvider.tsx
'use client';

import { createContext, useContext, useState } from 'react';

type Theme = 'light' | 'dark';

const ThemeContext = createContext<{
theme: Theme;
toggleTheme: () => void;
} | null>(null);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');

return (
<ThemeContext.Provider
value={{ theme, toggleTheme: () => setTheme((t) => (t === 'light' ? 'dark' : 'light')) }}
>
{children}
</ThemeContext.Provider>
);
}

export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('Use inside ThemeProvider');
return ctx;
}
// app/layout.tsx — Server Component, but Provider is a Client Component
import { ThemeProvider } from '@/components/providers/ThemeProvider';

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>
{/* Provider is a Client Component, children can still be Server Components */}
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}

4. Protect Server-Only Code — server-only Package

npm install server-only
// lib/database.ts
import 'server-only'; // Build error if this file is included in the client bundle

import { PrismaClient } from '@prisma/client';

const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };

export const db = globalForPrisma.prisma ?? new PrismaClient();

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;

5. Deduplicate Requests with React's cache()

// lib/queries.ts
import { cache } from 'react';
import { db } from './database';

// Cached within the same render cycle when called with identical arguments
export const getUser = cache(async (id: string) => {
return db.users.findUnique({ where: { id } });
});

export const getPost = cache(async (slug: string) => {
return db.posts.findUnique({ where: { slug } });
});
// Even if multiple components call the same query, the DB is hit only once
// app/page.tsx
async function UserGreeting({ userId }: { userId: string }) {
const user = await getUser(userId); // DB query
return <p>Hello, {user?.name}</p>;
}

async function UserAvatar({ userId }: { userId: string }) {
const user = await getUser(userId); // Returned from cache (no DB query)
return <img src={user?.avatar} alt={user?.name} />;
}
Advertisement