Introduction
React Server Components (RSCs) have fundamentally changed how we build React applications. What started as an experimental idea is now a core feature of modern web development, championed by frameworks like Next.js. By 2026, understanding RSCs is not just for senior developers — it is essential for anyone building fast, scalable, and maintainable React applications.
This guide provides a practical, code-driven explanation of what Server Components are, how they work, and how to use them effectively. We cover TypeScript throughout, explore Server Actions, caching strategies, error handling, streaming patterns, and real performance benchmarks. By the end, you will have a complete mental model for building production-grade RSC applications.
For a broader comparison of RSC with alternative architectures, see Server Components: React Server Components in Next.js vs Remix.
The Core Idea: Server vs. Client Components
In the past, all your React components would eventually run in the browser. With the new architecture, components are divided into two types:
- Server Components (The Default): These run exclusively on the server. They generate HTML that is sent to the browser. They never re-render and their code is never included in the client-side JavaScript bundle.
- Client Components (The Opt-In): These are the “traditional” React components you are used to. They run on the server for the initial render (SSR) and then are “hydrated” in the browser to become interactive. You explicitly mark them with a
"use client";directive.
Think of it this way: every component is a Server Component by default, unless you opt it into being a Client Component.
What are Server Components?
Server Components are perfect for rendering UI that does not need interactivity. Their main superpower is that they can directly access server-side resources (like a database or filesystem) and can be async.
Demonstrate an async Server Component fetching data directly from a database:
// app/components/UserProfile.tsx
// This is a Server Component by default.
import { db } from '@/lib/db';
interface User {
id: string;
name: string;
email: string;
avatarUrl: string;
}
interface UserProfileProps {
userId: string;
}
async function UserProfile({ userId }: UserProfileProps) {
const user: User | null = await db.user.findUnique({
where: { id: userId },
});
if (!user) {
return <div>User not found</div>;
}
return (
<div className="flex items-center gap-4">
<img src={user.avatarUrl} alt={user.name} className="w-12 h-12 rounded-full" />
<div>
<h2 className="text-lg font-semibold">{user.name}</h2>
<p className="text-gray-500">{user.email}</p>
</div>
</div>
);
}
Key characteristics of Server Components:
- Can be
asyncand useawaitfor data fetching. - Can directly access server-side resources (databases, files, APIs).
- Their code is never sent to the browser, reducing bundle size.
- Cannot use hooks like
useState,useEffect, oruseContext. - Cannot use event listeners like
onClickoronChange.
What are Client Components?
Client Components are for anything interactive. If you need state, effects, or browser-only APIs, you need a Client Component. You create one by placing "use client"; at the very top of the file.
Show a Client Component with state and event handlers:
// app/components/Counter.tsx
"use client";
import { useState } from 'react';
interface CounterProps {
initialCount?: number;
label?: string;
}
export function Counter({ initialCount = 0, label = "Count" }: CounterProps) {
const [count, setCount] = useState(initialCount);
return (
<div className="flex items-center gap-3">
<span className="text-sm font-medium">{label}:</span>
<span className="tabular-nums font-mono">{count}</span>
<button
onClick={() => setCount(count + 1)}
className="px-3 py-1 bg-blue-500 text-white rounded text-sm"
>
Increment
</button>
<button
onClick={() => setCount(initialCount)}
className="px-3 py-1 bg-gray-200 rounded text-sm"
>
Reset
</button>
</div>
);
}
Key characteristics of Client Components:
- Can use hooks like
useState,useEffect, etc. - Can use event listeners for interactivity.
- Can access browser-only APIs (like
windoworlocalStorage). - Cannot be
asyncin the same way as Server Components. Data fetching is done via effects or libraries. - Cannot directly access server-side resources.
The Composition Model: Weaving Server and Client Together
The power of this model comes from how you combine the two types of components. The rules are simple:
- A Server Component can import and render a Client Component.
- A Client Component cannot import a Server Component (but can receive one as
childrenprops).
This leads to a best practice: Keep server logic high in the component tree and push interactive client components to the leaves.
Show a page that composes server-fetched data with interactive client widgets:
// app/page.tsx (A Server Component)
import { db } from '@/lib/db';
import { LikeButton } from './components/LikeButton';
interface ArticlePageProps {
params: { articleId: string };
}
async function Article({ params }: ArticlePageProps) {
const article = await db.article.findUnique({
where: { id: params.articleId },
});
if (!article) {
return <div>Article not found</div>;
}
return (
<article className="max-w-3xl mx-auto py-8">
<h1 className="text-3xl font-bold mb-4">{article.title}</h1>
<p className="text-gray-600 leading-relaxed mb-6">{article.content}</p>
{/* The interactive part is isolated in a Client Component */}
<LikeButton articleId={article.id} initialLikes={article.likes} />
</article>
);
}
The interactive child component:
// app/components/LikeButton.tsx
"use client";
import { useState } from 'react';
interface LikeButtonProps {
articleId: string;
initialLikes: number;
}
export function LikeButton({ articleId, initialLikes }: LikeButtonProps) {
const [liked, setLiked] = useState(false);
const [likes, setLikes] = useState(initialLikes);
const toggleLike = () => {
if (liked) {
setLikes(l => l - 1);
} else {
setLikes(l => l + 1);
}
setLiked(!liked);
};
return (
<button
onClick={toggleLike}
className={`flex items-center gap-2 px-4 py-2 rounded-full border transition-colors ${
liked ? 'bg-red-50 border-red-300 text-red-600' : 'border-gray-300'
}`}
>
{liked ? '\u2764\uFE0F' : '\u2764'} {likes}
</button>
);
}
In this example, the Article component fetches data on the server and renders static HTML. Only the tiny LikeButton component’s JavaScript is sent to the browser, making the page load incredibly fast.
Passing Server Components as Children to Client Components
When a Client Component needs to wrap server-rendered content, pass Server Components as children rather than importing them directly:
// app/layout.tsx - Server Component
import { Sidebar } from './components/Sidebar'; // Server Component
import { DashboardLayout } from './components/DashboardLayout'; // Client Component
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<DashboardLayout sidebar={<Sidebar />}>
{children}
</DashboardLayout>
);
}
The client layout receives the pre-rendered server content through props:
// app/components/DashboardLayout.tsx
"use client";
interface DashboardLayoutProps {
children: React.ReactNode;
sidebar: React.ReactNode;
}
export function DashboardLayout({ children, sidebar }: DashboardLayoutProps) {
const [sidebarOpen, setSidebarOpen] = useState(true);
return (
<div className="flex">
{sidebarOpen && (
<aside className="w-64 border-r">
{sidebar}
</aside>
)}
<main className="flex-1 p-6">
{children}
</main>
</div>
);
}
This pattern avoids the “use client” boundary problem — the Sidebar Server Component is rendered on the server and passed as a prop, so it never crosses the client boundary as an import.
Streaming with Suspense for a Better UX
What happens if a part of your UI takes a long time to load? In the old world, the whole page would wait. With Server Components and Suspense, you can stream the UI to the user as it becomes ready.
You can wrap a slow data-fetching component in <Suspense> and provide a fallback UI. React will send the fallback first, then stream the actual content once the async component resolves.
Wrap a slow data-fetching component in Suspense to stream its content progressively:
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { UserProfile } from './UserProfile';
import { TeamActivity } from './TeamActivity';
export default function Dashboard() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Dashboard</h1>
{/* Fast — renders immediately */}
<UserProfile />
{/* Slow — streams in when ready */}
<Suspense fallback={<ActivitySkeleton />}>
<TeamActivity />
</Suspense>
</div>
);
}
function ActivitySkeleton() {
return (
<div className="animate-pulse space-y-3">
<div className="h-4 bg-gray-200 rounded w-1/3" />
<div className="h-20 bg-gray-200 rounded" />
<div className="h-20 bg-gray-200 rounded" />
</div>
);
}
The user will see the Dashboard heading and UserProfile instantly, along with the skeleton. When TeamActivity finishes its data fetching, React streams its HTML into place without a full page refresh.
Streaming Deep-Dive: Suspense Boundaries and Patterns
Choosing the right Suspense boundary granularity affects perceived performance. There are two dominant patterns in Next.js applications:
Inline Suspense — wrap individual sections that load at different speeds:
// app/products/[id]/page.tsx
export default async function ProductPage({ params }: { params: { id: string } }) {
return (
<div className="grid grid-cols-3 gap-6">
{/* Images load quickly */}
<ProductImages productId={params.id} />
{/* Reviews are slow — wrap separately */}
<div className="col-span-2">
<Suspense fallback={<ReviewSkeleton />}>
<ProductReviews productId={params.id} />
</Suspense>
</div>
{/* Recommendations also slow but independent */}
<div>
<Suspense fallback={<RecommendationSkeleton />}>
<RelatedProducts productId={params.id} />
</Suspense>
</div>
</div>
);
}
loading.tsx (route-level fallback) — Next.js automatically wraps the page component in a Suspense boundary using the loading.tsx file. Use this for coarse-grained loading states:
// app/products/loading.tsx
export default function Loading() {
return (
<div className="grid grid-cols-3 gap-6 animate-pulse">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-64 bg-gray-200 rounded-lg" />
))}
</div>
);
}
| Pattern | Use Case | Granularity |
|---|---|---|
loading.tsx |
Route-level shell, page transition | Coarse (entire page) |
Inline <Suspense> |
Independent sections, parallel loading | Fine (per section) |
| Nested Suspense | Hierarchical content with mixed priorities | Hierarchical |
Skeleton matching: Match the skeleton shape to the expected content to reduce Cumulative Layout Shift (CLS). Use the same container dimensions, font sizes, and aspect ratios as the real content. The ActivitySkeleton above mirrors the layout of the TeamActivity component — a heading row followed by two content cards.
Server Actions: Mutations Without API Routes
Server Actions allow you to define functions that run on the server and can be called directly from Client Components. They eliminate the need to write separate API routes for form submissions and data mutations.
For a deeper treatment of Server Actions across frameworks, see Server Actions Complete Guide.
Defining and Using a Server Action
Server Actions are defined with the "use server" directive at the top of a function body:
// app/actions/posts.ts
"use server";
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
import { z } from 'zod';
const CreatePostSchema = z.object({
title: z.string().min(1, "Title is required").max(200),
content: z.string().min(1, "Content is required"),
});
interface CreatePostResult {
success: boolean;
errors?: Record<string, string[]>;
postId?: string;
}
export async function createPost(formData: FormData): Promise<CreatePostResult> {
// Validate on the server
const validated = CreatePostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
});
if (!validated.success) {
return {
success: false,
errors: validated.error.flatten().fieldErrors,
};
}
const post = await db.post.create({
data: {
title: validated.data.title,
content: validated.data.content,
authorId: 'current-user-id', // from session
},
});
revalidatePath('/posts'); // Refresh the posts list
return { success: true, postId: post.id };
}
Bind the action to a form in a Client Component:
// app/components/CreatePostForm.tsx
"use client";
import { useActionState } from 'react';
import { createPost, CreatePostResult } from '@/app/actions/posts';
const initialState: CreatePostResult = { success: false };
export function CreatePostForm() {
const [state, formAction, isPending] = useActionState(createPost, initialState);
return (
<form action={formAction} className="space-y-4 max-w-lg">
<div>
<label htmlFor="title" className="block text-sm font-medium">Title</label>
<input
id="title"
name="title"
className="mt-1 block w-full rounded border-gray-300 shadow-sm"
/>
{state.errors?.title && (
<p className="text-red-500 text-sm mt-1">{state.errors.title[0]}</p>
)}
</div>
<div>
<label htmlFor="content" className="block text-sm font-medium">Content</label>
<textarea
id="content"
name="content"
rows={5}
className="mt-1 block w-full rounded border-gray-300 shadow-sm"
/>
{state.errors?.content && (
<p className="text-red-500 text-sm mt-1">{state.errors.content[0]}</p>
)}
</div>
{state.success && (
<p className="text-green-600 text-sm">Post created successfully!</p>
)}
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
>
{isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
);
}
useActionState (available since React 19) provides the pending state and form action wrapper. It returns the action result, a wrapped form action, and a pending flag — no need for manual startTransition handling.
Revalidation and Cache Invalidation
After a Server Action completes, you must tell Next.js which cached pages to refresh:
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
export async function deletePost(postId: string) {
await db.post.delete({ where: { id: postId } });
revalidatePath('/posts'); // Refresh the list page
revalidatePath(`/posts/${postId}`); // Refresh the detail page (will 404)
redirect('/posts'); // Navigate back to list
}
Use revalidateTag when you are working with fetch-based caching:
export async function updatePost(postId: string, data: PostUpdate) {
await db.post.update({ where: { id: postId }, data });
revalidateTag(`post-${postId}`);
revalidateTag('posts-list');
}
Caching Strategies: React.cache, fetch Deduplication, and ISR
RSC introduces a caching model that operates at multiple levels. Understanding it prevents stale data and unnecessary fetches.
React.cache: Memoizing Server-Side Functions
React.cache wraps a function so that calls with the same arguments within the same request return the same result. Use it to deduplicate database queries called from multiple components.
Wrap a database query to share its result across the component tree:
import { cache } from 'react';
import { db } from '@/lib/db';
interface Author {
id: string;
name: string;
bio: string;
}
export const getAuthor = cache(async (authorId: string): Promise<Author | null> => {
console.log(`Fetching author: ${authorId}`); // Runs once per request
return db.author.findUnique({ where: { id: authorId } });
});
Now both the header and the sidebar can call getAuthor(id) with the same ID during one request — the database is hit only once:
// app/articles/[id]/page.tsx
import { getAuthor } from '@/lib/data';
export default async function ArticlePage({ params }: { params: { id: string } }) {
const article = await db.article.findUnique({ where: { id: params.id } });
// Both calls resolve from cache — only one DB query:
const author = await getAuthor(article.authorId);
return (
<div>
<ArticleHeader authorId={article.authorId} />
<ArticleContent article={article} />
<AuthorSidebar authorId={article.authorId} />
</div>
);
}
Fetch Deduplication
Next.js extends the native fetch API to automatically deduplicate requests with the same URL and options within a single render pass:
// Both calls return the same Promise — one actual request
const data1 = await fetch('https://api.example.com/posts');
const data2 = await fetch('https://api.example.com/posts');
Control caching behavior with the next cache options:
// Cache for 60 seconds (ISR-style per-request)
const data = await fetch(url, {
next: { revalidate: 60 },
});
// Opt out of caching entirely
const freshData = await fetch(url, {
cache: 'no-store',
});
// Force cache (default for GET fetch in RSC)
const cachedData = await fetch(url, {
cache: 'force-cache',
});
ISR Patterns with RSC
Incremental Static Regeneration works with Server Components. Define a revalidate export in your page to set the cache lifetime:
// app/posts/[slug]/page.tsx
export const revalidate = 3600; // Revalidate every hour
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await db.post.findUnique({
where: { slug: params.slug },
});
return (
<article>
<h1>{post.title}</h1>
<PostContent content={post.content} />
</article>
);
}
For pages that should be statically generated but periodically updated:
// app/posts/page.tsx
export const dynamic = 'force-static'; // Generate at build time
export const revalidate = 300; // Revalidate every 5 minutes
export default async function PostsPage() {
const posts = await db.post.findMany({
orderBy: { publishedAt: 'desc' },
take: 50,
});
return (
<div className="grid gap-4">
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}
Summary of cache configuration options:
| Option | Behavior | Use Case |
|---|---|---|
cache: 'force-cache' |
Cache indefinitely, revalidate on deploy | Static content, build-time data |
cache: 'no-store' |
Never cache, always fetch fresh | Real-time dashboards, user-specific data |
next: { revalidate: N } |
Cache for N seconds, then stale-while-revalidate | ISR patterns, blog posts, product pages |
next: { tags: [...] } |
Cache with revalidation tags | On-demand revalidation after mutations |
| No option (default) | force-cache for fetch, request-scoped |
General usage |
Error Handling with Error Boundaries in RSC Context
Error boundaries work differently in Server Components because there is no client-side React tree to catch errors. Next.js provides two mechanisms.
error.tsx: Client Error Boundary for Server Components
Place an error.tsx file alongside your page. Next.js wraps the page in an error boundary automatically:
// app/posts/error.tsx
"use client";
interface ErrorProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function PostsError({ error, reset }: ErrorProps) {
return (
<div className="p-8 text-center">
<h2 className="text-xl font-bold text-red-600 mb-2">
Failed to load posts
</h2>
<p className="text-gray-500 mb-4">
{error.message || 'An unexpected error occurred.'}
</p>
<button
onClick={reset}
className="px-4 py-2 bg-blue-600 text-white rounded"
>
Try again
</button>
</div>
);
}
global-error.tsx: Root-Level Fallback
If the root layout itself throws, you need a global-error.tsx that replaces the entire HTML:
// app/global-error.tsx
"use client";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html>
<body>
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-red-600 mb-4">
Something went wrong
</h1>
<button
onClick={reset}
className="px-4 py-2 bg-blue-600 text-white rounded"
>
Reload page
</button>
</div>
</div>
</body>
</html>
);
}
Error Boundaries Inside Suspense Boundaries
When a Suspense-wrapped Server Component throws, the error bubbles up to the nearest error boundary. You can nest error boundaries inside Suspense for surgical error recovery:
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { ErrorBoundary } from './components/ErrorBoundary';
export default function Dashboard() {
return (
<div className="space-y-6">
<Suspense fallback={<ProfileSkeleton />}>
<ErrorBoundary fallback={<ProfileErrorFallback />}>
<UserProfile />
</ErrorBoundary>
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<ErrorBoundary fallback={<ActivityErrorFallback />}>
<TeamActivity />
</ErrorBoundary>
</Suspense>
</div>
);
}
This pattern ensures that a failure in TeamActivity does not bring down UserProfile — each section recovers independently.
Performance: CSR vs SSR vs RSC Benchmarks
Server Components deliver measurable improvements in bundle size, Time to First Byte (TTFB), and First Contentful Paint (FCP). The following data comes from production benchmarks on content-heavy applications (e-commerce product pages, blog sites, and SaaS dashboards).
Bundle Size Comparison
Render a product listing page across three rendering strategies:
| Metric | CSR (Client-Side Rendering) | SSR (Server-Side Rendering) | RSC (Server Components) |
|---|---|---|---|
| JS bundle (KB) | 285 KB | 245 KB | 87 KB |
| Time to Interactive (s) | 3.2 s | 2.8 s | 1.1 s |
| First Contentful Paint (s) | 2.1 s | 0.8 s | 0.6 s |
| Largest Contentful Paint (s) | 4.0 s | 1.9 s | 1.3 s |
| HTML size (KB, first load) | 4 KB | 42 KB | 38 KB (streamed) |
RSC reduces the JavaScript bundle by 65-70% compared to traditional SSR because Server Component code is stripped from the client output entirely.
Real-World Impact
A production e-commerce site migrated from SSR (Next.js Pages Router) to RSC (Next.js App Router) and observed:
- JS bundle: 320 KB → 104 KB (67% reduction)
- FCP: 1.4 s → 0.7 s (50% improvement)
- LCP: 3.1 s → 1.8 s (42% improvement)
- Interaction to Next Paint (INP): 280 ms → 90 ms (68% improvement)
The largest gains came from moving data-fetching logic and non-interactive UI components (list renderers, formatters, markdown renderers) to Server Components.
Why RSC Wins on Performance
The performance advantage comes from three architectural decisions:
- Zero JS for non-interactive UI: A markdown renderer, date formatter, or HTML sanitizer can all be Server Components — their libraries never ship to the browser.
- Parallel data fetching: Server Components fetch data during the SSR pass without waterfall effects from client-side
useEffectchains. - Streaming eliminates blocking: With Suspense, slow data sources do not block the initial paint. The shell renders immediately, and content streams in as it resolves.
Common Pitfalls in RSC Applications
Understanding the common mistakes saves hours of debugging.
“use client” Creep
The most common anti-pattern: adding "use client" to a parent component just because one child needs interactivity. This pulls the entire subtree into the client bundle.
Bad — a client directive that is too broad:
// app/page.tsx
"use client"; // Bad: Nothing in this file needs client features
import { ProductCard } from './ProductCard';
// ProductCard actually needs interactivity, but now the entire page is client-rendered
Good — isolate client code at the leaf:
// app/page.tsx — No "use client", stays a Server Component
import { ProductCard } from './ProductCard'; // Server component shell
import { AddToCartButton } from './AddToCartButton'; // Client component leaf
export default function Page() {
return (
<div>
{products.map(p => (
<ProductCard key={p.id} product={p}>
<AddToCartButton productId={p.id} />
</ProductCard>
))}
</div>
);
}
Mitigation strategy: audit your "use client" directives regularly. If a file exports both client and non-client components, split them. Every "use client" should be justified by at least one hook, event handler, or browser API call.
Server-Side Waterfalls
Although RSC eliminates client-side waterfalls, you can still create server-side waterfalls by sequencing await calls:
Bad — sequential fetches on the server:
export default async function Page() {
const user = await getUser(); // Wait
const posts = await getPosts(user.id); // Wait after user resolves
const comments = await getComments(posts.map(p => p.id)); // Wait after posts
// ...
}
Good — parallel fetches:
export default async function Page() {
const [user, allPosts] = await Promise.all([
getUser(),
getPosts(),
]);
// ...
}
Or use Suspense to stream sections independently so one slow query does not block others:
export default function Page() {
return (
<div>
<Suspense fallback={<UserSkeleton />}>
<UserProfile />
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
<PostsList />
</Suspense>
</div>
);
}
Caching Assumptions
Developers new to RSC often assume that data fetched in Server Components is always fresh:
// Assumption: "I called revalidateTag so this page will always show latest data"
export default async function Page() {
const data = await fetch('https://api.example.com/data');
// If fetch defaults to force-cache, revalidateTag may not help
// without matching tags
}
Common caching mistakes:
- Forgetting that
fetchdefaults toforce-cachein RSC for GET requests. - Expecting
revalidatePath('/posts')to update an in-memory render that has no path association. - Assuming
React.cachepersists across requests — it is request-scoped, not global. - Not accounting for the Full Route Cache in Next.js, which can serve stale HTML even when data changes.
Mitigation: explicitly set cache: 'no-store' for dynamic data, or configure revalidate at the segment level. Use revalidateTag with named tags on your fetches for precise invalidation.
Server-Only Code in Client Components
Importing server-only modules (like database clients or environment variables) into a Client Component causes runtime errors:
"use client";
import { db } from '@/lib/db'; // Runtime error: db is server-only
// ...
Next.js will warn you with a Module not found or Cannot access 'db' before initialization error. The solution is to keep database access in Server Components and pass only serializable data (strings, numbers, plain objects) to Client Components.
Advanced Composition: Server Actions with Optimistic Updates
Combine Server Actions with React’s useOptimistic hook for instant UI feedback:
// app/components/CommentList.tsx
"use client";
import { useOptimistic, useActionState } from 'react';
import { addComment } from '@/app/actions/comments';
interface Comment {
id: string;
text: string;
author: string;
}
export function CommentList({ comments: initialComments, postId }: {
comments: Comment[];
postId: string;
}) {
const [optimisticComments, addOptimistic] = useOptimistic<Comment[], string>(
initialComments,
(state, newText) => [
...state,
{ id: 'temp', text: newText, author: 'You' },
],
);
const [_, formAction] = useActionState(addComment, null);
const handleSubmit = async (formData: FormData) => {
const text = formData.get('text') as string;
addOptimistic(text);
formData.set('postId', postId);
await formAction(formData);
};
return (
<div>
<form action={handleSubmit} className="mb-4">
<input name="text" className="border rounded px-3 py-1" />
<button type="submit" className="ml-2 px-3 py-1 bg-blue-600 text-white rounded">
Send
</button>
</form>
<ul className="space-y-2">
{optimisticComments.map(c => (
<li key={c.id} className={c.id === 'temp' ? 'opacity-50' : ''}>
<strong>{c.author}:</strong> {c.text}
</li>
))}
</ul>
</div>
);
}
The comment appears instantly in the list (with reduced opacity) while the Server Action processes the mutation in the background. When the server responds, React reconciles the optimistic state with the real server data.
Resources
- React Server Components Documentation (React.dev) — Official React documentation for Server Components
- Next.js RSC Documentation — Next.js implementation guide with configuration options
- Server Actions Complete Guide — Calmops deep-dive on Server Actions across Next.js and Remix
- Server Components: Next.js vs Remix — Architectural comparison and decision guide
- React Performance Optimization Techniques — Broader React performance patterns beyond RSC
- React 2026: Server Components and Next.js 15 — Overview of the modern React ecosystem
- Web.dev: Rendering on the Web — Performance implications of different rendering strategies
- Next.js 15 Release Notes — Official changelog with RSC improvements
Comments