Next.js App Router: Complete Guide to Modern React Development
Next.js 13+ introduced the App Router, a revolutionary approach to building React applications. Let’s explore its features and best practices.
App Router Basics
File-based Routing
app/
├── page.tsx # Home page (/)
├── about/
│ └── page.tsx # About page (/about)
├── blog/
│ ├── page.tsx # Blog list (/blog)
│ └── [slug]/
│ └── page.tsx # Blog post (/blog/:slug)
└── dashboard/
├── layout.tsx # Nested layout
└── page.tsx # Dashboard (/dashboard)
Basic Page
// app/page.tsx
export default function Home() {
return (
<main>
<h1>Welcome to Next.js App Router</h1>
</main>
);
}
Server Components (Default)
Components are Server Components by default:
// app/blog/page.tsx
async function getBlogPosts() {
const res = await fetch('https://api.example.com/posts');
return res.json();
}
export default async function BlogPage() {
const posts = await getBlogPosts();
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
);
}
Benefits of Server Components
- Zero JavaScript: No client bundle
- Direct backend access: Database, APIs
- SEO-friendly: Fully rendered HTML
- Faster initial load: Less JS to parse
Client Components
Use 'use client' for interactivity:
'use client';
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
When to Use Client Components
- Event listeners (onClick, onChange)
- State (useState, useReducer)
- Effects (useEffect)
- Browser APIs (localStorage, window)
- Custom hooks
Layouts
Shared UI across routes:
// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
<main>{children}</main>
<footer>© 2024</footer>
</body>
</html>
);
}
Nested Layouts
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="dashboard">
<aside>
<nav>{/* Dashboard navigation */}</nav>
</aside>
<section>{children}</section>
</div>
);
}
Dynamic Routes
Single Dynamic Segment
// app/blog/[slug]/page.tsx
export default function BlogPost({
params,
}: {
params: { slug: string };
}) {
return <h1>Post: {params.slug}</h1>;
}
// Generate static paths
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return posts.map((post) => ({
slug: post.slug,
}));
}
Multiple Dynamic Segments
// app/shop/[category]/[product]/page.tsx
export default function ProductPage({
params,
}: {
params: { category: string; product: string };
}) {
return (
<div>
<h1>Category: {params.category}</h1>
<h2>Product: {params.product}</h2>
</div>
);
}
Catch-all Segments
// app/docs/[...slug]/page.tsx
export default function DocsPage({
params,
}: {
params: { slug: string[] };
}) {
// /docs/a/b/c -> params.slug = ['a', 'b', 'c']
return <h1>Docs: {params.slug.join('/')}</h1>;
}
Data Fetching
Parallel Data Fetching
async function getUser() {
const res = await fetch('https://api.example.com/user');
return res.json();
}
async function getPosts() {
const res = await fetch('https://api.example.com/posts');
return res.json();
}
export default async function Dashboard() {
// Fetch in parallel
const [user, posts] = await Promise.all([
getUser(),
getPosts(),
]);
return (
<div>
<h1>Welcome, {user.name}</h1>
<PostList posts={posts} />
</div>
);
}
Sequential Data Fetching
export default async function Page() {
// First fetch
const user = await getUser();
// Wait for user, then fetch posts
const posts = await getUserPosts(user.id);
return <div>{/* ... */}</div>;
}
Caching and Revalidation
// Cache for 1 hour
async function getData() {
const res = await fetch('https://api.example.com/data', {
next: { revalidate: 3600 }
});
return res.json();
}
// No caching
async function getDynamicData() {
const res = await fetch('https://api.example.com/data', {
cache: 'no-store'
});
return res.json();
}
Loading and Error States
Loading UI
// app/blog/loading.tsx
export default function Loading() {
return <div>Loading blog posts...</div>;
}
Error Handling
// app/blog/error.tsx
'use client';
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div>
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</div>
);
}
Streaming with Suspense
import { Suspense } from 'react';
async function Posts() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return <PostList posts={posts} />;
}
export default function Page() {
return (
<div>
<h1>Blog</h1>
<Suspense fallback={<div>Loading posts...</div>}>
<Posts />
</Suspense>
</div>
);
}
Metadata and SEO
Static Metadata
// app/about/page.tsx
export const metadata = {
title: 'About Us',
description: 'Learn more about our company',
};
export default function About() {
return <div>About page</div>;
}
Dynamic Metadata
// app/blog/[slug]/page.tsx
export async function generateMetadata({
params,
}: {
params: { slug: string };
}) {
const post = await fetch(`https://api.example.com/posts/${params.slug}`)
.then(r => r.json());
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
};
}
Route Handlers (API Routes)
// app/api/users/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
const users = await db.users.findAll();
return NextResponse.json(users);
}
export async function POST(request: Request) {
const body = await request.json();
const user = await db.users.create(body);
return NextResponse.json(user, { status: 201 });
}
Dynamic Route Handlers
// app/api/users/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const user = await db.users.findById(params.id);
return NextResponse.json(user);
}
Server Actions
// app/actions.ts
'use server';
export async function createPost(formData: FormData) {
const title = formData.get('title');
const content = formData.get('content');
await db.posts.create({ title, content });
revalidatePath('/blog');
}
// app/new-post/page.tsx
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>
);
}
Best Practices
1. Server Components by Default
// ✅ Server Component (default)
async function UserProfile() {
const user = await getUser();
return <div>{user.name}</div>;
}
// Only use 'use client' when needed
'use client';
function InteractiveButton() {
return <button onClick={() => alert('Hi!')}>Click</button>;
}
2. Composition Pattern
// Server Component
import ClientComponent from './ClientComponent';
export default async function ServerComponent() {
const data = await fetchData();
return (
<div>
<h1>Server Content</h1>
<ClientComponent data={data} />
</div>
);
}
3. Optimize Images
import Image from 'next/image';
export default function Hero() {
return (
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority
/>
);
}
4. Use TypeScript
interface PageProps {
params: { slug: string };
searchParams: { [key: string]: string | string[] | undefined };
}
export default async function Page({ params, searchParams }: PageProps) {
// Fully typed!
}
Performance Tips
- Use Server Components: Reduce client-side JavaScript
- Streaming: Use Suspense for better UX
- Image Optimization: Use next/image
- Font Optimization: Use next/font
- Code Splitting: Automatic with dynamic imports
Conclusion
Next.js App Router represents the future of React development. By leveraging Server Components, streaming, and modern patterns, you can build faster, more SEO-friendly applications with better developer experience.
Start with Server Components by default, add Client Components only when needed, and embrace the streaming capabilities for optimal performance.