Next.js Nested Dynamic Routes: Course Platform Architecture Guide
1. What Are Nested Dynamic Routes?
Nested dynamic routes combine multiple dynamic segments in a single URL path. Each segment is a variable placeholder that maps to real data in your database.
Single Dynamic: /courses/[slug]
/courses/nextjs
Nested Dynamic: /courses/[slug]/[topicSlug]
/courses/nextjs/routing
/courses/nextjs/server-components
/courses/python/functions
/courses/python/classes
The first [slug] identifies the course. The second [topicSlug] identifies the topic within that course. Together, they create a deep, meaningful URL structure that mirrors how users actually navigate learning content.
2. Why Are Nested Dynamic Routes Needed?
A course platform isn't a flat list of pages. It's a hierarchy:
Tech3Space
├── Next.js Course
│ ├── Introduction
│ ├── Installation
│ ├── App Router
│ ├── Server Components
│ ├── Client Components
│ └── Data Fetching
│
├── Python Course
│ ├── Variables
│ ├── Functions
│ ├── Classes
│ └── File Handling
Without nested dynamic routes, you'd need:
app/courses/nextjs/introduction/page.tsx
app/courses/nextjs/installation/page.tsx
app/courses/nextjs/app-router/page.tsx
app/courses/python/variables/page.tsx
app/courses/python/functions/page.tsx
Hundreds of files. Zero scalability.
With nested dynamic routes:
app/courses/[slug]/[topicSlug]/page.tsx
One file. Infinite course-topic combinations.
3. Basic Syntax
Create nested folders with square brackets for each dynamic segment:
app/
└── courses/
└── [slug]/ ← First dynamic segment (course)
├── page.tsx ← /courses/nextjs
├── layout.tsx ← Course-level layout
└── [topicSlug]/ ← Second dynamic segment (topic)
├── page.tsx ← /courses/nextjs/routing
└── layout.tsx ← Topic-level layout
Params flow through the hierarchy:
| URL | params.slug | params.topicSlug |
|---|---|---|
/courses/nextjs | "nextjs" | — |
/courses/nextjs/routing | "nextjs" | "routing" |
/courses/python/functions | "python" | "functions" |
/courses/react/hooks | "react" | "hooks" |
4. Simple Example
1// app/courses/[slug]/[topicSlug]/page.tsx 2interface Props { 3 params: Promise<{ slug: string; topicSlug: string }>; 4} 5 6export default async function TopicPage({ params }: Props) { 7 const { slug, topicSlug } = await params; 8 9 return ( 10 <main className="max-w-4xl mx-auto px-4 py-12"> 11 <nav className="text-sm text-gray-500 mb-4"> 12 <a href="/courses">Courses</a> /{" "} 13 <a href={`/courses/${slug}`}>{slug}</a> /{" "} 14 <span className="text-gray-900">{topicSlug}</span> 15 </nav> 16 17 <h1 className="text-4xl font-bold mb-4"> 18 {topicSlug.replace(/-/g, " ")} 19 </h1> 20 <p className="text-gray-600"> 21 Learning <strong>{topicSlug}</strong> in the{" "} 22 <strong>{slug}</strong> course. 23 </p> 24 </main> 25 ); 26}
URL: /courses/nextjs/server-components
Output: "Learning server-components in the nextjs course."
5. Next.js Example: Full Topic Page
Let's build a real topic page that fetches course and topic data:
1// app/courses/[slug]/[topicSlug]/page.tsx 2import { notFound } from "next/navigation"; 3import Link from "next/link"; 4import { Metadata } from "next"; 5 6interface Props { 7 params: Promise<{ slug: string; topicSlug: string }>; 8} 9 10// Mock data service 11async function getTopic(courseSlug: string, topicSlug: string) { 12 const courses = { 13 nextjs: { 14 title: "Next.js Mastery", 15 topics: { 16 routing: { 17 title: "App Router & Routing", 18 content: "Learn file-system based routing...", 19 duration: "45 min", 20 videoUrl: "https://example.com/videos/routing.mp4", 21 codeExample: `export default function Page() { 22 return <h1>Hello World</h1>; 23}`, 24 }, 25 "server-components": { 26 title: "Server Components", 27 content: "Server Components render on the server...", 28 duration: "60 min", 29 videoUrl: "https://example.com/videos/server-components.mp4", 30 codeExample: `async function CoursePage() { 31 const data = await fetch('/api/courses'); 32 return <CourseList data={data} />; 33}`, 34 }, 35 }, 36 }, 37 python: { 38 title: "Python Fundamentals", 39 topics: { 40 functions: { 41 title: "Functions in Python", 42 content: "Functions are reusable blocks of code...", 43 duration: "30 min", 44 videoUrl: "https://example.com/videos/functions.mp4", 45 codeExample: `def greet(name): 46 return f"Hello, {name}!"`, 47 }, 48 }, 49 }, 50 }; 51 52 const course = courses[courseSlug as keyof typeof courses]; 53 if (!course) return null; 54 55 const topic = course.topics[topicSlug as keyof typeof course.topics]; 56 if (!topic) return null; 57 58 return { course: { slug: courseSlug, title: course.title }, topic }; 59} 60 61export async function generateMetadata({ params }: Props): Promise<Metadata> { 62 const { slug, topicSlug } = await params; 63 const data = await getTopic(slug, topicSlug); 64 65 if (!data) { 66 return { title: "Topic Not Found | Tech3Space" }; 67 } 68 69 return { 70 title: `${data.topic.title} — ${data.course.title} | Tech3Space`, 71 description: data.topic.content.slice(0, 160), 72 }; 73} 74 75export default async function TopicPage({ params }: Props) { 76 const { slug, topicSlug } = await params; 77 const data = await getTopic(slug, topicSlug); 78 79 if (!data) { 80 notFound(); 81 } 82 83 const { course, topic } = data; 84 85 return ( 86 <article className="max-w-4xl mx-auto px-4 py-12"> 87 {/* Breadcrumb */} 88 <nav className="flex items-center gap-2 text-sm text-gray-500 mb-8"> 89 <Link href="/courses" className="hover:text-blue-600">Courses</Link> 90 <span>/</span> 91 <Link href={`/courses/${slug}`} className="hover:text-blue-600"> 92 {course.title} 93 </Link> 94 <span>/</span> 95 <span className="text-gray-900">{topic.title}</span> 96 </nav> 97 98 {/* Topic Header */} 99 <header className="mb-8"> 100 <div className="flex items-center gap-3 mb-3"> 101 <span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-xs font-medium"> 102 {topic.duration} 103 </span> 104 </div> 105 <h1 className="text-4xl font-bold">{topic.title}</h1> 106 </header> 107 108 {/* Video Player */} 109 <div className="aspect-video bg-gray-900 rounded-xl mb-8 overflow-hidden"> 110 <video 111 src={topic.videoUrl} 112 controls 113 className="w-full h-full" 114 poster={`/images/${slug}/${topicSlug}.jpg`} 115 /> 116 </div> 117 118 {/* Content */} 119 <div className="prose max-w-none mb-8"> 120 <p className="text-lg text-gray-700 leading-relaxed">{topic.content}</p> 121 </div> 122 123 {/* Code Example */} 124 <div className="bg-gray-900 rounded-xl p-6 overflow-x-auto"> 125 <div className="flex items-center justify-between mb-3"> 126 <span className="text-xs text-gray-400 uppercase">Example</span> 127 </div> 128 <pre className="text-sm text-green-400"> 129 <code>{topic.codeExample}</code> 130 </pre> 131 </div> 132 </article> 133 ); 134}
6. Real-World Example: Tech3Space Learning Platform
A production course platform needs topic navigation, progress tracking, and seamless transitions between lessons.
Topic Navigator Component
1// components/courses/TopicNavigator.tsx 2import Link from "next/link"; 3import { CheckCircle, Circle, Play } from "lucide-react"; 4 5interface Topic { 6 slug: string; 7 title: string; 8 duration: string; 9 isCompleted: boolean; 10} 11 12interface TopicNavigatorProps { 13 courseSlug: string; 14 topics: Topic[]; 15 currentTopicSlug: string; 16} 17 18export function TopicNavigator({ 19 courseSlug, 20 topics, 21 currentTopicSlug, 22}: TopicNavigatorProps) { 23 return ( 24 <aside className="w-72 bg-white border-r h-screen sticky top-0 overflow-y-auto"> 25 <div className="p-4 border-b"> 26 <h3 className="font-semibold text-gray-900">Course Content</h3> 27 <p className="text-sm text-gray-500">{topics.length} lessons</p> 28 </div> 29 <nav className="p-2"> 30 {topics.map((topic, index) => { 31 const isActive = topic.slug === currentTopicSlug; 32 const href = `/courses/${courseSlug}/${topic.slug}`; 33 34 return ( 35 <Link 36 key={topic.slug} 37 href={href} 38 className={`flex items-start gap-3 p-3 rounded-lg mb-1 transition ${ 39 isActive 40 ? "bg-blue-50 border border-blue-200" 41 : "hover:bg-gray-50" 42 }`} 43 > 44 <div className="mt-0.5"> 45 {topic.isCompleted ? ( 46 <CheckCircle className="w-5 h-5 text-green-500" /> 47 ) : isActive ? ( 48 <Play className="w-5 h-5 text-blue-600" /> 49 ) : ( 50 <Circle className="w-5 h-5 text-gray-400" /> 51 )} 52 </div> 53 <div className="flex-1"> 54 <p 55 className={`text-sm font-medium ${ 56 isActive ? "text-blue-900" : "text-gray-700" 57 }`} 58 > 59 {index + 1}. {topic.title} 60 </p> 61 <p className="text-xs text-gray-500 mt-0.5">{topic.duration}</p> 62 </div> 63 </Link> 64 ); 65 })} 66 </nav> 67 </aside> 68 ); 69}
Course Detail Layout with Topic Sidebar
1// app/courses/[slug]/layout.tsx 2import { TopicNavigator } from "@/components/courses/TopicNavigator"; 3 4interface Props { 5 children: React.ReactNode; 6 params: Promise<{ slug: string }>; 7} 8 9async function getCourseTopics(slug: string) { 10 // Fetch from API/DB 11 const topics = [ 12 { slug: "introduction", title: "Introduction", duration: "15 min", isCompleted: true }, 13 { slug: "installation", title: "Installation", duration: "20 min", isCompleted: true }, 14 { slug: "routing", title: "App Router & Routing", duration: "45 min", isCompleted: false }, 15 { slug: "server-components", title: "Server Components", duration: "60 min", isCompleted: false }, 16 { slug: "data-fetching", title: "Data Fetching", duration: "50 min", isCompleted: false }, 17 ]; 18 return topics; 19} 20 21export default async function CourseLayout({ children, params }: Props) { 22 const { slug } = await params; 23 const topics = await getCourseTopics(slug); 24 25 return ( 26 <div className="flex min-h-screen"> 27 <TopicNavigator 28 courseSlug={slug} 29 topics={topics} 30 currentTopicSlug="" // Will be determined by child page 31 /> 32 <main className="flex-1">{children}</main> 33 </div> 34 ); 35}
Active Topic Highlighting
To highlight the current topic, pass the active slug from the topic page:
1// app/courses/[slug]/[topicSlug]/page.tsx 2import { TopicNavigator } from "@/components/courses/TopicNavigator"; 3 4export default async function TopicPage({ params }: Props) { 5 const { slug, topicSlug } = await params; 6 const topics = await getCourseTopics(slug); 7 8 return ( 9 <div className="flex min-h-screen"> 10 <TopicNavigator 11 courseSlug={slug} 12 topics={topics} 13 currentTopicSlug={topicSlug} 14 /> 15 <main className="flex-1 p-8">{/* Topic content */}</main> 16 </div> 17 ); 18}
7. Common Mistakes
❌ Mistake 1: Confusing Param Order
1// WRONG — params destructured in wrong order 2const { topicSlug, slug } = await params;
1// CORRECT — match the folder structure 2const { slug, topicSlug } = await params;
❌ Mistake 2: Deep Nesting Without Layouts
1// WRONG — fetching topics in every topic page 2// app/courses/[slug]/[topicSlug]/page.tsx 3export default async function Page({ params }) { 4 const { slug } = await params; 5 const topics = await getTopics(slug); // Fetched again on every topic! 6 const topic = await getTopic(slug, topicSlug); 7 // ... 8}
1// CORRECT — fetch shared data in layout 2// app/courses/[slug]/layout.tsx 3export default async function CourseLayout({ children, params }) { 4 const { slug } = await params; 5 const topics = await getTopics(slug); // Fetched once, shared 6 return ( 7 <TopicProvider topics={topics}> 8 {children} 9 </TopicProvider> 10 ); 11}
❌ Mistake 3: Not Handling Invalid Combinations
1// WRONG — assumes topic exists for every course 2const topic = await getTopic(slug, topicSlug); 3return <TopicContent topic={topic} />; // 💥 Crash if null
1// CORRECT — validate both course and topic 2const course = await getCourse(slug); 3if (!course) notFound(); 4 5const topic = await getTopic(slug, topicSlug); 6if (!topic) notFound();
❌ Mistake 4: Hardcoding Topic Navigation
1// WRONG — not scalable 2<Link href="/courses/nextjs/routing">Routing</Link> 3<Link href="/courses/nextjs/server-components">Server Components</Link>
1// CORRECT — data-driven from API 2{topics.map((topic) => ( 3 <Link key={topic.slug} href={`/courses/${slug}/${topic.slug}`}> 4 {topic.title} 5 </Link> 6))}
8. Best Practices
| Practice | Implementation |
|---|---|
| Fetch course data in layout | Share course info across all topics |
| Validate at every level | Check course exists, then topic exists |
| Use breadcrumbs | Help users understand their location |
| Generate static params | Pre-render popular course-topic pairs |
| Add prev/next navigation | Let users move between topics easily |
| Track progress | Mark completed topics in the sidebar |
| SEO per topic | Unique title: "Topic — Course | Tech3Space" |
Previous / Next Topic Navigation
1// components/courses/TopicNavigation.tsx 2import Link from "next/link"; 3import { ChevronLeft, ChevronRight } from "lucide-react"; 4 5interface TopicNavigationProps { 6 courseSlug: string; 7 topics: { slug: string; title: string }[]; 8 currentSlug: string; 9} 10 11export function TopicNavigation({ 12 courseSlug, 13 topics, 14 currentSlug, 15}: TopicNavigationProps) { 16 const currentIndex = topics.findIndex((t) => t.slug === currentSlug); 17 const prev = topics[currentIndex - 1]; 18 const next = topics[currentIndex + 1]; 19 20 return ( 21 <div className="flex items-center justify-between mt-12 pt-8 border-t"> 22 {prev ? ( 23 <Link 24 href={`/courses/${courseSlug}/${prev.slug}`} 25 className="flex items-center gap-2 text-gray-600 hover:text-blue-600" 26 > 27 <ChevronLeft className="w-5 h-5" /> 28 <div> 29 <p className="text-xs text-gray-400">Previous</p> 30 <p className="font-medium">{prev.title}</p> 31 </div> 32 </Link> 33 ) : ( 34 <div /> 35 )} 36 37 {next ? ( 38 <Link 39 href={`/courses/${courseSlug}/${next.slug}`} 40 className="flex items-center gap-2 text-gray-600 hover:text-blue-600 text-right" 41 > 42 <div> 43 <p className="text-xs text-gray-400">Next</p> 44 <p className="font-medium">{next.title}</p> 45 </div> 46 <ChevronRight className="w-5 h-5" /> 47 </Link> 48 ) : ( 49 <div /> 50 )} 51 </div> 52 ); 53}
9. Architecture Explanation
Nested Dynamic Route Request Flow
User visits: /courses/nextjs/server-components
↓
Router matches: app/courses/[slug]/[topicSlug]/page.tsx
slug = "nextjs"
topicSlug = "server-components"
↓
Course Layout (app/courses/[slug]/layout.tsx)
├── Fetches course data
├── Fetches all topics for sidebar
└── Renders TopicNavigator + children
↓
Topic Page (app/courses/[slug]/[topicSlug]/page.tsx)
├── Validates course exists
├── Validates topic exists in course
├── Fetches topic content
├── Renders video, content, code example
└── Renders prev/next navigation
↓
Fully rendered HTML sent to browser
↓
Hydrate interactive components (video controls, progress tracker)
Tech3Space URL Architecture
/
├── /courses ← Course catalog
│ └── /courses/[slug] ← Course overview
│ ├── /courses/[slug]/introduction
│ ├── /courses/[slug]/installation
│ ├── /courses/[slug]/routing
│ ├── /courses/[slug]/server-components
│ ├── /courses/[slug]/client-components
│ └── /courses/[slug]/data-fetching
│
├── /blog
│ └── /blog/[slug]
│
└── /dashboard
└── /dashboard/courses
10. Practice Task
Task: Build a documentation site with nested dynamic routes.
Requirements:
- Create
app/docs/[category]/[article]/page.tsx - Categories:
getting-started,components,api - Each category has 3 articles
- Display a sidebar showing all articles in the current category
- Highlight the active article
- Add breadcrumbs: Docs / Category / Article
- Handle 404s for invalid categories or articles
Example URLs:
/docs/getting-started/installation/docs/components/button/docs/api/authentication
11. Mini Project: Complete Tech3Space Topic Page
Build a full topic learning page with these features:
File Structure
app/courses/[slug]/
├── page.tsx ← Course overview
├── layout.tsx ← Course layout with sidebar
├── loading.tsx ← Course skeleton
└── [topicSlug]/
├── page.tsx ← Topic content
├── loading.tsx ← Topic skeleton
└── not-found.tsx ← Invalid topic
app/courses/[slug]/[topicSlug]/page.tsx
1import { notFound } from "next/navigation"; 2import { Metadata } from "next"; 3import { getCourse, getTopic, getTopics } from "@/lib/api/courses"; 4import { TopicNavigator } from "@/components/courses/TopicNavigator"; 5import { TopicNavigation } from "@/components/courses/TopicNavigation"; 6import { VideoPlayer } from "@/components/courses/VideoPlayer"; 7import { CodeBlock } from "@/components/ui/CodeBlock"; 8 9interface Props { 10 params: Promise<{ slug: string; topicSlug: string }>; 11} 12 13export async function generateMetadata({ params }: Props): Promise<Metadata> { 14 const { slug, topicSlug } = await params; 15 const [course, topic] = await Promise.all([ 16 getCourse(slug), 17 getTopic(slug, topicSlug), 18 ]); 19 20 if (!course || !topic) { 21 return { title: "Not Found | Tech3Space" }; 22 } 23 24 return { 25 title: `${topic.title} — ${course.title} | Tech3Space`, 26 description: topic.description, 27 }; 28} 29 30export default async function TopicPage({ params }: Props) { 31 const { slug, topicSlug } = await params; 32 33 const [course, topic, topics] = await Promise.all([ 34 getCourse(slug), 35 getTopic(slug, topicSlug), 36 getTopics(slug), 37 ]); 38 39 if (!course || !topic) { 40 notFound(); 41 } 42 43 return ( 44 <div className="flex min-h-screen bg-gray-50"> 45 <TopicNavigator 46 courseSlug={slug} 47 courseTitle={course.title} 48 topics={topics} 49 currentTopicSlug={topicSlug} 50 progress={course.progress} 51 /> 52 53 <main className="flex-1 max-w-4xl mx-auto px-8 py-12"> 54 {/* Breadcrumb */} 55 <nav className="text-sm text-gray-500 mb-6"> 56 <a href="/courses">Courses</a> 57 <span className="mx-2">/</span> 58 <a href={`/courses/${slug}`}>{course.title}</a> 59 <span className="mx-2">/</span> 60 <span className="text-gray-900">{topic.title}</span> 61 </nav> 62 63 {/* Topic Header */} 64 <header className="mb-8"> 65 <span className="text-sm text-gray-500"> 66 Lesson {topics.findIndex((t) => t.slug === topicSlug) + 1} of{" "} 67 {topics.length} 68 </span> 69 <h1 className="text-3xl font-bold mt-2">{topic.title}</h1> 70 <p className="text-gray-600 mt-2">{topic.description}</p> 71 </header> 72 73 {/* Video */} 74 <VideoPlayer 75 src={topic.videoUrl} 76 poster={topic.thumbnail} 77 className="mb-8" 78 /> 79 80 {/* Content */} 81 <article className="prose max-w-none mb-8"> 82 {topic.content} 83 </article> 84 85 {/* Code Example */} 86 {topic.code && ( 87 <div className="mb-8"> 88 <h3 className="text-lg font-semibold mb-3">Code Example</h3> 89 <CodeBlock code={topic.code} language={topic.language} /> 90 </div> 91 )} 92 93 {/* Prev / Next */} 94 <TopicNavigation 95 courseSlug={slug} 96 topics={topics} 97 currentSlug={topicSlug} 98 /> 99 </main> 100 </div> 101 ); 102}
app/courses/[slug]/[topicSlug]/loading.tsx
1export default function TopicLoading() { 2 return ( 3 <div className="flex min-h-screen"> 4 <div className="w-72 bg-white border-r animate-pulse" /> 5 <main className="flex-1 max-w-4xl mx-auto px-8 py-12 animate-pulse"> 6 <div className="h-4 bg-gray-200 rounded w-1/4 mb-6" /> 7 <div className="h-8 bg-gray-200 rounded w-3/4 mb-4" /> 8 <div className="h-4 bg-gray-200 rounded w-full mb-2" /> 9 <div className="h-4 bg-gray-200 rounded w-5/6 mb-8" /> 10 <div className="aspect-video bg-gray-200 rounded-xl mb-8" /> 11 <div className="space-y-3"> 12 <div className="h-4 bg-gray-200 rounded w-full" /> 13 <div className="h-4 bg-gray-200 rounded w-full" /> 14 <div className="h-4 bg-gray-200 rounded w-4/5" /> 15 </div> 16 </main> 17 </div> 18 ); 19}
Summary
| Concept | File Path | URL Example |
|---|---|---|
| Course Overview | app/courses/[slug]/page.tsx | /courses/nextjs |
| Topic Page | app/courses/[slug]/[topicSlug]/page.tsx | /courses/nextjs/routing |
| Course Layout | app/courses/[slug]/layout.tsx | Shared across all topics |
| Topic Layout | app/courses/[slug]/[topicSlug]/layout.tsx | Topic-specific shell |
| Params | { slug, topicSlug } | Both available in page component |
What's Next?
In Module 8, we'll explore Route Groups (protected) — how to organize public and authenticated routes without polluting URLs, and how to implement real server-side authentication architecture.