Next.js App Router revolutionizes how we build multi-page applications with its file-based routing system, nested layouts, and powerful navigation patterns. This lesson explores the core concepts that make Next.js routing intuitive yet powerful.
Next.js App Router uses a file-based routing system where the file structure in the app directory directly maps to URL paths. This convention-based approach eliminates the need for manual route configuration.
app/
├── layout.tsx # Root layout
├── page.tsx # Home page (/)
├── about/
│ └── page.tsx # About page (/about)
├── blog/
│ ├── layout.tsx # Blog layout
│ ├── page.tsx # Blog index (/blog)
│ └── [slug]/
│ └── page.tsx # Blog post (/blog/[slug])
└── dashboard/
├── layout.tsx # Dashboard layout
├── page.tsx # Dashboard home (/dashboard)
└── settings/
└── page.tsx # Settings (/dashboard/settings)
Each route segment can contain special files that define behavior:
page.tsx: The unique UI for a route segmentlayout.tsx: Shared UI that wraps child segmentsloading.tsx: Loading UI shown while the page loadserror.tsx: Error UI for handling route errorsroute.ts: API endpoints for the route segment// app/page.tsx - Home page
export default function HomePage() {
return (
<div>
<h1>Welcome to Our App</h1>
<p>This is the home page</p>
</div>
)
}
// app/about/page.tsx - About page
export default function AboutPage() {
return (
<div>
<h1>About Us</h1>
<p>Learn more about our company</p>
</div>
)
}
The root layout (app/layout.tsx) is the top-most layout that wraps all pages. It's where you define the HTML structure, include global styles, and add providers.
// app/layout.tsx
import './globals.css'
import { Inter } from 'next/font/google'
import { Providers } from './providers'
const inter = Inter({ subsets: ['latin'] })
export const metadata = {
title: 'My Next.js App',
description: 'Built with Next.js 13+',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>
<Providers>
{/* Global navigation that appears on all pages */}
<nav className="global-nav">
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/blog">Blog</a>
</nav>
{/* Page content */}
<main>{children}</main>
{/* Global footer */}
<footer className="global-footer">
<p>© 2024 My App</p>
</footer>
</Providers>
</body>
</html>
)
}
Layouts can be nested to create section-specific UI. Each layout wraps the routes in its segment and below.
// app/dashboard/layout.tsx - Dashboard-specific layout
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="dashboard-layout">
{/* Dashboard sidebar */}
<aside className="dashboard-sidebar">
<nav>
<a href="/dashboard">Overview</a>
<a href="/dashboard/settings">Settings</a>
<a href="/dashboard/analytics">Analytics</a>
</nav>
</aside>
{/* Dashboard content area */}
<div className="dashboard-content">
<header className="dashboard-header">
<h1>Dashboard</h1>
</header>
{children}
</div>
</div>
)
}
// app/dashboard/page.tsx - Dashboard home page
export default function DashboardPage() {
return (
<div>
<h2>Welcome to Dashboard</h2>
<p>Here's your overview</p>
</div>
)
}
// app/dashboard/settings/page.tsx - Settings page
export default function SettingsPage() {
return (
<div>
<h2>Settings</h2>
<p>Manage your preferences</p>
</div>
)
}
Layouts compose naturally, creating a hierarchy of shared UI:
// The final rendered HTML for /dashboard/settings:
<html>
<body>
{/* From app/layout.tsx */}
<nav className="global-nav">...</nav>
<main>
{/* From app/dashboard/layout.tsx */}
<div className="dashboard-layout">
<aside className="dashboard-sidebar">...</aside>
<div className="dashboard-content">
<header className="dashboard-header">...</header>
{/* From app/dashboard/settings/page.tsx */}
<div>
<h2>Settings</h2>
<p>Manage your preferences</p>
</div>
</div>
</div>
</main>
<footer className="global-footer">...</footer>
</body>
</html>
The Link component is the primary way to navigate between routes. It provides client-side navigation with prefetching for better performance.
import Link from 'next/link'
export default function Navigation() {
return (
<nav>
{/* Basic navigation */}
<Link href="/">Home</Link>
<Link href="/about">About</Link>
{/* Dynamic navigation with active state */}
<Link
href="/dashboard"
className={({ isActive }) =>
isActive ? 'active' : ''
}
>
Dashboard
</Link>
{/* Navigation with query parameters */}
<Link href="/search?q=react&sort=popular">
Search React
</Link>
{/* Programmatic navigation */}
<Link
href={`/posts/${postId}`}
scroll={false} // Prevent scrolling to top
>
View Post
</Link>
</nav>
)
}
For navigation triggered by events or conditions, use the useRouter hook:
'use client'
import { useRouter } from 'next/navigation'
export default function LoginForm() {
const router = useRouter()
const handleLogin = async (formData: FormData) => {
const result = await authenticate(formData)
if (result.success) {
// Navigate to dashboard after successful login
router.push('/dashboard')
router.refresh() // Refresh server components
} else {
// Show error message
setError(result.error)
}
}
const handleLogout = () => {
logout()
// Replace current route to prevent back navigation
router.replace('/login')
}
return (
<form action={handleLogin}>
{/* Form fields */}
<button type="submit">Login</button>
</form>
)
}
Pass state between routes using URL parameters or router state:
'use client'
import { useRouter, usePathname } from 'next/navigation'
export default function ProductFilters() {
const router = useRouter()
const pathname = usePathname()
const applyFilters = (filters: ProductFilters) => {
// Build query string
const params = new URLSearchParams()
Object.entries(filters).forEach(([key, value]) => {
if (value) params.append(key, value.toString())
})
// Navigate with new filters
router.push(`${pathname}?${params.toString()}`)
}
const clearFilters = () => {
// Navigate without query parameters
router.push(pathname)
}
return (
<div>
<select onChange={(e) => applyFilters({ category: e.target.value })}>
<option value="">All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
<button onClick={clearFilters}>Clear Filters</button>
</div>
)
}
Create dynamic routes using square brackets [param] in the filename:
// app/blog/[slug]/page.tsx - Dynamic blog post page
export default function BlogPost({ params }: { params: { slug: string } }) {
return (
<article>
<h1>Blog Post: {params.slug}</h1>
<p>This is the content for {params.slug}</p>
</article>
)
}
// app/products/[id]/page.tsx - Dynamic product page
export default function ProductPage({ params }: { params: { id: string } }) {
// Fetch product data using the ID
const product = getProduct(params.id)
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>Price: ${product.price}</p>
</div>
)
}
For static generation, use generateStaticParams to pre-build dynamic routes:
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
// Fetch all blog posts
const posts = await getBlogPosts()
// Return array of params for each post
return posts.map((post) => ({
slug: post.slug,
}))
}
export default function BlogPost({ params }: { params: { slug: string } }) {
const post = getBlogPost(params.slug)
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
)
}
Handle multiple dynamic segments with catch-all routes:
// app/docs/[...slug]/page.tsx - Catch-all for documentation
export default function DocsPage({ params }: { params: { slug: string[] } }) {
// slug is an array of path segments
const path = params.slug.join('/')
return (
<div>
<h1>Documentation: {path}</h1>
<p>Viewing docs for: {params.slug.join(' > ')}</p>
</div>
)
}
// Examples:
// /docs/getting-started -> params.slug = ['getting-started']
// /docs/api/users/create -> params.slug = ['api', 'users', 'create']
Make catch-all segments optional with double brackets:
// app/shop/[[...slug]]/page.tsx - Optional catch-all
export default function ShopPage({ params }: { params: { slug?: string[] } }) {
if (!params.slug) {
// /shop - Shop homepage
return <h1>Shop Homepage</h1>
}
// /shop/clothing, /shop/clothing/shirts, etc.
const category = params.slug[0]
const subcategory = params.slug[1]
return (
<div>
<h1>Shop: {category}</h1>
{subcategory && <p>Subcategory: {subcategory}</p>}
</div>
)
}
Organize routes without affecting URL structure using parentheses:
app/
├── (marketing)/
│ ├── layout.tsx # Marketing layout
│ ├── page.tsx # Home page (/)
│ ├── about/
│ │ └── page.tsx # About page (/about)
│ └── contact/
│ └── page.tsx # Contact page (/contact)
├── (dashboard)/
│ ├── layout.tsx # Dashboard layout
│ ├── page.tsx # Dashboard (/dashboard)
│ └── settings/
│ └── page.tsx # Settings (/dashboard/settings)
└── api/
└── users/
└── route.ts # API endpoint (/api/users)
// app/(marketing)/layout.tsx - Marketing section layout
export default function MarketingLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="marketing-layout">
<header>
<nav>
<Link href="/">Home</Link>
<Link href="/about">About</Link>
<Link href="/contact">Contact</Link>
</nav>
</header>
<main>{children}</main>
<footer>Marketing Footer</footer>
</div>
)
}
// app/(dashboard)/layout.tsx - Dashboard section layout
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="dashboard-layout">
<aside>
<nav>
<Link href="/dashboard">Overview</Link>
<Link href="/dashboard/settings">Settings</Link>
</nav>
</aside>
<main>{children}</main>
</div>
)
}
Create multiple independent pages within the same layout using slots:
// app/@analytics/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
team,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<div className="dashboard">
<div className="main-content">
{children}
</div>
<div className="sidebar">
{analytics}
</div>
<div className="team-panel">
{team}
</div>
</div>
)
}
// app/dashboard/page.tsx - Main content
export default function DashboardPage() {
return <h1>Dashboard Overview</h1>
}
// app/@analytics/dashboard/page.tsx - Analytics slot
export default function AnalyticsPage() {
return <div>Analytics Widget</div>
}
// app/@team/dashboard/page.tsx - Team slot
export default function TeamPage() {
return <div>Team Widget</div>
}
Create loading states with loading.tsx files:
// app/dashboard/loading.tsx - Loading state for dashboard routes
export default function DashboardLoading() {
return (
<div className="loading">
<div className="skeleton-header">
<div className="skeleton-text"></div>
</div>
<div className="skeleton-content">
<div className="skeleton-card"></div>
<div className="skeleton-card"></div>
</div>
</div>
)
}
// app/blog/[slug]/loading.tsx - Loading for blog posts
export default function BlogPostLoading() {
return (
<article className="loading-post">
<div className="skeleton-title"></div>
<div className="skeleton-meta"></div>
<div className="skeleton-content">
<div className="skeleton-paragraph"></div>
<div className="skeleton-paragraph"></div>
<div className="skeleton-paragraph"></div>
</div>
</article>
)
}
Handle route-specific errors with error.tsx:
// app/dashboard/error.tsx - Error boundary for dashboard
'use client'
import { useEffect } from 'react'
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// Log the error to an error reporting service
console.error('Dashboard error:', error)
}, [error])
return (
<div className="error-boundary">
<h2>Something went wrong in the Dashboard!</h2>
<p>We encountered an error while loading your dashboard.</p>
<details>
<summary>Error Details</summary>
<p>{error.message}</p>
{error.digest && <p>Error ID: {error.digest}</p>}
</details>
<button onClick={reset}>Try again</button>
</div>
)
}
// app/blog/[slug]/error.tsx - Error for blog posts
'use client'
export default function BlogPostError({
error,
reset,
}: {
error: Error
reset: () => void
}) {
return (
<article className="error-post">
<h1>Unable to Load Blog Post</h1>
<p>Sorry, we couldn't load this blog post.</p>
<button onClick={() => reset()}>Try Again</button>
<Link href="/blog">Back to Blog</Link>
</article>
)
}
Create navigation components that highlight the current page:
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
interface NavItem {
href: string
label: string
icon?: React.ReactNode
}
export default function Navigation({ items }: { items: NavItem[] }) {
const pathname = usePathname()
return (
<nav className="main-navigation">
{items.map((item) => {
const isActive = pathname === item.href ||
(item.href !== '/' && pathname.startsWith(item.href))
return (
<Link
key={item.href}
href={item.href}
className={`nav-item ${isActive ? 'active' : ''}`}
aria-current={isActive ? 'page' : undefined}
>
{item.icon && <span className="nav-icon">{item.icon}</span>}
<span className="nav-label">{item.label}</span>
</Link>
)
})}
</nav>
)
}
// Usage
const navigationItems = [
{ href: '/', label: 'Home' },
{ href: '/dashboard', label: 'Dashboard' },
{ href: '/blog', label: 'Blog' },
{ href: '/settings', label: 'Settings' },
]
<Navigation items={navigationItems} />
Implement breadcrumbs that reflect the current route hierarchy:
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
export default function Breadcrumbs() {
const pathname = usePathname()
// Generate breadcrumb items from pathname
const pathSegments = pathname
.split('/')
.filter(segment => segment)
.map((segment, index, array) => {
const href = '/' + array.slice(0, index + 1).join('/')
const label = segment.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
return { href, label }
})
return (
<nav aria-label="Breadcrumb">
<ol className="breadcrumb">
<li>
<Link href="/">Home</Link>
</li>
{pathSegments.map((segment, index) => (
<li key={segment.href}>
{index < pathSegments.length - 1 ? (
<Link href={segment.href}>{segment.label}</Link>
) : (
<span>{segment.label}</span>
)}
</li>
))}
</ol>
</nav>
)
}
Implement route protection with middleware and layout checks:
// middleware.ts - Route protection middleware
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Protected routes
const protectedRoutes = ['/dashboard', '/settings', '/profile']
const isProtectedRoute = protectedRoutes.some(route =>
pathname.startsWith(route)
)
// Public routes
const publicRoutes = ['/login', '/signup', '/']
const isPublicRoute = publicRoutes.some(route =>
pathname.startsWith(route)
)
const token = request.cookies.get('auth-token')?.value
if (isProtectedRoute && !token) {
// Redirect to login if accessing protected route without auth
return NextResponse.redirect(new URL('/login', request.url))
}
if (isPublicRoute && token && pathname === '/login') {
// Redirect to dashboard if already logged in
return NextResponse.redirect(new URL('/dashboard', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}
// app/(dashboard)/layout.tsx - Dashboard layout with auth check
import { redirect } from 'next/navigation'
import { getServerSession } from 'next-auth'
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await getServerSession()
if (!session) {
redirect('/login')
}
return (
<div className="dashboard-layout">
<aside>
<DashboardNav user={session.user} />
</aside>
<main>{children}</main>
</div>
)
}
Let's put it all together with a comprehensive e-commerce site:
app/
├── layout.tsx # Root layout with header/footer
├── page.tsx # Homepage
├── (shop)/
│ ├── layout.tsx # Shop layout with sidebar
│ ├── page.tsx # Shop homepage
│ ├── products/
│ │ ├── page.tsx # Product listing
│ │ └── [id]/
│ │ └── page.tsx # Product details
│ └── categories/
│ └── [category]/
│ └── page.tsx # Category page
├── (account)/
│ ├── layout.tsx # Account layout
│ ├── page.tsx # Account dashboard
│ ├── orders/
│ │ └── page.tsx # Order history
│ └── profile/
│ └── page.tsx # Profile settings
└── (admin)/
├── layout.tsx # Admin layout
├── page.tsx # Admin dashboard
└── products/
├── page.tsx # Product management
└── [id]/
└── page.tsx # Edit product
// app/layout.tsx - Root layout
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html>
<body>
<Header />
<main>{children}</main>
<Footer />
</body>
</html>
)
}
// app/(shop)/layout.tsx - Shop section layout
export default function ShopLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="shop-layout">
<aside className="shop-sidebar">
<CategoryFilter />
<PriceFilter />
<BrandFilter />
</aside>
<div className="shop-content">
{children}
</div>
</div>
)
}
// app/(shop)/products/[id]/page.tsx - Product details
export default async function ProductPage({
params,
searchParams,
}: {
params: { id: string }
searchParams: { variant?: string }
}) {
const product = await getProduct(params.id)
const variant = searchParams.variant
? product.variants.find(v => v.id === searchParams.variant)
: product.variants[0]
return (
<div className="product-page">
<ProductGallery images={variant.images} />
<ProductInfo
product={product}
variant={variant}
onVariantChange={(variantId) => {
// Navigate to same product with different variant
window.location.search = `?variant=${variantId}`
}}
/>
<ProductTabs productId={product.id} />
</div>
)
}
// app/(account)/layout.tsx - Account section layout
export default async function AccountLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await getServerSession()
if (!session) {
redirect('/login')
}
return (
<div className="account-layout">
<AccountSidebar user={session.user} />
<div className="account-content">
{children}
</div>
</div>
)
}
[param] syntax for flexible URL patterns(group) organize routes without affecting URLsLink and useRouter handle client-side routinggenerateStaticParams for content-heavy sitesBy mastering these routing patterns, you'll build sophisticated, maintainable Next.js applications that provide excellent user experiences and developer productivity.