Routing System — app/ Directory, Dynamic Routes, Route Groups, Parallel/Intercepting Routes
What Is the App Router?
The App Router, introduced in Next.js 13, is a new routing system based on the app/ directory. Compared to the older pages/ directory approach (Pages Router), it offers the following characteristics:
| Feature | Pages Router | App Router |
|---|---|---|
| Default rendering | Client Components | Server Components |
| Layouts | _app.js, _document.js | Nested layout.tsx |
| Data fetching | getServerSideProps, getStaticProps | async components, fetch |
| Loading UI | Custom implementation | loading.tsx file convention |
| Error handling | Custom implementation | error.tsx file convention |
Next.js 15 (2024) stabilized the App Router, and it is now recommended for all new projects.
app/ Directory Structure
Files inside the app/ folder carry special meaning based on their names.
app/
├── layout.tsx # Root layout (required)
├── page.tsx # / route
├── globals.css
├── about/
│ └── page.tsx # /about route
├── blog/
│ ├── layout.tsx # Shared layout for /blog and its children
│ ├── page.tsx # /blog route
│ └── [slug]/
│ └── page.tsx # /blog/:slug dynamic route
└── dashboard/
├── layout.tsx
├── page.tsx
├── loading.tsx # Loading UI
└── error.tsx # Error UI
File Convention Summary
| File | Role |
|---|---|
page.tsx | The actual UI for the route |
layout.tsx | Shared layout for child pages (not unmounted on navigation) |
loading.tsx | Suspense-based loading UI |
error.tsx | Error Boundary-based error UI |
not-found.tsx | 404 UI |
route.ts | API endpoint (Route Handler) |
template.tsx | Creates a new instance on every navigation |
Basic Routing
Root Layout — app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'My App',
description: 'Next.js 15 App Router Example',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body className={inter.className}>{children}</body>
</html>
);
}
Page Component — app/page.tsx
// app/page.tsx → /
export default function HomePage() {
return (
<main>
<h1>Home Page</h1>
<p>Welcome to Next.js 15 App Router.</p>
</main>
);
}
Nested Layouts
// app/dashboard/layout.tsx
import Sidebar from '@/components/Sidebar';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex">
<Sidebar />
<main className="flex-1 p-6">{children}</main>
</div>
);
}
// app/dashboard/page.tsx → /dashboard
export default function DashboardPage() {
return <h1>Dashboard</h1>;
}
Dynamic Routes
[param] — Single Segment
// app/blog/[slug]/page.tsx → /blog/hello-world
interface PageProps {
params: Promise<{ slug: string }>;
}
export default async function BlogPost({ params }: PageProps) {
const { slug } = await params; // params is a Promise in Next.js 15
const post = await getPost(slug);
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
async function getPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { revalidate: 3600 }, // Revalidate every 1 hour
});
if (!res.ok) throw new Error('Failed to load post');
return res.json();
}
[...slug] — Catch-All Segment
// app/docs/[...slug]/page.tsx
// Matches /docs/intro, /docs/guide/setup, /docs/guide/advanced/tips
interface PageProps {
params: Promise<{ slug: string[] }>;
}
export default async function DocsPage({ params }: PageProps) {
const { slug } = await params;
// slug = ['guide', 'setup'] for /docs/guide/setup
return (
<div>
<p>Path: {slug.join(' / ')}</p>
</div>
);
}
[[...slug]] — Optional Catch-All
// app/shop/[[...categories]]/page.tsx
// /shop (categories = undefined)
// /shop/electronics (categories = ['electronics'])
// /shop/electronics/phones (categories = ['electronics', 'phones'])
interface PageProps {
params: Promise<{ categories?: string[] }>;
}
export default async function ShopPage({ params }: PageProps) {
const { categories } = await params;
if (!categories) {
return <h1>All Products</h1>;
}
return <h1>Category: {categories.join(' > ')}</h1>;
}
generateStaticParams — Static Generation
Statically generates dynamic routes at build time.
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return posts.map((post: { slug: string }) => ({
slug: post.slug,
}));
}
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
return <article>{post.title}</article>;
}
Route Groups
Wrapping a folder name in (parentheses) groups files without affecting the URL path.
Separating Layouts
app/
├── (marketing)/
│ ├── layout.tsx # Marketing-specific layout
│ ├── page.tsx # /
│ ├── about/
│ │ └── page.tsx # /about
│ └── pricing/
│ └── page.tsx # /pricing
├── (app)/
│ ├── layout.tsx # App layout (with sidebar)
│ ├── dashboard/
│ │ └── page.tsx # /dashboard
│ └── settings/
│ └── page.tsx # /settings
└── layout.tsx # Root layout (shared)
// app/(marketing)/layout.tsx
export default function MarketingLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div>
<header>Marketing Header (Login / Sign Up)</header>
{children}
<footer>Marketing Footer</footer>
</div>
);
}
// app/(app)/layout.tsx
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/auth';
export default async function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getSession();
if (!session) redirect('/login');
return (
<div className="flex">
<nav>App Sidebar</nav>
<main>{children}</main>
</div>
);
}
Multiple Root Layouts
Each route group can have its own root layout (including <html> and <body>).
app/
├── (shop)/
│ ├── layout.tsx # Shop-specific html/body
│ └── page.tsx
└── (blog)/
├── layout.tsx # Blog-specific html/body
└── page.tsx
Parallel Routes
Use @folder slots to render multiple pages simultaneously within the same layout.
Basic Structure
app/
└── dashboard/
├── layout.tsx # Receives @analytics and @team slots
├── page.tsx
├── @analytics/
│ ├── page.tsx
│ └── default.tsx # Default UI for the slot
└── @team/
├── page.tsx
└── default.tsx
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
team,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
team: React.ReactNode;
}) {
return (
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">{children}</div>
<aside>
{analytics}
{team}
</aside>
</div>
);
}
// app/dashboard/@analytics/page.tsx
export default async function AnalyticsPanel() {
const data = await fetchAnalytics();
return (
<div className="p-4 border rounded">
<h2>Analytics</h2>
<p>Visitors: {data.visitors}</p>
</div>
);
}
// app/dashboard/@analytics/default.tsx
// Shown when no sub-page matches for the parallel route
export default function AnalyticsDefault() {
return <div>No analytics data</div>;
}
Tab UI Example
Parallel routes can be used to implement conditional modals or tabs.
// app/dashboard/layout.tsx
import Link from 'next/link';
export default function DashboardLayout({
children,
overview,
metrics,
}: {
children: React.ReactNode;
overview: React.ReactNode;
metrics: React.ReactNode;
}) {
return (
<div>
<nav className="flex gap-4 mb-4">
<Link href="/dashboard">Overview</Link>
<Link href="/dashboard/metrics">Metrics</Link>
</nav>
{overview}
{metrics}
{children}
</div>
);
}
Intercepting Routes
Intercept routes allow you to display the content of another route as a modal while keeping the current context. This is used to implement patterns like Instagram's photo preview.
Convention
| Syntax | Meaning |
|---|---|
(.)folder | Intercept at the same level |
(..)folder | Intercept one level up |
(..)(..)folder | Intercept two levels up |
(...)folder | Intercept from the root level |
Gallery + Modal Example
app/
├── layout.tsx
├── page.tsx # Gallery listing /
├── photos/
│ └── [id]/
│ └── page.tsx # Full-screen photo /photos/1
└── @modal/
├── default.tsx # Returns null (default)
└── (.)photos/
└── [id]/
└── page.tsx # Displayed as modal
// app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
{children}
{modal}
</body>
</html>
);
}
// app/page.tsx — Gallery listing
import Link from 'next/link';
const photos = [
{ id: 1, src: '/photo1.jpg', title: 'Photo 1' },
{ id: 2, src: '/photo2.jpg', title: 'Photo 2' },
{ id: 3, src: '/photo3.jpg', title: 'Photo 3' },
];
export default function GalleryPage() {
return (
<div className="grid grid-cols-3 gap-4 p-8">
{photos.map((photo) => (
<Link key={photo.id} href={`/photos/${photo.id}`}>
<img src={photo.src} alt={photo.title} className="w-full rounded" />
</Link>
))}
</div>
);
}
// app/photos/[id]/page.tsx — Full screen (accessed directly by URL)
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function PhotoPage({ params }: PageProps) {
const { id } = await params;
return (
<div className="flex items-center justify-center min-h-screen bg-black">
<img src={`/photo${id}.jpg`} alt={`Photo ${id}`} className="max-h-screen" />
</div>
);
}
// app/@modal/(.)photos/[id]/page.tsx — Intercepted as modal
'use client';
import { useRouter } from 'next/navigation';
import Modal from '@/components/Modal';
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function PhotoModal({ params }: PageProps) {
const { id } = await params;
return (
<Modal>
<img src={`/photo${id}.jpg`} alt={`Photo ${id}`} className="max-w-2xl" />
</Modal>
);
}
// components/Modal.tsx
'use client';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect } from 'react';
export default function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter();
const handleClose = useCallback(() => {
router.back();
}, [router]);
// Close with ESC key
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleClose();
};
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, [handleClose]);
return (
<div
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
onClick={handleClose}
>
<div onClick={(e) => e.stopPropagation()}>{children}</div>
</div>
);
}
// app/@modal/default.tsx — null when not intercepting
export default function ModalDefault() {
return null;
}
loading.tsx and error.tsx
loading.tsx — Suspense-based Loading UI
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
);
}
error.tsx — Error Boundary
// app/dashboard/error.tsx
'use client'; // Error components must be Client Components
import { useEffect } from 'react';
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="text-center p-8">
<h2 className="text-xl font-bold text-red-600">Something went wrong</h2>
<p className="text-gray-600 mt-2">{error.message}</p>
<button
onClick={reset}
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded"
>
Try again
</button>
</div>
);
}
Route Handler (API)
Create API endpoints using a route.ts file.
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
// GET /api/users
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const page = searchParams.get('page') ?? '1';
const users = await fetchUsers({ page: parseInt(page) });
return NextResponse.json(users);
}
// POST /api/users
export async function POST(request: NextRequest) {
const body = await request.json();
if (!body.name || !body.email) {
return NextResponse.json(
{ error: 'Name and email are required' },
{ status: 400 }
);
}
const user = await createUser(body);
return NextResponse.json(user, { status: 201 });
}
// app/api/users/[id]/route.ts
interface RouteContext {
params: Promise<{ id: string }>;
}
// GET /api/users/:id
export async function GET(request: NextRequest, { params }: RouteContext) {
const { id } = await params;
const user = await findUser(id);
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
return NextResponse.json(user);
}
// DELETE /api/users/:id
export async function DELETE(request: NextRequest, { params }: RouteContext) {
const { id } = await params;
await deleteUser(id);
return new NextResponse(null, { status: 204 });
}
Practical Example — Blog Platform Routing
app/
├── layout.tsx
├── page.tsx # / Home
├── (auth)/
│ ├── login/
│ │ └── page.tsx # /login
│ └── register/
│ └── page.tsx # /register
├── (main)/
│ ├── layout.tsx # With header/footer
│ ├── blog/
│ │ ├── page.tsx # /blog listing
│ │ ├── [slug]/
│ │ │ ├── page.tsx # /blog/:slug
│ │ │ └── not-found.tsx
│ │ └── category/
│ │ └── [category]/
│ │ └── page.tsx # /blog/category/:category
│ └── @modal/
│ ├── default.tsx
│ └── (.)blog/
│ └── [slug]/
│ └── page.tsx # Preview modal
└── api/
├── posts/
│ └── route.ts
└── posts/
└── [slug]/
└── route.ts
// app/(main)/blog/page.tsx
import Link from 'next/link';
import { Suspense } from 'react';
async function PostList() {
const posts = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 },
}).then((r) => r.json());
return (
<ul className="space-y-6">
{posts.map((post: any) => (
<li key={post.slug} className="border-b pb-6">
<Link href={`/blog/${post.slug}`}>
<h2 className="text-xl font-bold hover:underline">{post.title}</h2>
</Link>
<p className="text-gray-600 mt-1">{post.excerpt}</p>
<time className="text-sm text-gray-400">{post.publishedAt}</time>
</li>
))}
</ul>
);
}
export default function BlogPage() {
return (
<div className="max-w-2xl mx-auto py-12 px-4">
<h1 className="text-3xl font-bold mb-8">Blog</h1>
<Suspense fallback={<p>Loading posts...</p>}>
<PostList />
</Suspense>
</div>
);
}
Pro Tips
1. Next.js 15 params — Always await
// In Next.js 15, params and searchParams are Promises
// ❌ Old way
export default function Page({ params }: { params: { id: string } }) {
const { id } = params; // May cause type errors
}
// ✅ Next.js 15 way
export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
}
2. Limit Middleware Scope with Route Groups
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Apply auth only to (app) group paths
if (pathname.startsWith('/dashboard') || pathname.startsWith('/settings')) {
const token = request.cookies.get('token');
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*'],
};
3. Parallel Data Fetching
// Run multiple fetches in parallel — avoid sequential execution
async function DashboardPage() {
// ❌ Sequential execution (slow)
// const user = await fetchUser();
// const posts = await fetchPosts();
// ✅ Parallel execution (fast)
const [user, posts, analytics] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchAnalytics(),
]);
return (
<div>
<h1>Hello, {user.name}</h1>
<p>Posts: {posts.length}</p>
<p>Visitors: {analytics.visitors}</p>
</div>
);
}
4. notFound() and not-found.tsx
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
notFound(); // Renders app/blog/[slug]/not-found.tsx
}
return <article>{post.title}</article>;
}
// app/blog/[slug]/not-found.tsx
import Link from 'next/link';
export default function PostNotFound() {
return (
<div className="text-center py-16">
<h2 className="text-2xl font-bold">Post not found</h2>
<Link href="/blog" className="text-blue-500 mt-4 inline-block">
Back to blog list
</Link>
</div>
);
}
5. Type-Safe Routing — next-safe-navigation
npm install next-safe-navigation
import { createNavigationConfig } from 'next-safe-navigation';
import { z } from 'zod';
// Define route types
export const { routes, useSafeRouter, Link } = createNavigationConfig((defineRoute) => ({
home: defineRoute('/'),
blog: defineRoute('/blog'),
blogPost: defineRoute('/blog/[slug]', {
params: z.object({ slug: z.string() }),
}),
shop: defineRoute('/shop/[[...categories]]', {
params: z.object({ categories: z.array(z.string()).optional() }),
}),
}));
// Usage
import { routes } from '@/lib/navigation';
// ✅ Type-safe — compile error if slug is missing
<Link route={routes.blogPost({ slug: 'hello-world' })}>
Blog Post
</Link>