Introduction
React has fundamentally shifted toward server-first architecture. With Server Components now stable and Next.js 15, building performant React applications has never been easier. This guide covers the modern React paradigm.
What Are Server Components?
The Concept
Server Components (RSC) run exclusively on the server:
- Zero JavaScript sent to client
- Direct database access
- Native Node.js modules
- Automatic code splitting
Client vs Server
// Server Component (default in App Router)
async function ServerComponent() {
// This runs on the server only
const user = await db.users.findById(params.id);
return (
<div>
<h1>{user.name}</h1>
{/* No JS bundle for this component */}
</div>
);
}
// Client Component
'use client';
function ClientComponent() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(c => c + 1)}>
{count}
</button>
);
}
Next.js 15 Features
1. Partial Prerendering
// app/blog/[slug]/page.tsx
import { Suspense } from 'react';
// Static content loads instantly
async function BlogContent({ slug }: { slug: string }) {
const post = await db.posts.findBySlug(slug);
return <article>{post.content}</article>;
}
// Dynamic content streams in
async function Comments({ slug }: { slug: string }) {
const comments = await db.comments.findByPost(slug);
return <CommentsList data={comments} />;
}
export default function Page({ params }: { params: { slug: string } }) {
return (
<div>
{/* Static - served instantly */}
<BlogContent slug={params.slug} />
{/* Dynamic - streams in */}
<Suspense fallback={<CommentsSkeleton />}>
<Comments slug={params.slug} />
</Suspense>
</div>
);
}
2. Server Actions
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title');
await db.posts.create({ title });
// Revalidate the blog page
revalidatePath('/blog');
return { success: true };
}
// app/page.tsx
import { createPost } from './actions';
export default function Page() {
return (
<form action={createPost}>
<input name="title" placeholder="Post title" />
<button type="submit">Create</button>
</form>
);
}
3. Enhanced Fetch
// Automatic caching and revalidation
async function getData() {
const res = await fetch('https://api.example.com/data', {
next: {
revalidate: 3600, // Revalidate every hour
tags: ['my-data'] // Tag for on-demand revalidation
}
});
return res.json();
}
// In API route - trigger revalidation
import { revalidateTag } from 'next/cache';
export async function POST() {
revalidateTag('my-data');
return Response.json({ revalidated: true });
}
Data Fetching Patterns
Parallel Requests
// Fetch in parallel - no waterfall
async function Page() {
const [user, posts, comments] = await Promise.all([
fetch('/api/user').then(r => r.json()),
fetch('/api/posts').then(r => r.json()),
fetch('/api/comments').then(r => r.json()),
]);
return (
<div>
<UserProfile user={user} />
<PostsList posts={posts} />
<CommentsList comments={comments} />
</div>
);
}
Streaming with Suspense
import { Suspense } from 'react';
function Profile() {
return (
<Suspense fallback={<ProfileSkeleton />}>
<ProfileContent />
</Suspense>
);
}
function Dashboard() {
return (
<div className="grid">
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<ActivityFeed />
</Suspense>
</div>
);
}
Migration Guide
From Pages to App Router
// Old: pages/api/users.ts
export default function handler(req, res) {
const users = await db.users.findAll();
res.status(200).json(users);
}
// New: app/api/users/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
const users = await db.users.findAll();
return NextResponse.json(users);
}
Using Client Libraries
// Server Component
async function ServerPage() {
// Direct database access
const data = await db.query('SELECT * FROM users');
// Pass to client component
return <ClientComponent data={data} />;
}
// Client Component with hooks
'use client';
function ClientComponent({ data }) {
const [query, setQuery] = useState('');
// Use SWR for client-side fetching
const { data: filtered } = useSWR(
`/api/users?q=${query}`,
fetcher
);
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<List data={filtered || data} />
</div>
);
}
Best Practices
1. Keep Server Components Default
// ✅ Good - Server Component
async function PostList() {
const posts = await db.posts.findAll();
return <List items={posts} />;
}
// Only add 'client' when needed
'use client';
function LikeButton({ postId }) {
const [liked, setLiked] = useState(false);
// Interactive logic here
}
2. Use Server Actions
// ✅ Good - Server Actions for mutations
export async function deletePost(id: string) {
await db.posts.delete(id);
revalidatePath('/posts');
}
// For complex client state, use mutations
'use client';
function SearchInput() {
const [query, setQuery] = useState('');
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
);
}
3. Cache Wisely
// Dynamic data - no cache
fetch('https://api/user', { cache: 'no-store' });
// Static data - cache indefinitely
fetch('https://api/config');
// Revalidate on demand
fetch('https://api/posts', { next: { tags: ['posts'] } });
External Resources
Documentation
Tools
Key Takeaways
- Server Components run on server, zero JS to client
- Next.js 15 brings partial prerendering
- Server Actions handle mutations without API routes
- Suspense enables streaming and progressive loading
- Keep server default, use ‘client’ sparingly
- Parallel fetching prevents waterfalls
Comments