The App Router is Next.js's modern routing system built on React Server Components. Here's how to use it effectively.
File-Based Routing#
app/
├── page.tsx # / (home)
├── layout.tsx # Root layout
├── loading.tsx # Loading UI
├── error.tsx # Error boundary
├── not-found.tsx # 404 page
├── about/
│ └── page.tsx # /about
├── blog/
│ ├── page.tsx # /blog
│ └── [slug]/
│ └── page.tsx # /blog/:slug
├── (marketing)/ # Route group (no URL impact)
│ ├── layout.tsx
│ ├── pricing/
│ │ └── page.tsx # /pricing
│ └── features/
│ └── page.tsx # /features
└── api/
└── users/
└── route.ts # /api/users
Pages and Layouts#
1// app/layout.tsx - Root layout (required)
2export default function RootLayout({
3 children,
4}: {
5 children: React.ReactNode;
6}) {
7 return (
8 <html lang="en">
9 <body>
10 <Header />
11 <main>{children}</main>
12 <Footer />
13 </body>
14 </html>
15 );
16}
17
18// app/page.tsx - Home page
19export default function HomePage() {
20 return <h1>Welcome</h1>;
21}
22
23// app/dashboard/layout.tsx - Nested layout
24export default function DashboardLayout({
25 children,
26}: {
27 children: React.ReactNode;
28}) {
29 return (
30 <div className="dashboard">
31 <Sidebar />
32 <div className="content">{children}</div>
33 </div>
34 );
35}
36
37// app/dashboard/page.tsx
38export default function DashboardPage() {
39 return <h1>Dashboard</h1>;
40}Server Components (Default)#
1// Server Components can:
2// - Fetch data directly
3// - Access backend resources
4// - Keep sensitive data on server
5// - Reduce client bundle size
6
7// app/posts/page.tsx
8async function getPosts() {
9 const res = await fetch('https://api.example.com/posts', {
10 cache: 'force-cache', // Default: cache indefinitely
11 });
12 return res.json();
13}
14
15export default async function PostsPage() {
16 const posts = await getPosts();
17
18 return (
19 <ul>
20 {posts.map((post) => (
21 <li key={post.id}>{post.title}</li>
22 ))}
23 </ul>
24 );
25}
26
27// Direct database access
28import { prisma } from '@/lib/prisma';
29
30export default async function UsersPage() {
31 const users = await prisma.user.findMany();
32
33 return (
34 <ul>
35 {users.map((user) => (
36 <li key={user.id}>{user.name}</li>
37 ))}
38 </ul>
39 );
40}Client Components#
1// Add 'use client' directive for interactivity
2'use client';
3
4import { useState } from 'react';
5
6export default function Counter() {
7 const [count, setCount] = useState(0);
8
9 return (
10 <button onClick={() => setCount(count + 1)}>
11 Count: {count}
12 </button>
13 );
14}
15
16// Mixing Server and Client Components
17// app/page.tsx (Server Component)
18import Counter from './Counter'; // Client Component
19
20async function getData() {
21 const res = await fetch('...');
22 return res.json();
23}
24
25export default async function Page() {
26 const data = await getData();
27
28 return (
29 <div>
30 <h1>{data.title}</h1>
31 <Counter /> {/* Client Component */}
32 </div>
33 );
34}Data Fetching#
1// Fetch with caching options
2async function getData() {
3 // Default: cache forever
4 const res1 = await fetch('https://api.example.com/data');
5
6 // Revalidate every 60 seconds
7 const res2 = await fetch('https://api.example.com/data', {
8 next: { revalidate: 60 },
9 });
10
11 // No caching
12 const res3 = await fetch('https://api.example.com/data', {
13 cache: 'no-store',
14 });
15
16 return { res1, res2, res3 };
17}
18
19// Parallel data fetching
20async function Page() {
21 // Start both requests simultaneously
22 const [users, posts] = await Promise.all([
23 getUsers(),
24 getPosts(),
25 ]);
26
27 return (
28 <div>
29 <UserList users={users} />
30 <PostList posts={posts} />
31 </div>
32 );
33}
34
35// Sequential when dependent
36async function Page({ params }: { params: { id: string } }) {
37 const user = await getUser(params.id);
38 const posts = await getUserPosts(user.id); // Depends on user
39
40 return <UserProfile user={user} posts={posts} />;
41}Loading and Error States#
1// app/dashboard/loading.tsx
2export default function Loading() {
3 return <div>Loading...</div>;
4}
5
6// app/dashboard/error.tsx
7'use client';
8
9export default function Error({
10 error,
11 reset,
12}: {
13 error: Error;
14 reset: () => void;
15}) {
16 return (
17 <div>
18 <h2>Something went wrong!</h2>
19 <p>{error.message}</p>
20 <button onClick={reset}>Try again</button>
21 </div>
22 );
23}
24
25// app/not-found.tsx
26export default function NotFound() {
27 return (
28 <div>
29 <h2>Not Found</h2>
30 <p>Could not find the requested resource.</p>
31 </div>
32 );
33}
34
35// Trigger not-found programmatically
36import { notFound } from 'next/navigation';
37
38async function getPost(id: string) {
39 const post = await prisma.post.findUnique({ where: { id } });
40
41 if (!post) {
42 notFound();
43 }
44
45 return post;
46}Dynamic Routes#
1// app/blog/[slug]/page.tsx
2interface Props {
3 params: { slug: string };
4 searchParams: { [key: string]: string | undefined };
5}
6
7export default async function BlogPost({ params, searchParams }: Props) {
8 const post = await getPost(params.slug);
9
10 return <article>{post.content}</article>;
11}
12
13// Generate static paths
14export async function generateStaticParams() {
15 const posts = await getPosts();
16
17 return posts.map((post) => ({
18 slug: post.slug,
19 }));
20}
21
22// Catch-all routes: [...slug]
23// app/docs/[...slug]/page.tsx
24// Matches /docs/a, /docs/a/b, /docs/a/b/c
25
26// Optional catch-all: [[...slug]]
27// Matches /docs, /docs/a, /docs/a/bServer Actions#
1// app/actions.ts
2'use server';
3
4import { revalidatePath } from 'next/cache';
5import { redirect } from 'next/navigation';
6
7export async function createPost(formData: FormData) {
8 const title = formData.get('title') as string;
9 const content = formData.get('content') as string;
10
11 await prisma.post.create({
12 data: { title, content },
13 });
14
15 revalidatePath('/posts');
16 redirect('/posts');
17}
18
19// app/posts/new/page.tsx
20import { createPost } from '../actions';
21
22export default function NewPost() {
23 return (
24 <form action={createPost}>
25 <input name="title" required />
26 <textarea name="content" required />
27 <button type="submit">Create</button>
28 </form>
29 );
30}
31
32// With useFormState for feedback
33'use client';
34
35import { useFormState } from 'react-dom';
36import { createPost } from '../actions';
37
38export default function NewPost() {
39 const [state, formAction] = useFormState(createPost, { error: null });
40
41 return (
42 <form action={formAction}>
43 {state.error && <p className="error">{state.error}</p>}
44 <input name="title" required />
45 <button type="submit">Create</button>
46 </form>
47 );
48}Route Handlers (API Routes)#
1// app/api/users/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3
4export async function GET(request: NextRequest) {
5 const searchParams = request.nextUrl.searchParams;
6 const limit = searchParams.get('limit') || '10';
7
8 const users = await prisma.user.findMany({
9 take: parseInt(limit),
10 });
11
12 return NextResponse.json(users);
13}
14
15export async function POST(request: NextRequest) {
16 const body = await request.json();
17
18 const user = await prisma.user.create({
19 data: body,
20 });
21
22 return NextResponse.json(user, { status: 201 });
23}
24
25// app/api/users/[id]/route.ts
26export async function GET(
27 request: NextRequest,
28 { params }: { params: { id: string } }
29) {
30 const user = await prisma.user.findUnique({
31 where: { id: params.id },
32 });
33
34 if (!user) {
35 return NextResponse.json({ error: 'Not found' }, { status: 404 });
36 }
37
38 return NextResponse.json(user);
39}Metadata#
1// Static metadata
2export const metadata = {
3 title: 'My App',
4 description: 'Welcome to my app',
5};
6
7// Dynamic metadata
8export async function generateMetadata({
9 params,
10}: {
11 params: { slug: string };
12}) {
13 const post = await getPost(params.slug);
14
15 return {
16 title: post.title,
17 description: post.excerpt,
18 openGraph: {
19 title: post.title,
20 images: [post.image],
21 },
22 };
23}Best Practices#
Components:
✓ Default to Server Components
✓ Use Client Components only for interactivity
✓ Keep client bundles small
✓ Colocate data fetching with components
Data:
✓ Fetch in parallel when possible
✓ Use appropriate caching strategies
✓ Revalidate on mutations
✓ Handle loading and error states
Structure:
✓ Use route groups for organization
✓ Share layouts effectively
✓ Use loading.tsx for streaming
✓ Implement proper error boundaries
Conclusion#
The App Router brings powerful patterns with Server Components, streaming, and server actions. Default to server components, use client components for interactivity, and leverage the built-in caching and revalidation system. Structure your routes with layouts and route groups for maintainable applications.