Master Next.js App Router — Server Components, Server Actions, streaming, caching strategies, layouts, route handlers, and migration from Pages Router.
The Next.js App Router (introduced in Next.js 13, stable in 14) changes how you think about React apps. Server Components, streaming, and Server Actions replace patterns you've used for years. Here's everything you need to know.
app/
├── layout.tsx ← root layout (required)
├── page.tsx ← / route
├── loading.tsx ← Suspense boundary for this segment
├── error.tsx ← error boundary
├── not-found.tsx ← 404 for this segment
├── blog/
│ ├── layout.tsx ← shared blog layout
│ ├── page.tsx ← /blog route
│ └── [slug]/ ← dynamic segment
│ └── page.tsx ← /blog/:slug route
├── api/
│ └── users/
│ └── route.ts ← /api/users route handler
└── (marketing)/ ← route group (no URL segment)
├── about/page.tsx ← /about
└── pricing/page.tsx← /pricing
// ✅ Server Component (default in App Router)
// Runs on the server — can use async/await, access DB directly
// Cannot use hooks, browser APIs, or event handlers
import { db } from "@/lib/db"
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await db.posts.findUnique({ where: { slug: params.slug } })
if (!post) notFound()
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
)
}
// ✅ Client Component — opt-in with "use client"
// Runs in the browser, can use hooks and events
"use client"
import { useState } from "react"
export function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false)
return (
<button onClick={() => setLiked(!liked)}>
{liked ? "❤️ Liked" : "🤍 Like"}
</button>
)
}
Rule of thumb: Keep everything Server Components by default. Add "use client" only when you need interactivity or browser APIs.
// app/layout.tsx — root layout, wraps every page
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Header />
<main>{children}</main>
<Footer />
</body>
</html>
)
}
// app/blog/layout.tsx — wraps only /blog/* pages
export default function BlogLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex gap-8">
<aside><BlogSidebar /></aside>
<div>{children}</div>
</div>
)
}
// app/actions.ts
"use server"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
export async function createPost(formData: FormData) {
const title = formData.get("title") as string
const content = formData.get("content") as string
// Validate
if (!title || title.length < 3) {
return { error: "Title must be at least 3 characters" }
}
// Save to DB — direct, no API route needed
const post = await db.posts.create({ data: { title, content } })
revalidatePath("/blog") // invalidate blog cache
redirect(`/blog/${post.slug}`) // navigate to new post
}
// app/blog/new/page.tsx
import { createPost } from "@/app/actions"
export default function NewPostPage() {
return (
<form action={createPost}> {/* action = Server Action */}
<input name="title" placeholder="Post title" required />
<textarea name="content" />
<button type="submit">Publish</button>
</form>
)
}
// Full Route Cache: static pages cached at build time
// To opt out (dynamic):
export const dynamic = "force-dynamic"
// fetch() is extended with caching options
const data = await fetch("https://api.example.com/posts", {
cache: "force-cache", // static (default)
next: { revalidate: 3600 }, // ISR: revalidate every hour
})
// Cache tags — invalidate specific data
const posts = await fetch("/api/posts", {
next: { tags: ["posts"] }
})
// In a Server Action:
import { revalidateTag } from "next/cache"
revalidateTag("posts") // invalidate all fetches tagged "posts"
import { Suspense } from "react"
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
{/* Fast components render immediately */}
<QuickStats />
{/* Slow components stream in later */}
<Suspense fallback={<Skeleton />}>
<SlowAnalyticsChart />
</Suspense>
<Suspense fallback={<Skeleton />}>
<RecentOrders />
</Suspense>
</div>
)
}
// SlowAnalyticsChart is a Server Component with a slow fetch
async function SlowAnalyticsChart() {
const data = await fetchSlowAnalytics() // takes 2-3 seconds
return <Chart data={data} />
}
// The page HTML starts streaming immediately.
// SlowAnalyticsChart's HTML is streamed in when ready.
// app/api/posts/route.ts
import { NextRequest, NextResponse } from "next/server"
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl
const page = Number(searchParams.get("page") ?? 1)
const posts = await db.posts.findMany({
skip: (page - 1) * 20,
take: 20,
orderBy: { createdAt: "desc" },
})
return NextResponse.json({ data: posts })
}
export async function POST(request: NextRequest) {
const body = await request.json()
const post = await db.posts.create({ data: body })
return NextResponse.json(post, { status: 201 })
}
// app/api/posts/[id]/route.ts
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
await db.posts.delete({ where: { id: params.id } })
return new NextResponse(null, { status: 204 })
}
// Static metadata
export const metadata = {
title: "My Blog",
description: "A blog about web development",
}
// Dynamic metadata
export async function generateMetadata({ params }: { params: { slug: string } }) {
const post = await db.posts.findUnique({ where: { slug: params.slug } })
return {
title: post?.title ?? "Not Found",
description: post?.excerpt,
openGraph: {
images: [{ url: post?.coverImage }],
},
}
}