Skip to main content
Advertisement

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:

FeaturePages RouterApp Router
Default renderingClient ComponentsServer Components
Layouts_app.js, _document.jsNested layout.tsx
Data fetchinggetServerSideProps, getStaticPropsasync components, fetch
Loading UICustom implementationloading.tsx file convention
Error handlingCustom implementationerror.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

FileRole
page.tsxThe actual UI for the route
layout.tsxShared layout for child pages (not unmounted on navigation)
loading.tsxSuspense-based loading UI
error.tsxError Boundary-based error UI
not-found.tsx404 UI
route.tsAPI endpoint (Route Handler)
template.tsxCreates 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

SyntaxMeaning
(.)folderIntercept at the same level
(..)folderIntercept one level up
(..)(..)folderIntercept two levels up
(...)folderIntercept from the root level
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>
Advertisement