Next.js 14 App Router: Complete Guide with Server Components and Server Actions
Next.js 14 introduced significant improvements to the App Router, making it more powerful and developer-friendly. In this comprehensive guide, we'll explore the App Router, Server Components, and Server Actions with practical examples.
What's New in Next.js 14?
The App Router brings several game-changing features:
- 🚀 Server Components by default - Better performance and smaller bundle sizes
- ⚡ Server Actions - Simplified data mutations without API routes
- 🎯 Improved routing - File-system based with powerful conventions
- 📦 Streaming and Suspense - Progressive rendering for better UX
- 🔄 Parallel Routes - Render multiple pages simultaneously
Understanding the App Directory Structure
The new App Router uses a file-system based routing with special files:
app/
├── layout.tsx # Root layout (required)
├── page.tsx # Home page
├── loading.tsx # Loading UI
├── error.tsx # Error UI
├── not-found.tsx # 404 page
├── blog/
│ ├── page.tsx # /blog
│ ├── [slug]/
│ │ └── page.tsx # /blog/[slug]
│ └── layout.tsx # Blog layout
└── api/
└── route.ts # API route
Server Components vs Client Components
Server Components (Default)
Server Components run on the server and don't ship JavaScript to the client:
// app/blog/page.tsx
// This is a Server Component by default
async function BlogPage() {
// Fetch data directly in the component
const posts = await fetch('https://api.example.com/posts').then(res => res.json());
return (
<div>
<h1>Blog Posts</h1>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
);
}
export default BlogPage;
Client Components
Use the 'use client' directive for interactive components:
// app/components/LikeButton.tsx
'use client';
import { useState } from 'react';
export function LikeButton() {
const [likes, setLikes] = useState(0);
return (
<button onClick={() => setLikes(likes + 1)}>
❤️ {likes} Likes
</button>
);
}
Dynamic Routes and Params
Creating Dynamic Routes
// app/blog/[slug]/page.tsx
interface PageProps {
params: { slug: string };
searchParams: { [key: string]: string | string[] | undefined };
}
async function BlogPost({ params }: PageProps) {
const post = await fetch(`https://api.example.com/posts/${params.slug}`)
.then(res => res.json());
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
export default BlogPost;
Generating Static Params
// Generate static pages at build time
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts')
.then(res => res.json());
return posts.map((post) => ({
slug: post.slug,
}));
}
Server Actions: The Game Changer
Server Actions allow you to run server-side code directly from your components:
// app/actions/posts.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// Save to database
await db.posts.create({
data: { title, content }
});
// Revalidate the blog page
revalidatePath('/blog');
return { success: true };
}
Using Server Actions in Forms
// app/blog/new/page.tsx
import { createPost } from '@/app/actions/posts';
function NewPostPage() {
return (
<form action={createPost}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<button type="submit">Create Post</button>
</form>
);
}
export default NewPostPage;
Data Fetching Patterns
Parallel Data Fetching
async function Dashboard() {
// These fetch in parallel
const [user, posts, stats] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchStats(),
]);
return (
<div>
<UserProfile user={user} />
<PostList posts={posts} />
<Statistics stats={stats} />
</div>
);
}
Sequential Data Fetching
async function UserDashboard({ userId }: { userId: string }) {
// Fetch user first
const user = await fetchUser(userId);
// Then fetch user-specific data
const posts = await fetchUserPosts(user.id);
return (
<div>
<h1>{user.name}'s Dashboard</h1>
<PostList posts={posts} />
</div>
);
}
Loading and Error States
Loading UI
// app/blog/loading.tsx
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
</div>
);
}
Error Handling
// app/blog/error.tsx
'use client';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}
Streaming with Suspense
// app/dashboard/page.tsx
import { Suspense } from 'react';
function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
{/* This loads immediately */}
<UserInfo />
{/* This streams in when ready */}
<Suspense fallback={<LoadingSkeleton />}>
<SlowDataComponent />
</Suspense>
</div>
);
}
Metadata and SEO
Static Metadata
// app/blog/[slug]/page.tsx
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Blog Post',
description: 'Read our latest blog post',
};
Dynamic Metadata
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const post = await fetchPost(params.slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
};
}
Best Practices
1. Use Server Components by Default
// ✅ Good - Server Component
async function ProductList() {
const products = await fetchProducts();
return <div>{/* render products */}</div>;
}
// ❌ Avoid - Unnecessary Client Component
'use client';
function ProductList() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetchProducts().then(setProducts);
}, []);
return <div>{/* render products */}</div>;
}
2. Colocate Server Actions
// app/blog/actions.ts
'use server';
export async function updatePost(id: string, data: FormData) {
// Server-side logic here
}
3. Optimize Images
import Image from 'next/image';
function Hero() {
return (
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={600}
priority
/>
);
}
4. Use Route Handlers for APIs
// app/api/posts/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
const posts = await fetchPosts();
return NextResponse.json(posts);
}
export async function POST(request: Request) {
const data = await request.json();
const post = await createPost(data);
return NextResponse.json(post);
}
Performance Tips
- Minimize Client Components: Use them only when you need interactivity
- Leverage Streaming: Use Suspense for better perceived performance
- Optimize Data Fetching: Fetch in parallel when possible
- Use Caching: Leverage Next.js automatic caching
- Implement ISR: Use Incremental Static Regeneration for dynamic content
Common Pitfalls to Avoid
❌ Don't use 'use client' everywhere
❌ Don't fetch data in Client Components unnecessarily
❌ Don't forget to handle loading and error states
❌ Don't ignore TypeScript types
❌ Don't skip metadata for SEO
Conclusion
The Next.js 14 App Router represents a significant leap forward in React development. By embracing Server Components and Server Actions, you can build faster, more efficient applications with less code.
Key takeaways:
- Server Components are the default and should be your go-to choice
- Server Actions simplify data mutations
- Streaming and Suspense improve user experience
- The file-system based routing is intuitive and powerful
Start building with the App Router today and experience the future of React development!
Resources
Enjoyed this article?
Explore more deep dives into architecture, performance, and modern .NET.