Next.js App Router Guide: From Basics to Advanced Patterns
Master the Next.js App Router: routing, layouts, server components, data fetching, and advanced patterns for modern web apps.
Photo by Davide Baraldi on Unsplash
The Next.js App Router represents a fundamental shift in how we build React applications. This comprehensive guide will take you from the basics to advanced patterns, helping you master modern Next.js development.
Table of Contents#
- Understanding the App Router
- File-Based Routing
- Layouts and Templates
- Server and Client Components
- Data Fetching Strategies
- Route Handlers (API Routes)
- Advanced Patterns
Understanding the App Router#
The App Router is built on React Server Components and introduces new conventions for building applications with improved performance and developer experience.
Key Benefits#
- Server-First Architecture: Components render on the server by default
- Streaming and Suspense: Progressive rendering for better UX
- Nested Layouts: Share UI across routes efficiently
- Colocation: Keep related files together
- Improved Data Fetching: Fetch data where you need it
File-Based Routing#
Basic Route Structure#
app/
├── page.js # / route
├── about/
│ └── page.js # /about route
├── blog/
│ ├── page.js # /blog route
│ └── [slug]/
│ └── page.js # /blog/[slug] dynamic route
└── dashboard/
├── layout.js # Dashboard layout
├── page.js # /dashboard route
└── settings/
└── page.js # /dashboard/settings routeDynamic Routes#
// app/blog/[slug]/page.js
export default function BlogPost({ params }) {
return <h1>Post: {params.slug}</h1>
}
// Generate static params at build time
export async function generateStaticParams() {
const posts = await getPosts()
return posts.map((post) => ({
slug: post.slug,
}))
}Catch-All Routes#
// app/docs/[...slug]/page.js
export default function Docs({ params }) {
// params.slug will be an array: ['getting-started', 'installation']
return <div>Docs: {params.slug.join('/')}</div>
}Layouts and Templates#
Root Layout (Required)#
// app/layout.js
export const metadata = {
title: 'My App',
description: 'Welcome to my app',
}
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<header>
<nav>{/* Navigation */}</nav>
</header>
<main>{children}</main>
<footer>{/* Footer */}</footer>
</body>
</html>
)
}Nested Layouts#
// app/dashboard/layout.js
export default function DashboardLayout({ children }) {
return (
<div className="dashboard">
<aside>
<DashboardNav />
</aside>
<section>{children}</section>
</div>
)
}Templates (Re-render on Navigation)#
// app/template.js
export default function Template({ children }) {
return <div className="animate-fade-in">{children}</div>
}Server and Client Components#
Server Components (Default)#
// app/posts/page.js - Server Component
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
cache: 'no-store', // Dynamic data
})
return res.json()
}
export default async function PostsPage() {
const posts = await getPosts()
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
)
}Client Components#
'use client'
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
)
}Composition Pattern#
// app/page.js - Server Component
import ClientComponent from './ClientComponent'
async function getData() {
const data = await fetch('...')
return data.json()
}
export default async function Page() {
const data = await getData()
return (
<div>
<h1>Server-rendered content</h1>
<ClientComponent initialData={data} />
</div>
)
}Data Fetching Strategies#
Static Data (Default)#
// Cached by default
async function getStaticData() {
const res = await fetch('https://api.example.com/data')
return res.json()
}Dynamic Data#
// Opt out of caching
async function getDynamicData() {
const res = await fetch('https://api.example.com/data', {
cache: 'no-store'
})
return res.json()
}Revalidated Data#
// Revalidate every 60 seconds
async function getRevalidatedData() {
const res = await fetch('https://api.example.com/data', {
next: { revalidate: 60 }
})
return res.json()
}Parallel Data Fetching#
export default async function Page() {
// Fetch in parallel
const [posts, users] = await Promise.all([
getPosts(),
getUsers()
])
return (
<div>
<Posts data={posts} />
<Users data={users} />
</div>
)
}Sequential Data Fetching#
export default async function Page() {
const user = await getUser()
const posts = await getUserPosts(user.id) // Waits for user
return <UserPosts user={user} posts={posts} />
}Route Handlers#
Basic API Route#
// app/api/posts/route.js
export async function GET(request) {
const posts = await getPosts()
return Response.json(posts)
}
export async function POST(request) {
const body = await request.json()
const post = await createPost(body)
return Response.json(post, { status: 201 })
}Dynamic Route Handlers#
// app/api/posts/[id]/route.js
export async function GET(request, { params }) {
const post = await getPost(params.id)
if (!post) {
return Response.json({ error: 'Not found' }, { status: 404 })
}
return Response.json(post)
}
export async function PATCH(request, { params }) {
const body = await request.json()
const post = await updatePost(params.id, body)
return Response.json(post)
}
export async function DELETE(request, { params }) {
await deletePost(params.id)
return new Response(null, { status: 204 })
}Middleware Integration#
// app/api/protected/route.js
import { auth } from '@/lib/auth'
export async function GET(request) {
const session = await auth(request)
if (!session) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const data = await getProtectedData(session.userId)
return Response.json(data)
}Advanced Patterns#
Loading States#
// app/dashboard/loading.js
export default function Loading() {
return <div className="spinner">Loading...</div>
}Error Handling#
// app/dashboard/error.js
'use client'
export default function Error({ error, reset }) {
return (
<div>
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>Try again</button>
</div>
)
}Not Found Pages#
// app/blog/[slug]/not-found.js
export default function NotFound() {
return (
<div>
<h2>Post Not Found</h2>
<p>Could not find the requested post.</p>
</div>
)
}
// Trigger not-found
import { notFound } from 'next/navigation'
export default async function Page({ params }) {
const post = await getPost(params.slug)
if (!post) {
notFound()
}
return <Post data={post} />
}Streaming with Suspense#
import { Suspense } from 'react'
async function SlowComponent() {
await new Promise(resolve => setTimeout(resolve, 3000))
return <div>Slow content loaded!</div>
}
export default function Page() {
return (
<div>
<h1>Fast content</h1>
<Suspense fallback={<div>Loading slow content...</div>}>
<SlowComponent />
</Suspense>
</div>
)
}Route Groups#
app/
├── (marketing)/
│ ├── layout.js # Marketing layout
│ ├── page.js # / route
│ └── about/
│ └── page.js # /about route
└── (shop)/
├── layout.js # Shop layout
├── products/
│ └── page.js # /products route
└── cart/
└── page.js # /cart routeParallel Routes#
app/
└── dashboard/
├── @analytics/
│ └── page.js
├── @team/
│ └── page.js
└── layout.js// app/dashboard/layout.js
export default function Layout({ children, analytics, team }) {
return (
<div>
{children}
<div className="grid grid-cols-2">
{analytics}
{team}
</div>
</div>
)
}Intercepting Routes#
app/
└── photos/
├── [id]/
│ └── page.js # /photos/123
└── (..)photos/
└── [id]/
└── page.js # Intercepts /photos/123 when navigating from same levelBest Practices#
1. Server Components by Default#
Use server components unless you need interactivity, browser APIs, or state.
2. Fetch Data Where You Need It#
Don't prop drill - fetch data in the component that needs it.
3. Use Streaming for Better UX#
Wrap slow components in Suspense to stream content progressively.
4. Optimize Images#
import Image from 'next/image'
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority
/>5. Implement Proper Error Boundaries#
Create error.js files at appropriate levels in your route hierarchy.
6. Use Metadata API#
export const metadata = {
title: 'My Page',
description: 'Page description',
openGraph: {
title: 'My Page',
description: 'Page description',
images: ['/og-image.jpg'],
},
}Performance Optimization#
Code Splitting#
import dynamic from 'next/dynamic'
const DynamicComponent = dynamic(() => import('./HeavyComponent'), {
loading: () => <p>Loading...</p>,
ssr: false, // Disable SSR if needed
})Font Optimization#
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] })
export default function RootLayout({ children }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
)
}Route Prefetching#
Next.js automatically prefetches routes in the viewport using <Link>.
Conclusion#
The Next.js App Router provides a powerful foundation for building modern web applications. By understanding these patterns and best practices, you can create fast, scalable, and maintainable applications.
Key Takeaways#
- Server Components are the default and provide better performance
- Use Client Components only when needed for interactivity
- Leverage streaming and Suspense for better UX
- Fetch data where you need it, not at the top level
- Use proper error handling and loading states
- Optimize images, fonts, and code splitting
Next Steps#
- Explore the Next.js documentation
- Build a project using the App Router
- Experiment with advanced patterns like parallel routes
- Learn about deployment optimization
Related Guides:
- Next.js Performance Optimization
- Building SaaS with Next.js and Supabase
- Deploying Next.js to Production
- Missing Suspense boundary with useSearchParams: 3 fixes — the build error that only surfaces in production when a static page uses
useSearchParamswithout aSuspensewrapper. Covers the Suspense fix, thesearchParamsprop alternative, and when to useconnection(). - Next.js proxy.js matcher: static assets and auth gaps — if you upgraded to Next.js 16 and your CSS or images stopped loading after adding auth, this is the matcher pattern fix.
Related#
Frequently Asked Questions
One email a month — no fluff
RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.
Related Guides
Next.js Performance Optimization for Indie Developers
Ship a fast Next.js app: Core Web Vitals, image and font optimization, bundle trimming, caching, and the RUM checks that catch regressions.
Caching Strategies for Next.js + Supabase Applications
Caching patterns for Next.js + Supabase at scale: Redis integration, ISR optimization, SWR patterns, and cache invalidation.
Next.js 15 Partial Prerendering: Guide
Next.js 15 Partial Prerendering: the static shell / dynamic holes model, Suspense boundaries, streaming, caching, and migration paths.