Next.js 15 App Router Deep Dive

Master the Next.js 15 App Router with React Server Components, streaming, and modern patterns. This comprehensive guide covers everything you need to build production-ready applications.
Why App Router?
The App Router introduces powerful new features:
- React Server Components by default
- Streaming and Suspense support
- Improved data fetching patterns
- Built-in loading and error states
- Nested layouts and templates
- Parallel and intercepting routes
File-Based Routing
Basic Structure
app/
├── page.tsx # / route
├── about/
│ └── page.tsx # /about route
├── blog/
│ ├── page.tsx # /blog route
│ └── [slug]/
│ └── page.tsx # /blog/[slug] route
└── layout.tsx # Root layout
Special Files
app/
├── layout.tsx # Shared layout
├── page.tsx # Page component
├── loading.tsx # Loading UI
├── error.tsx # Error UI
├── not-found.tsx # 404 UI
├── template.tsx # Re-mounted layout
└── route.ts # API endpoint
Layouts
Root Layout (Required)
tsx// app/layout.tsx export default function RootLayout({ children, }: { children: React.ReactNode }) { return ( <html lang="en"> <body> <nav>Navigation</nav> {children} <footer>Footer</footer> </body> </html> ) }
Nested Layouts
tsx// app/blog/layout.tsx export default function BlogLayout({ children, }: { children: React.ReactNode }) { return ( <div className="blog-layout"> <aside> <h2>Blog Sidebar</h2> </aside> <main>{children}</main> </div> ) }
Metadata
tsx// app/layout.tsx import type { Metadata } from 'next' export const metadata: Metadata = { title: { default: 'My App', template: '%s | My App' // Blog Post | My App }, description: 'My awesome application', openGraph: { title: 'My App', description: 'My awesome application', images: ['/og-image.jpg'] } }
Dynamic Metadata
tsx// app/blog/[slug]/page.tsx export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> { const post = await getPost(params.slug) return { title: post.title, description: post.excerpt, openGraph: { images: [post.image] } } }
Server Components
Default Behavior
tsx// app/page.tsx - Server Component by default async function getData() { const res = await fetch('https://api.example.com/data', { cache: 'force-cache' // SSG }) return res.json() } export default async function Page() { const data = await getData() return ( <div> <h1>{data.title}</h1> </div> ) }
Data Fetching Patterns
tsx// Static Site Generation (SSG) async function getStaticData() { const res = await fetch('https://api.example.com/data', { cache: 'force-cache' // Default }) return res.json() } // Server-Side Rendering (SSR) async function getDynamicData() { const res = await fetch('https://api.example.com/data', { cache: 'no-store' }) return res.json() } // Incremental Static Regeneration (ISR) async function getRevalidatedData() { const res = await fetch('https://api.example.com/data', { next: { revalidate: 60 } // Revalidate every 60 seconds }) return res.json() }
Parallel Data Fetching
tsxexport default async function Page() { // These requests happen in parallel const [users, posts] = await Promise.all([ fetch('https://api.example.com/users').then(r => r.json()), fetch('https://api.example.com/posts').then(r => r.json()) ]) return ( <div> <Users data={users} /> <Posts data={posts} /> </div> ) }
Client Components
Using 'use client'
tsx// app/components/Counter.tsx 'use client' import { useState } from 'react' export function Counter() { const [count, setCount] = useState(0) return ( <div> <p>Count: {count}</p> <button onClick={() => setCount(count + 1)}> Increment </button> </div> ) }
Client Component Best Practices
tsx// ❌ Don't make the entire page a client component 'use client' export default function Page() { const [state, setState] = useState() return <div>...</div> } // ✅ Extract interactive parts into client components import { InteractiveWidget } from './InteractiveWidget' export default async function Page() { const data = await getData() return ( <div> <h1>{data.title}</h1> <InteractiveWidget /> </div> ) }
Loading States
Loading.tsx
tsx// 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 className="h-4 bg-gray-200 rounded w-full mb-2" /> <div className="h-4 bg-gray-200 rounded w-5/6" /> </div> ) }
Suspense Boundaries
tsximport { Suspense } from 'react' export default function Page() { return ( <div> <h1>My Page</h1> <Suspense fallback={<div>Loading posts...</div>}> <Posts /> </Suspense> <Suspense fallback={<div>Loading comments...</div>}> <Comments /> </Suspense> </div> ) } async function Posts() { const posts = await getPosts() return <div>{/* Render posts */}</div> } async function Comments() { const comments = await getComments() return <div>{/* Render comments */}</div> }
Error Handling
Error.tsx
tsx// 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> ) }
Not Found
tsx// app/blog/[slug]/page.tsx import { notFound } from 'next/navigation' export default async function BlogPost({ params }: { params: { slug: string } }) { const post = await getPost(params.slug) if (!post) { notFound() } return <article>{post.content}</article> }
tsx// app/blog/[slug]/not-found.tsx export default function NotFound() { return ( <div> <h2>Post Not Found</h2> <p>Could not find the requested blog post.</p> </div> ) }
Dynamic Routes
Single Dynamic Segment
tsx// app/blog/[slug]/page.tsx export default async function BlogPost({ params }: { params: { slug: string } }) { const post = await getPost(params.slug) return <article>{post.content}</article> } // Generate static paths at build time export async function generateStaticParams() { const posts = await getPosts() return posts.map((post) => ({ slug: post.slug })) }
Multiple Dynamic Segments
tsx// app/blog/[category]/[slug]/page.tsx export default async function Post({ params }: { params: { category: string; slug: string } }) { const post = await getPost(params.category, params.slug) return <article>{post.content}</article> }
Catch-All Routes
tsx// app/docs/[...slug]/page.tsx export default function Docs({ params }: { params: { slug: string[] } }) { // /docs/a -> ['a'] // /docs/a/b -> ['a', 'b'] // /docs/a/b/c -> ['a', 'b', 'c'] return <div>Docs: {params.slug.join('/')}</div> }
Route Handlers (API Routes)
GET Request
tsx// app/api/posts/route.ts import { NextResponse } from 'next/server' export async function GET() { const posts = await getPosts() return NextResponse.json(posts) }
POST Request
tsx// app/api/posts/route.ts export async function POST(request: Request) { const body = await request.json() const post = await createPost(body) return NextResponse.json(post, { status: 201 }) }
Dynamic Route Handler
tsx// app/api/posts/[id]/route.ts export async function GET( request: Request, { params }: { params: { id: string } } ) { const post = await getPost(params.id) return NextResponse.json(post) } export async function DELETE( request: Request, { params }: { params: { id: string } } ) { await deletePost(params.id) return new Response(null, { status: 204 }) }
Request Helpers
tsxexport async function GET(request: Request) { // URL params const searchParams = request.nextUrl.searchParams const query = searchParams.get('query') // Headers const authorization = request.headers.get('authorization') // Cookies const { cookies } = await import('next/headers') const token = cookies().get('token') return NextResponse.json({ query, authorized: !!authorization }) }
Server Actions
Form Actions
tsx// 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 await db.post.create({ data: { title, content } }) revalidatePath('/blog') redirect('/blog') }
tsx// app/blog/new/page.tsx import { createPost } from '@/app/actions' export default function NewPost() { return ( <form action={createPost}> <input name="title" type="text" required /> <textarea name="content" required /> <button type="submit">Create Post</button> </form> ) }
Progressive Enhancement
tsx'use client' import { useFormStatus } from 'react-dom' import { createPost } from '@/app/actions' function SubmitButton() { const { pending } = useFormStatus() return ( <button type="submit" disabled={pending}> {pending ? 'Creating...' : 'Create Post'} </button> ) } export function NewPostForm() { return ( <form action={createPost}> <input name="title" type="text" required /> <textarea name="content" required /> <SubmitButton /> </form> ) }
Parallel Routes
app/
├── @analytics/
│ └── page.tsx
├── @team/
│ └── page.tsx
└── page.tsx
tsx// app/layout.tsx export default function Layout({ children, analytics, team }: { children: React.ReactNode analytics: React.ReactNode team: React.ReactNode }) { return ( <div> {children} <div className="grid grid-cols-2 gap-4"> {analytics} {team} </div> </div> ) }
Intercepting Routes
app/
├── feed/
│ └── page.tsx
├── photo/
│ └── [id]/
│ └── page.tsx
└── @modal/
└── (.)photo/
└── [id]/
└── page.tsx
tsx// app/@modal/(.)photo/[id]/page.tsx import { Modal } from '@/components/Modal' export default function PhotoModal({ params }: { params: { id: string } }) { return ( <Modal> <img src={`/photos/${params.id}.jpg`} alt="Photo" /> </Modal> ) }
Middleware
tsx// middleware.ts import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' export function middleware(request: NextRequest) { // Check authentication const token = request.cookies.get('token') if (!token && request.nextUrl.pathname.startsWith('/dashboard')) { return NextResponse.redirect(new URL('/login', request.url)) } // Add custom header const response = NextResponse.next() response.headers.set('x-custom-header', 'value') return response } export const config = { matcher: ['/dashboard/:path*', '/api/:path*'] }
Best Practices
1. Fetch Data Where You Need It
tsx// ✅ Fetch in the component that needs the data async function UserProfile({ userId }: { userId: string }) { const user = await getUser(userId) return <div>{user.name}</div> } // Next.js automatically deduplicates requests export default async function Page() { return ( <div> <UserProfile userId="1" /> <UserProfile userId="1" /> {/* Same request, only fetched once */} </div> ) }
2. Use Loading States
tsx// ✅ Show loading UI immediately export default function Page() { return ( <Suspense fallback={<LoadingSkeleton />}> <SlowComponent /> </Suspense> ) }
3. Keep Client Components Small
tsx// ✅ Small, focused client component 'use client' export function LikeButton() { const [liked, setLiked] = useState(false) return <button onClick={() => setLiked(!liked)}>Like</button> } // Server component for the rest export default async function Post({ id }: { id: string }) { const post = await getPost(id) return ( <article> <h1>{post.title}</h1> <p>{post.content}</p> <LikeButton /> </article> ) }