Next.js App Router: Complete Routing Guide with Examples
Routing is the Backbone of Every Application
Every click, every URL change, every page transition in your Next.js app is governed by the App Router. Unlike traditional React where you manually configure routes with a library like React Router, Next.js uses a file-system based router. The URL structure mirrors your folder structure.
This module covers everything from basic static pages to advanced patterns like parallel routes and intercepting routes. By the end, you'll be able to build any navigation pattern — from simple blogs to complex dashboards with modal overlays.
Static Routes
The simplest form of routing. A folder with a page.tsx file creates a route.
app/
├── page.tsx ← /
├── about/
│ └── page.tsx ← /about
├── contact/
│ └── page.tsx ← /contact
└── pricing/
└── page.tsx ← /pricing
Example:
1// app/about/page.tsx 2export const metadata = { 3 title: "About Us", 4}; 5 6export default function AboutPage() { 7 return ( 8 <main className="max-w-4xl mx-auto px-4 py-12"> 9 <h1 className="text-4xl font-bold mb-6">About Tech3Space</h1> 10 <p className="text-lg text-gray-600"> 11 We are on a mission to make tech education accessible to everyone. 12 </p> 13 </main> 14 ); 15}
Visit localhost:3000/about — this component renders. No configuration needed.
Dynamic Routes
When a route segment is variable — like a product ID, blog slug, or username — use dynamic segments with square brackets.
app/
├── blog/
│ └── [slug]/ ← /blog/hello-world, /blog/nextjs-guide
│ └── page.tsx
└── courses/
└── [id]/ ← /courses/101, /courses/205
└── page.tsx
Single Dynamic Segment
1// app/blog/[slug]/page.tsx 2interface Props { 3 params: Promise<{ slug: string }>; 4} 5 6export default async function BlogPost({ params }: Props) { 7 const { slug } = await params; 8 9 return ( 10 <article> 11 <h1>Post: {slug}</h1> 12 <p>Reading blog post with slug: {slug}</p> 13 </article> 14 ); 15}
URL: /blog/getting-started-with-nextjs
Output: Post: getting-started-with-nextjs
Multiple Dynamic Segments
1// app/courses/[courseId]/lessons/[lessonId]/page.tsx 2interface Props { 3 params: Promise<{ courseId: string; lessonId: string }>; 4} 5 6export default async function LessonPage({ params }: Props) { 7 const { courseId, lessonId } = await params; 8 9 return ( 10 <div> 11 <h1>Lesson {lessonId}</h1> 12 <p>Part of Course {courseId}</p> 13 </div> 14 ); 15}
URL: /courses/101/lessons/3
Access: courseId = "101", lessonId = "3"
Nested Routes
Routes can be nested infinitely. Each folder is a URL segment, and each can have its own layout.tsx, loading.tsx, and error.tsx.
app/
├── courses/ ← /courses
│ ├── layout.tsx ← Courses layout
│ ├── page.tsx ← Course listing
│ └── [courseId]/ ← /courses/101
│ ├── layout.tsx ← Course detail layout
│ ├── page.tsx ← Course overview
│ └── lessons/ ← /courses/101/lessons
│ └── [lessonId]/ ← /courses/101/lessons/3
│ └── page.tsx ← Individual lesson
Key insight: Each level can add persistent UI via layout.tsx while only the page.tsx content changes.
Catch-All Routes
When you need a route to match any number of segments after a certain point, use the catch-all syntax [...slug].
app/
└── docs/
└── [...slug]/ ← Matches /docs, /docs/getting-started, /docs/getting-started/installation
└── page.tsx
Documentation Site Example
1// app/docs/[...slug]/page.tsx 2interface Props { 3 params: Promise<{ slug: string[] }>; 4} 5 6export default async function DocsPage({ params }: Props) { 7 const { slug } = await params; // slug is an array! 8 9 // slug = ["getting-started"] for /docs/getting-started 10 // slug = ["getting-started", "install"] for /docs/getting-started/install 11 12 const path = slug.join("/"); 13 14 return ( 15 <div className="flex gap-8"> 16 <aside className="w-64"> 17 <DocsSidebar currentPath={path} /> 18 </aside> 19 <article className="flex-1"> 20 <h1 className="text-3xl font-bold mb-4"> 21 {slug[slug.length - 1].replace(/-/g, " ")} 22 </h1> 23 <DocsContent path={path} /> 24 </article> 25 </div> 26 ); 27}
Matched URLs:
| URL | slug array |
|---|---|
/docs | [] (empty — see optional catch-all below) |
/docs/quickstart | ["quickstart"] |
/docs/quickstart/setup | ["quickstart", "setup"] |
/docs/quickstart/setup/config | ["quickstart", "setup", "config"] |
Optional Catch-All Routes
A regular catch-all [...slug] requires at least one segment after the parent. If you want the parent route itself to match too, use double brackets [[...slug]].
app/
└── docs/
└── [[...slug]]/ ← Matches /docs AND /docs/anything/else
└── page.tsx
Comparison:
| Route Type | Matches /docs | Matches /docs/a | Matches /docs/a/b |
|---|---|---|---|
docs/page.tsx | ✅ | ❌ | ❌ |
docs/[slug]/page.tsx | ❌ | ✅ | ❌ |
docs/[...slug]/page.tsx | ❌ | ✅ | ✅ |
docs/[[...slug]]/page.tsx | ✅ | ✅ | ✅ |
Use case: A documentation site where /docs shows the introduction and deeper paths show specific pages.
1// app/docs/[[...slug]]/page.tsx 2interface Props { 3 params: Promise<{ slug?: string[] }>; 4} 5 6export default async function DocsPage({ params }: Props) { 7 const { slug } = await params; 8 9 if (!slug || slug.length === 0) { 10 return <DocsIntroduction />; // /docs 11 } 12 13 return <DocsContent path={slug.join("/")} />; // /docs/anything 14}
Route Groups
Route groups let you organize routes without affecting the URL. Folders wrapped in parentheses (group) are invisible in the browser address bar.
app/
├── (marketing)/ ← No URL prefix
│ ├── page.tsx ← /
│ ├── about/
│ │ └── page.tsx ← /about
│ └── pricing/
│ └── page.tsx ← /pricing
│
├── (dashboard)/ ← No URL prefix
│ ├── dashboard/
│ │ └── page.tsx ← /dashboard
│ └── settings/
│ └── page.tsx ← /settings
│
└── (auth)/ ← No URL prefix
├── login/
│ └── page.tsx ← /login
└── register/
└── page.tsx ← /register
Why Use Route Groups?
1. Different layouts for different sections:
1// app/(marketing)/layout.tsx 2export default function MarketingLayout({ children }: { children: React.ReactNode }) { 3 return ( 4 <> 5 <MarketingNav /> 6 {children} 7 <Footer /> 8 </> 9 ); 10} 11 12// app/(dashboard)/layout.tsx 13import { requireAuth } from "@/lib/auth"; 14 15export default async function DashboardLayout({ children }: { children: React.ReactNode }) { 16 await requireAuth(); // Protect all dashboard routes 17 18 return ( 19 <div className="flex"> 20 <Sidebar /> 21 <main className="flex-1">{children}</main> 22 </div> 23 ); 24}
2. Logical organization without URL pollution:
app/
├── (shop)/ ← E-commerce section
│ ├── products/
│ │ └── page.tsx ← /products
│ └── cart/
│ └── page.tsx ← /cart
│
└── (blog)/ ← Blog section
├── posts/
│ └── page.tsx ← /posts
└── authors/
└── page.tsx ← /authors
Parallel Routes
Parallel routes allow you to render multiple pages in the same layout simultaneously. They are defined using named slots with the @folder convention.
app/
├── layout.tsx
├── page.tsx
└── @team/ ← Parallel slot: @team
│ └── page.tsx
├── @analytics/ ← Parallel slot: @analytics
│ └── page.tsx
└── settings/
├── page.tsx
├── @team/
│ └── page.tsx
└── @analytics/
└── page.tsx
Dashboard Example
1// app/layout.tsx 2export default function RootLayout({ 3 children, 4 team, 5 analytics, 6}: { 7 children: React.ReactNode; 8 team: React.ReactNode; 9 analytics: React.ReactNode; 10}) { 11 return ( 12 <html> 13 <body> 14 {children} 15 <div className="grid grid-cols-2 gap-4 mt-8"> 16 <section className="border rounded-lg p-4"> 17 <h2 className="font-semibold mb-2">Team</h2> 18 {team} 19 </section> 20 <section className="border rounded-lg p-4"> 21 <h2 className="font-semibold mb-2">Analytics</h2> 22 {analytics} 23 </section> 24 </div> 25 </body> 26 </html> 27 ); 28}
1// app/@team/page.tsx 2export default function TeamPage() { 3 return ( 4 <ul> 5 <li>Alice — Frontend</li> 6 <li>Bob — Backend</li> 7 <li>Charlie — Design</li> 8 </ul> 9 ); 10}
1// app/@analytics/page.tsx 2export default function AnalyticsPage() { 3 return ( 4 <div> 5 <p>Total Users: 12,450</p> 6 <p>Active Now: 342</p> 7 </div> 8 ); 9}
Result: The home page shows the main content, the team list, and analytics side-by-side — all loaded in parallel.
Conditional Parallel Routes
You can conditionally render slots based on the route:
1// app/layout.tsx 2export default function RootLayout({ 3 children, 4 modal, 5}: { 6 children: React.ReactNode; 7 modal: React.ReactNode; 8}) { 9 return ( 10 <html> 11 <body> 12 {children} 13 {modal} {/* Only renders when @modal slot matches */} 14 </body> 15 </html> 16 ); 17}
Intercepting Routes
Intercepting routes let you show a route's content within the current layout instead of navigating to a new page. Perfect for modals, side panels, and quick previews.
Use the (.), (..), or (...) prefixes:
| Syntax | Intercepts |
|---|---|
(.)folder | Same level |
(..)folder | One level up |
(..)(..)folder | Two levels up |
(...)folder | Root level |
Modal Overlay Example
Imagine browsing a photo gallery. Clicking a photo should open it in a modal overlay while keeping the gallery visible underneath.
app/
├── layout.tsx
├── page.tsx ← Gallery grid
└── photos/
├── page.tsx ← Full photo page (direct visit)
└── [id]/
└── page.tsx ← Full photo page (direct visit)
└── @modal/ ← Parallel slot for modal
└── (.)photos/
└── [id]/
└── page.tsx ← Modal version (intercepted)
1// app/layout.tsx 2export default function RootLayout({ 3 children, 4 modal, 5}: { 6 children: React.ReactNode; 7 modal: React.ReactNode; 8}) { 9 return ( 10 <html> 11 <body> 12 {children} 13 {modal} 14 </body> 15 </html> 16 ); 17}
1// app/@modal/(.)photos/[id]/page.tsx — Intercepted modal 2"use client"; 3 4import { useRouter } from "next/navigation"; 5 6export default function PhotoModal({ params }: { params: Promise<{ id: string }> }) { 7 const router = useRouter(); 8 9 return ( 10 <div 11 className="fixed inset-0 bg-black/80 flex items-center justify-center z-50" 12 onClick={() => router.back()} // Close modal on backdrop click 13 > 14 <div className="bg-white p-4 rounded-lg max-w-2xl"> 15 <img 16 src={`/photos/${params.id}.jpg`} 17 alt="Photo" 18 className="rounded" 19 /> 20 <button 21 onClick={() => router.back()} 22 className="mt-4 px-4 py-2 bg-gray-200 rounded" 23 > 24 Close 25 </button> 26 </div> 27 </div> 28 ); 29}
Behavior:
- Direct visit to
/photos/123→ Shows full photo page - Clicking from gallery → Opens modal overlay, URL changes to
/photos/123 - Refreshing at
/photos/123→ Shows full photo page (modal doesn't exist)
Navigation
The Link Component
The Link component is the primary way to navigate between routes. It prefetches pages in the viewport and handles client-side navigation.
1import Link from "next/link"; 2 3export default function Navbar() { 4 return ( 5 <nav className="flex gap-6"> 6 <Link href="/" className="hover:text-blue-600">Home</Link> 7 <Link href="/courses" className="hover:text-blue-600">Courses</Link> 8 <Link href="/about" className="hover:text-blue-600">About</Link> 9 10 {/* Dynamic route */} 11 <Link href="/courses/nextjs" className="hover:text-blue-600"> 12 Next.js Course 13 </Link> 14 15 {/* With query parameters */} 16 <Link href="/courses?category=web" className="hover:text-blue-600"> 17 Web Courses 18 </Link> 19 20 {/* Replace current history entry instead of pushing */} 21 <Link href="/dashboard" replace> 22 Dashboard 23 </Link> 24 25 {/* Scroll to specific element */} 26 <Link href="/about#team">Our Team</Link> 27 28 {/* External link */} 29 <Link href="https://github.com" target="_blank" rel="noopener noreferrer"> 30 GitHub 31 </Link> 32 </nav> 33 ); 34}
Link vs <a> tag:
| Feature | <Link> | <a> |
|---|---|---|
| Client-side navigation | ✅ | ❌ (full page reload) |
| Prefetching | ✅ | ❌ |
| Scroll restoration | ✅ | ❌ |
| Use for | Internal navigation | External links only |
Programmatic Navigation with redirect
Use redirect in Server Components or Server Actions to navigate programmatically.
1// app/dashboard/page.tsx 2import { redirect } from "next/navigation"; 3import { getSession } from "@/lib/auth"; 4 5export default async function DashboardPage() { 6 const session = await getSession(); 7 8 if (!session) { 9 redirect("/login"); // Server-side redirect 10 } 11 12 return <div>Welcome, {session.user.name}</div>; 13}
1// actions/auth.ts 2"use server"; 3 4import { redirect } from "next/navigation"; 5 6export async function login(formData: FormData) { 7 const email = formData.get("email"); 8 const password = formData.get("password"); 9 10 const user = await authenticateUser(email, password); 11 12 if (!user) { 13 return { error: "Invalid credentials" }; 14 } 15 16 await createSession(user); 17 redirect("/dashboard"); // Redirect after successful login 18}
The useRouter Hook (Client Components)
For programmatic navigation in Client Components:
1"use client"; 2 3import { useRouter } from "next/navigation"; 4 5export default function Pagination({ currentPage }: { currentPage: number }) { 6 const router = useRouter(); 7 8 return ( 9 <div className="flex gap-2"> 10 <button 11 onClick={() => router.push(`/blog?page=${currentPage - 1}`)} 12 disabled={currentPage <= 1} 13 > 14 Previous 15 </button> 16 <button 17 onClick={() => router.push(`/blog?page=${currentPage + 1}`)} 18 > 19 Next 20 </button> 21 22 {/* Refresh current route */} 23 <button onClick={() => router.refresh()}> 24 Refresh Data 25 </button> 26 27 {/* Go back */} 28 <button onClick={() => router.back()}> 29 Back 30 </button> 31 </div> 32 ); 33}
notFound() for 404 Handling
Trigger a 404 response programmatically from any Server Component:
1import { notFound } from "next/navigation"; 2 3export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) { 4 const { id } = await params; 5 const product = await getProduct(id); 6 7 if (!product) { 8 notFound(); // Renders the nearest not-found.tsx 9 } 10 11 return <div>{product.name}</div>; 12}
1// app/products/[id]/not-found.tsx 2import Link from "next/link"; 3 4export default function ProductNotFound() { 5 return ( 6 <div className="text-center py-20"> 7 <h1 className="text-4xl font-bold mb-4">Product Not Found</h1> 8 <p className="text-gray-600 mb-6"> 9 The product you're looking for doesn't exist. 10 </p> 11 <Link href="/products" className="text-blue-600 hover:underline"> 12 Browse All Products → 13 </Link> 14 </div> 15 ); 16}
Complete Routing Reference Table
| Route Type | File Path | URL Example | Params |
|---|---|---|---|
| Static | app/about/page.tsx | /about | None |
| Dynamic | app/blog/[slug]/page.tsx | /blog/hello | { slug: "hello" } |
| Dynamic (multi) | app/[a]/[b]/page.tsx | /x/y | { a: "x", b: "y" } |
| Catch-all | app/docs/[...slug]/page.tsx | /docs/a/b | { slug: ["a", "b"] } |
| Optional catch-all | app/docs/[[...slug]]/page.tsx | /docs or /docs/a | { slug: undefined } or { slug: ["a"] } |
| Route group | app/(shop)/products/page.tsx | /products | None |
| Parallel | app/@team/page.tsx |
Real-World Example: Tech3Space Course Platform
Let's build the complete routing for a learning platform:
app/
├── layout.tsx ← Root layout
├── page.tsx ← Landing page
├── (marketing)/
│ ├── layout.tsx ← Marketing layout (nav + footer)
│ ├── about/
│ │ └── page.tsx ← /about
│ └── pricing/
│ └── page.tsx ← /pricing
│
├── (learning)/
│ ├── layout.tsx ← Learning layout (minimal nav)
│ ├── courses/
│ │ ├── page.tsx ← /courses
│ │ └── [slug]/ ← /courses/python
│ │ ├── page.tsx ← Course overview
│ │ └── lessons/
│ │ └── [lessonId]/ ← /courses/python/functions
│ │ ├── page.tsx ← Lesson content
│ │ └── loading.tsx
│ └── dashboard/
│ └── page.tsx ← /dashboard
│
├── (auth)/
│ ├── login/
│ │ └── page.tsx ← /login
│ └── register/
│ └── page.tsx ← /register
│
└── docs/
└── [[...slug]]/ ← /docs, /docs/getting-started
└── page.tsx
Course Navigation Example
1// app/courses/page.tsx 2import Link from "next/link"; 3 4const courses = [ 5 { slug: "nextjs", title: "Next.js Mastery" }, 6 { slug: "python", title: "Python Fundamentals" }, 7 { slug: "react", title: "React Deep Dive" }, 8]; 9 10export default function CoursesPage() { 11 return ( 12 <div className="max-w-6xl mx-auto px-4 py-12"> 13 <h1 className="text-3xl font-bold mb-8">All Courses</h1> 14 <div className="grid grid-cols-1 md:grid-cols-3 gap-6"> 15 {courses.map((course) => ( 16 <Link 17 key={course.slug} 18 href={`/courses/${course.slug}`} 19 className="block p-6 border rounded-xl hover:shadow-lg transition" 20 > 21 <h2 className="text-xl font-semibold">{course.title}</h2> 22 <span className="text-blue-600 mt-2 inline-block"> 23 Start Learning → 24 </span> 25 </Link> 26 ))} 27 </div> 28 </div> 29 ); 30}
1// app/courses/[slug]/page.tsx 2import Link from "next/link"; 3import { notFound } from "next/navigation"; 4 5interface Props { 6 params: Promise<{ slug: string }>; 7} 8 9export default async function CoursePage({ params }: Props) { 10 const { slug } = await params; 11 const course = await getCourse(slug); 12 13 if (!course) { 14 notFound(); 15 } 16 17 return ( 18 <div> 19 <h1 className="text-3xl font-bold mb-4">{course.title}</h1> 20 <p className="text-gray-600 mb-8">{course.description}</p> 21 22 <h2 className="text-xl font-semibold mb-4">Lessons</h2> 23 <ul className="space-y-2"> 24 {course.lessons.map((lesson) => ( 25 <li key={lesson.id}> 26 <Link 27 href={`/courses/${slug}/lessons/${lesson.id}`} 28 className="text-blue-600 hover:underline" 29 > 30 {lesson.title} 31 </Link> 32 </li> 33 ))} 34 </ul> 35 </div> 36 ); 37}
1// app/courses/[slug]/lessons/[lessonId]/page.tsx 2import { notFound } from "next/navigation"; 3 4interface Props { 5 params: Promise<{ slug: string; lessonId: string }>; 6} 7 8export default async function LessonPage({ params }: Props) { 9 const { slug, lessonId } = await params; 10 const lesson = await getLesson(slug, lessonId); 11 12 if (!lesson) { 13 notFound(); 14 } 15 16 return ( 17 <article> 18 <h1 className="text-2xl font-bold mb-4">{lesson.title}</h1> 19 <div className="aspect-video bg-gray-900 rounded-lg mb-6"> 20 <video src={lesson.videoUrl} controls className="w-full h-full" /> 21 </div> 22 <div className="prose max-w-none"> 23 {lesson.content} 24 </div> 25 </article> 26 ); 27}
Best Practices
- Use
Linkfor all internal navigation — Never use<a>for internal links - Leverage route groups — Separate layouts without URL pollution
- Use catch-all routes for CMS content — Blogs, docs, and dynamic pages
- Always handle
notFound()— Every dynamic route should validate params - Prefetch strategically — Links in viewport auto-prefetch; use
prefetch={false}for heavy pages - Keep params validation in
page.tsx— Don't let invalid slugs crash your app
Summary
| Pattern | Syntax | Use Case |
|---|---|---|
| Static | folder/page.tsx | Fixed pages (About, Contact) |
| Dynamic | [param]/page.tsx | Variable content (User profiles, Products) |
| Catch-all | [...slug]/page.tsx | Deep nesting (Documentation) |
| Optional catch-all | [[...slug]]/page.tsx | Optional deep paths |
| Route groups | (group)/page.tsx | Layout organization |
| Parallel routes | @slot/page.tsx | Multiple simultaneous views |
| Intercepting | (.)folder/page.tsx | Modal overlays |
| Link | <Link href="..."> | Client-side navigation |
| redirect | redirect("/path") | Server-side navigation |
| notFound | notFound() | 404 handling |