Full-Stack & Server Components
Master modern SSR frameworks and React 19 server features
Full-Stack & Server Components in React
Master full-stack React development with free flashcards and spaced repetition practice. This lesson covers React Server Components (RSC), the differences between client and server rendering, data fetching patterns, and how to build full-stack applications with modern Reactβessential concepts for building performant, scalable web applications.
Welcome to Full-Stack React Development π
React has evolved beyond a client-side library into a full-stack framework. With the introduction of React Server Components, you can now build applications where some components render on the server and others on the client, creating a hybrid architecture that combines the best of both worlds. This paradigm shift enables better performance, smaller bundle sizes, and more intuitive data fetching patterns.
Understanding server components is crucial for modern React development, especially when working with frameworks like Next.js 13+ that embrace this architecture. Let's explore how to leverage server-side rendering, streaming, and server components to build lightning-fast applications.
Core Concepts π‘
What Are React Server Components?
React Server Components (RSC) are components that render exclusively on the server. Unlike traditional server-side rendering (SSR) where components render once on the server and then hydrate on the client, RSC never ship their code to the browser.
π§ Memory Device: SERVER vs CLIENT
SERVER components = Secure data access, Exclusive backend logic, Reduced bundle size, Very fast initial load, Efficient caching, Reads from databases directlyCLIENT components = Click handlers, Listeners for events, Interactivity required, Effects (useEffect), Navigator APIs, Time-based updates (useState)
Server vs Client Components: The Key Differences
| Feature | Server Components π₯οΈ | Client Components π» |
|---|---|---|
| Where they run | Server only | Server (for SSR) + Client |
| Bundle size impact | Zero (code stays on server) | Increases bundle (shipped to browser) |
| Data fetching | Direct database/API access | Must use fetch/API routes |
| Interactivity | No hooks, no state, no events | Full access to hooks and events |
| Secrets/credentials | Can use safely (never exposed) | Must avoid (shipped to client) |
| Re-rendering | On navigation or refresh | On state/prop changes |
How to Declare Component Types
In React with frameworks like Next.js 13+, components are server components by default. You opt into client components explicitly:
// ServerComponent.js - Default (no directive needed)
export default function ServerComponent() {
// Can access databases, file system, etc.
const data = await database.query('SELECT * FROM users');
return <div>{data.length} users</div>;
}
// ClientComponent.js - Must use 'use client' directive
'use client';
import { useState } from 'react';
export default function ClientComponent() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
β οΈ Critical rule: The 'use client' directive must be at the very top of the file, before any imports!
The Component Boundary Model
ββββββββββββββββββββββββββββββββββββββββββββββββ
β SERVER COMPONENT (Default) β
β β Can import other server components β
β β Can import client components β
β β Direct data access β
β β No hooks or interactivity β
β β
β ββββββββββββββββββββββββββββββββββββββ β
β β CLIENT COMPONENT ('use client') β β
β β β Can use hooks and state β β
β β β Can have event handlers β β
β β β Cannot import server components β β
β β β Props from server must be β β
β β serializable (JSON-compatible) β β
β ββββββββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββ
π‘ Pro Tip: Think of the server/client boundary like a one-way membrane. Data flows from server β client, but you can't flow server-only code back up.
Data Fetching in Server Components
One of the biggest advantages of server components is async/await at the component level:
// In traditional React, this wouldn't work!
// In RSC, this is the recommended pattern
export default async function ProductList() {
// This runs on the server, not in the browser
const response = await fetch('https://api.example.com/products', {
headers: { 'Authorization': `Bearer ${process.env.API_SECRET}` }
});
const products = await response.json();
return (
<div>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
π Security benefit: Notice how we can safely use process.env.API_SECRET because this code never reaches the browser!
Streaming and Suspense
Server components work seamlessly with React's Suspense for streaming HTML to the browser:
import { Suspense } from 'react';
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<LoadingSkeleton />}>
<SlowDataComponent />
</Suspense>
<Suspense fallback={<Spinner />}>
<AnotherSlowComponent />
</Suspense>
</div>
);
}
async function SlowDataComponent() {
const data = await fetchSlowData();
return <div>{data}</div>;
}
STREAMING RENDERING TIMELINE
0ms βββΊ Send initial HTML (header, static content)
β
100ms βββΊ First Suspense resolves, stream chunk
β
250ms βββΊ Second Suspense resolves, stream chunk
β
300ms βββΊ Complete, client hydrates interactive parts
β
User sees content progressively
β
No waiting for slowest data
β
Better perceived performance
Composition Patterns
Pattern 1: Server component wraps client component
// app/page.js (Server Component)
import ClientCounter from './ClientCounter';
export default async function Page() {
const initialValue = await fetchInitialValue();
return (
<div>
<h1>Welcome</h1>
<ClientCounter initial={initialValue} />
</div>
);
}
// ClientCounter.js
'use client';
import { useState } from 'react';
export default function ClientCounter({ initial }) {
const [count, setCount] = useState(initial);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
Pattern 2: Passing server components as children
// ServerParent.js (Server)
import ClientWrapper from './ClientWrapper';
import ServerChild from './ServerChild';
export default function ServerParent() {
return (
<ClientWrapper>
<ServerChild /> {/* This stays a server component! */}
</ClientWrapper>
);
}
// ClientWrapper.js
'use client';
export default function ClientWrapper({ children }) {
return <div className="interactive-wrapper">{children}</div>;
}
π― Key insight: Even though ServerChild is rendered inside a client component, it maintains its server component nature because it's passed as children (a serialized slot).
Full-Stack Data Mutations
React Server Components integrate with Server Actions for mutations:
// app/actions.js
'use server';
import { db } from './database';
export async function createPost(formData) {
const title = formData.get('title');
const content = formData.get('content');
await db.posts.create({ title, content });
revalidatePath('/posts'); // Refresh the posts list
}
// app/new-post/page.js (Server Component)
import { createPost } from '../actions';
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" required />
<button type="submit">Create Post</button>
</form>
);
}
π‘ This works without JavaScript! The form submits via HTML form submission, then progressively enhances with JS when available.
Examples with Explanations π
Example 1: Building a Product Catalog with Mixed Components
// app/products/page.js (Server Component)
import { db } from '@/lib/database';
import ProductCard from './ProductCard';
import FilterPanel from './FilterPanel';
export default async function ProductsPage({ searchParams }) {
// Server component can directly query database
const products = await db.product.findMany({
where: {
category: searchParams.category,
price: { lte: searchParams.maxPrice }
},
include: { reviews: true }
});
const categories = await db.category.findMany();
return (
<div className="products-layout">
<FilterPanel categories={categories} />
<div className="product-grid">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
);
}
// app/products/FilterPanel.js (Client Component)
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';
export default function FilterPanel({ categories }) {
const router = useRouter();
const searchParams = useSearchParams();
const [selectedCategory, setSelectedCategory] = useState(
searchParams.get('category') || 'all'
);
const handleCategoryChange = (category) => {
setSelectedCategory(category);
const params = new URLSearchParams(searchParams);
params.set('category', category);
router.push(`/products?${params.toString()}`);
};
return (
<aside>
<h3>Filter by Category</h3>
{categories.map(cat => (
<button
key={cat.id}
onClick={() => handleCategoryChange(cat.slug)}
className={selectedCategory === cat.slug ? 'active' : ''}
>
{cat.name}
</button>
))}
</aside>
);
}
// app/products/ProductCard.js (Server Component)
import AddToCartButton from './AddToCartButton';
export default async function ProductCard({ product }) {
const averageRating = product.reviews.reduce(
(sum, r) => sum + r.rating, 0
) / product.reviews.length;
return (
<article className="product-card">
<img src={product.imageUrl} alt={product.name} />
<h3>{product.name}</h3>
<p>${product.price}</p>
<div>β {averageRating.toFixed(1)} ({product.reviews.length} reviews)</div>
<AddToCartButton productId={product.id} />
</article>
);
}
// app/products/AddToCartButton.js (Client Component)
'use client';
import { useState } from 'react';
import { addToCart } from '../actions';
export default function AddToCartButton({ productId }) {
const [isAdding, setIsAdding] = useState(false);
const handleClick = async () => {
setIsAdding(true);
await addToCart(productId);
setIsAdding(false);
};
return (
<button onClick={handleClick} disabled={isAdding}>
{isAdding ? 'Adding...' : 'Add to Cart'}
</button>
);
}
Why this architecture works:
- π₯οΈ
ProductsPageis a server component that queries the database directly - π»
FilterPanelis a client component because it needsuseStateandonClickhandlers - π₯οΈ
ProductCardstays a server component to calculate ratings server-side - π»
AddToCartButtonis a client component for interactivity - π― Result: Minimal JavaScript shipped to browser, fast initial load, full interactivity where needed
Example 2: Nested Layouts with Data Dependencies
// app/dashboard/layout.js (Server Component)
import { auth } from '@/lib/auth';
import { db } from '@/lib/database';
import Sidebar from './Sidebar';
export default async function DashboardLayout({ children }) {
const session = await auth();
if (!session) {
redirect('/login');
}
const user = await db.user.findUnique({
where: { id: session.userId },
include: { notifications: { where: { read: false } } }
});
return (
<div className="dashboard-layout">
<Sidebar user={user} unreadCount={user.notifications.length} />
<main>{children}</main>
</div>
);
}
// app/dashboard/Sidebar.js (Client Component)
'use client';
import { useState } from 'react';
import Link from 'next/link';
export default function Sidebar({ user, unreadCount }) {
const [isExpanded, setIsExpanded] = useState(true);
return (
<aside className={isExpanded ? 'expanded' : 'collapsed'}>
<button onClick={() => setIsExpanded(!isExpanded)}>β°</button>
<div className="user-info">
<img src={user.avatar} alt={user.name} />
{isExpanded && <span>{user.name}</span>}
</div>
<nav>
<Link href="/dashboard">Dashboard</Link>
<Link href="/dashboard/messages">
Messages {unreadCount > 0 && <span className="badge">{unreadCount}</span>}
</Link>
<Link href="/dashboard/settings">Settings</Link>
</nav>
</aside>
);
}
Key pattern: The layout fetches data on the server (auth check, user data) then passes serializable props to the client component for interactivity. The authentication logic never reaches the browser.
Example 3: Parallel Data Fetching with Suspense
// app/analytics/page.js (Server Component)
import { Suspense } from 'react';
import RevenueChart from './RevenueChart';
import UserStats from './UserStats';
import RecentOrders from './RecentOrders';
import LoadingCard from './LoadingCard';
export default function AnalyticsPage() {
return (
<div className="analytics-grid">
<Suspense fallback={<LoadingCard title="Revenue" />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<LoadingCard title="Users" />}>
<UserStats />
</Suspense>
<Suspense fallback={<LoadingCard title="Orders" />}>
<RecentOrders />
</Suspense>
</div>
);
}
// app/analytics/RevenueChart.js (Server Component)
import { db } from '@/lib/database';
import ChartRenderer from './ChartRenderer';
export default async function RevenueChart() {
// Simulate slow query
await new Promise(resolve => setTimeout(resolve, 2000));
const revenueData = await db.order.groupBy({
by: ['createdAt'],
_sum: { total: true },
where: {
createdAt: { gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) }
}
});
return <ChartRenderer data={revenueData} type="revenue" />;
}
// app/analytics/UserStats.js (Server Component)
import { db } from '@/lib/database';
export default async function UserStats() {
// This runs in parallel with RevenueChart!
const [totalUsers, activeUsers, newUsers] = await Promise.all([
db.user.count(),
db.user.count({ where: { lastActive: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) } } }),
db.user.count({ where: { createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) } } })
]);
return (
<div className="stats-card">
<h3>User Statistics</h3>
<dl>
<dt>Total Users</dt>
<dd>{totalUsers.toLocaleString()}</dd>
<dt>Active (7d)</dt>
<dd>{activeUsers.toLocaleString()}</dd>
<dt>New (7d)</dt>
<dd>{newUsers.toLocaleString()}</dd>
</dl>
</div>
);
}
Performance win: All three components fetch data in parallel. The page streams HTML as each Suspense boundary resolves, giving users progressive loading instead of waiting for the slowest query.
Example 4: Server Actions for Form Mutations
// app/actions/posts.js
'use server';
import { db } from '@/lib/database';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
export async function createPost(formData) {
const title = formData.get('title');
const content = formData.get('content');
const published = formData.get('published') === 'on';
// Validation
if (!title || title.length < 3) {
return { error: 'Title must be at least 3 characters' };
}
// Database mutation
const post = await db.post.create({
data: { title, content, published }
});
// Revalidate the posts list cache
revalidatePath('/posts');
// Redirect to the new post
redirect(`/posts/${post.id}`);
}
export async function updatePost(postId, formData) {
const updates = {
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published') === 'on'
};
await db.post.update({
where: { id: postId },
data: updates
});
revalidatePath(`/posts/${postId}`);
revalidatePath('/posts');
return { success: true };
}
// app/posts/new/page.js (Server Component)
import { createPost } from '@/app/actions/posts';
import SubmitButton from './SubmitButton';
export default function NewPostPage() {
return (
<form action={createPost}>
<div>
<label htmlFor="title">Title</label>
<input id="title" name="title" required />
</div>
<div>
<label htmlFor="content">Content</label>
<textarea id="content" name="content" rows={10} />
</div>
<div>
<label>
<input type="checkbox" name="published" />
Publish immediately
</label>
</div>
<SubmitButton />
</form>
);
}
// app/posts/new/SubmitButton.js (Client Component)
'use client';
import { useFormStatus } from 'react-dom';
export default function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create Post'}
</button>
);
}
Modern form handling: Server Actions enable progressive enhancement. The form works without JavaScript (standard form submission), but when JS is available, it submits without a full page reload and shows pending state.
Common Mistakes β οΈ
Mistake 1: Using hooks in server components
β Wrong:
export default async function ServerComponent() {
const [data, setData] = useState([]); // Error!
return <div>{data}</div>;
}
β Right:
export default async function ServerComponent() {
const data = await fetchData(); // Use async/await directly
return <div>{data}</div>;
}
Why: Server components don't have a lifecycle or re-render mechanism. They execute once per request. Use async/await for data fetching instead of useEffect.
Mistake 2: Importing server components into client components
β Wrong:
'use client';
import ServerComponent from './ServerComponent'; // Error!
export default function ClientComponent() {
return <ServerComponent />;
}
β Right:
// Parent.js (Server)
import ClientComponent from './ClientComponent';
import ServerComponent from './ServerComponent';
export default function Parent() {
return (
<ClientComponent>
<ServerComponent /> {/* Pass as children */}
</ClientComponent>
);
}
// ClientComponent.js
'use client';
export default function ClientComponent({ children }) {
return <div className="wrapper">{children}</div>;
}
Why: Client components can't import server components because the server code would need to be bundled for the browser. Instead, compose them from a parent server component.
Mistake 3: Passing non-serializable props to client components
β Wrong:
// Server component
export default function Parent() {
const handleClick = () => console.log('clicked'); // Function!
return <ClientChild onAction={handleClick} />; // Error!
}
β Right:
// Server component
export default function Parent() {
const data = { id: 1, name: 'Test' }; // Serializable object
return <ClientChild data={data} />;
}
Why: Props passed from server to client components must be JSON-serializable (strings, numbers, objects, arrays). Functions, dates, and class instances don't serialize properly.
Mistake 4: Forgetting 'use client' directive
β Wrong:
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0); // Error in server component!
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
β Right:
'use client'; // Must be first line!
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
Why: Without the directive, the component defaults to being a server component, which can't use hooks or event handlers.
Mistake 5: Over-using client components
β Wrong:
'use client';
export default function ProductList({ products }) {
// This entire component is client-side now!
return (
<div>
{products.map(p => (
<div key={p.id}>
<h3>{p.name}</h3>
<button>Add to cart</button>
</div>
))}
</div>
);
}
β Right:
// ProductList.js (Server Component)
import AddToCartButton from './AddToCartButton';
export default function ProductList({ products }) {
return (
<div>
{products.map(p => (
<div key={p.id}>
<h3>{p.name}</h3>
<AddToCartButton productId={p.id} /> {/* Only this is client-side */}
</div>
))}
</div>
);
}
// AddToCartButton.js (Client Component)
'use client';
export default function AddToCartButton({ productId }) {
return <button onClick={() => addToCart(productId)}>Add to cart</button>;
}
Why: Keep the 'use client' boundary as deep as possible. Only make interactive parts client components to minimize bundle size.
Key Takeaways π―
Server components are the default in modern React frameworks. They render on the server, never ship code to the browser, and can directly access backend resources.
Use 'use client' sparingly at the top of files that need interactivity, hooks, or browser APIs. Keep the client boundary as small as possible.
Composition over imports: Pass server components as
childrenor props to client components rather than importing them directly.Async/await in components is the new data fetching paradigm. No more useEffect for initial data loading in server components.
Suspense enables streaming: Wrap async server components in Suspense boundaries to stream HTML progressively and show loading states.
Server Actions provide a modern, progressive-enhancement-friendly way to handle mutations without separate API routes.
Props must be serializable when passing from server to client components. Stick to JSON-compatible data types.
Security by default: Server components can safely use API keys and database credentials because the code never reaches the browser.
π Quick Reference Card
| Server Component | Default, async/await, no hooks, direct DB access |
| Client Component | 'use client' directive, hooks, event handlers |
| Composition Rule | Server can import client, not vice versa |
| Props Rule | Server β Client props must be JSON-serializable |
| Data Fetching | Server: async/await, Client: useEffect/SWR/React Query |
| Suspense | Wrap async components for streaming & loading states |
| Server Actions | 'use server' for form mutations & progressive enhancement |
| Bundle Impact | Server: 0 KB, Client: full component code shipped |
π Further Study
React Server Components RFC - https://github.com/reactjs/rfcs/blob/main/text/0188-server-components.md - The original proposal explaining the motivation and design
Next.js App Router Documentation - https://nextjs.org/docs/app - Comprehensive guide to building with React Server Components in Next.js
Server Actions Deep Dive - https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations - Detailed patterns for mutations and form handling