Dynamic Routes, Route Groups and Parallel Routes
Dynamic Route Segments: Parameterized URLs
Static routes handle known paths. But in real applications, blog post slugs, user IDs, product SKUs โ these values are unknown at build time. URLs need to match arbitrary values. This is what dynamic route segments solve.
[slug] โ Single Dynamic Segment
Wrapping a folder name in square brackets makes that segment a dynamic parameter that captures any value:
app/
โโโ blog/
โโโ [slug]/
โโโ page.tsx # Matches /blog/hello-world, /blog/my-post, etc.
The captured value is available via the params prop. In Next.js 15, params is a Promise and must be awaited:
// app/blog/[slug]/page.tsx
interface Props {
params: Promise<{ slug: string }>;
}
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = await db.posts.findFirst({
where: { slug, published: true },
});
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<PostBody content={post.content} />
</article>
);
}
Dynamic segments can nest and stack:
app/
โโโ shop/
โโโ [category]/
โโโ [productId]/
โโโ page.tsx # /shop/electronics/iphone-15
// params: { category: 'electronics', productId: 'iphone-15' }
interface Props {
params: Promise<{ category: string; productId: string }>;
}
Both parameters are available independently. The folder nesting makes the URL hierarchy explicit: category is a parent concept of productId.
[...slug] โ Catch-All Segments
[...slug] matches a path segment and all subsequent segments. The parameter value is a string array:
app/
โโโ docs/
โโโ [...slug]/
โโโ page.tsx # Matches /docs/a, /docs/a/b, /docs/a/b/c
# Does NOT match /docs (needs at least one segment)
// app/docs/[...slug]/page.tsx
interface Props {
params: Promise<{ slug: string[] }>;
}
export default async function DocsPage({ params }: Props) {
const { slug } = await params;
// /docs/getting-started โ slug = ['getting-started']
// /docs/api/reference/hooks โ slug = ['api', 'reference', 'hooks']
const docPath = slug.join('/'); // Reconstruct as a path string
const doc = await getDoc(docPath);
if (!doc) notFound();
return <DocPage doc={doc} breadcrumbs={slug} />;
}
Catch-all routes are ideal for documentation sites, content management systems, or any scenario where the depth of the URL hierarchy is determined by content structure rather than code structure.
[[...slug]] โ Optional Catch-All
Double brackets make the catch-all optional. The difference from [...slug] is that [[...slug]] also matches the path with no dynamic segments at all:
app/
โโโ docs/
โโโ [[...slug]]/
โโโ page.tsx # Matches /docs (slug is undefined)
# Also matches /docs/a, /docs/a/b, etc.
// app/docs/[[...slug]]/page.tsx
interface Props {
params: Promise<{ slug?: string[] }>;
}
export default async function DocsPage({ params }: Props) {
const { slug } = await params;
if (!slug) {
// Render the documentation index/landing page
return <DocsIndex />;
}
const docPath = slug.join('/');
const doc = await getDoc(docPath);
if (!doc) notFound();
return <DocPage doc={doc} />;
}
This is particularly useful for documentation sites: /docs shows the overview, /docs/getting-started/installation shows the specific page, and both share the same layout and page component. A single page.tsx handles the entire docs section.
Static Pre-rendering with generateStaticParams
For dynamic routes whose content is relatively stable โ blog posts, documentation, product pages โ you can tell Next.js which paths to pre-render at build time using generateStaticParams:
// app/blog/[slug]/page.tsx
// Called at build time, returns all paths to pre-render
export async function generateStaticParams() {
const posts = await db.posts.findMany({
where: { published: true },
select: { slug: true },
});
// Return array of param objects matching the dynamic segments
return posts.map(post => ({ slug: post.slug }));
}
// For paths not listed in generateStaticParams:
// - true (default): render on demand
// - false: return 404
export const dynamicParams = true;
export default async function PostPage({ params }: Props) {
// Same implementation as before
}
generateStaticParams integrates with Next.js's incremental static regeneration (ISR). Pre-rendered paths are served from edge cache, while unlisted paths are rendered on demand and then cached. This is the optimal strategy for large content sites: build common pages statically, generate rare pages on demand.
Route Groups: Directory Hierarchy Without URL Impact
Why Route Groups Exist
As applications grow, the app/ directory can become large and difficult to organize. Different sections of your application often need different layouts, but you don't want those organizational distinctions to appear in URLs. Consider:
- Marketing pages (
/,/about,/pricing) need a layout with a marketing nav bar - Application pages (
/dashboard,/settings) need a layout with a sidebar - Authentication pages (
/login,/register) need a minimal layout without navigation
Using plain directories would produce URLs like /marketing/about or /app/dashboard โ not what you want.
(group) โ Parentheses Create Groups, No URL Impact
Wrapping a folder name in parentheses creates a route group โ it organizes files without contributing a URL segment:
app/
โโโ (marketing)/
โ โโโ layout.tsx # Marketing layout (nav bar)
โ โโโ page.tsx # / (home page)
โ โโโ about/
โ โ โโโ page.tsx # /about
โ โโโ pricing/
โ โโโ page.tsx # /pricing
โโโ (app)/
โ โโโ layout.tsx # Application layout (sidebar)
โ โโโ dashboard/
โ โ โโโ page.tsx # /dashboard
โ โโโ settings/
โ โโโ page.tsx # /settings
โโโ (auth)/
โโโ layout.tsx # Auth layout (minimal)
โโโ login/
โ โโโ page.tsx # /login
โโโ register/
โโโ page.tsx # /register
When you visit /about, Next.js uses (marketing)/layout.tsx. When you visit /dashboard, it uses (app)/layout.tsx. The URL is always /about and /dashboard โ the group folders are invisible to users and search engines.
// app/(marketing)/layout.tsx
export default function MarketingLayout({ children }: { children: React.ReactNode }) {
return (
<>
<MarketingNav />
{children}
<MarketingFooter />
</>
);
}
// 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();
// Authentication guard at the layout level
if (!session) redirect('/login');
return (
<div className="flex h-screen">
<Sidebar user={session.user} />
<main className="flex-1 overflow-auto">{children}</main>
</div>
);
}
The (app)/layout.tsx example shows another benefit of route groups for authentication: you can put auth guards in a layout that wraps all protected routes, avoiding repetition in every page component.
Multiple Root Layouts
Route groups enable another powerful pattern: multiple root layouts. By deleting the top-level app/layout.tsx and creating a layout.tsx in each route group, different sections can have completely independent <html> and <body> structures. This is useful when sections have genuinely different HTML shells โ different <lang> attributes, different font loading strategies, or different viewport configurations.
Parallel Routes: Rendering Multiple Pages Simultaneously
The Problem Parallel Routes Solve
Complex dashboards often have multiple independent data panels: user statistics in one area, a real-time chart in another, an activity feed in a third. Each panel needs its own data, its own loading state, and its own error handling. Without parallel routes, you'd fetch all this data at the page level and manually coordinate loading and error states.
Parallel routes make these independent areas first-class routing concepts. Each slot fetches its own data independently, has its own loading.tsx and error.tsx, and streams its content as soon as it's ready โ without waiting for other slots.
@slot โ Defining Named Slots
Create slot directories with an @ prefix alongside your layout:
app/
โโโ dashboard/
โโโ layout.tsx # Receives children, analytics, activity as props
โโโ page.tsx # /dashboard main content
โโโ @analytics/
โ โโโ page.tsx # Analytics panel
โ โโโ loading.tsx # Analytics skeleton
โโโ @activity/
โโโ page.tsx # Activity feed
โโโ loading.tsx # Activity skeleton
The slot directory names (without @) become props on the sibling layout.tsx:
// app/dashboard/layout.tsx
interface Props {
children: React.ReactNode; // Rendered by dashboard/page.tsx
analytics: React.ReactNode; // Rendered by @analytics/page.tsx
activity: React.ReactNode; // Rendered by @activity/page.tsx
}
export default function DashboardLayout({ children, analytics, activity }: Props) {
return (
<div className="dashboard-grid">
<section className="main-content">{children}</section>
<aside className="analytics-panel">{analytics}</aside>
<aside className="activity-feed">{activity}</aside>
</div>
);
}
// app/dashboard/@analytics/page.tsx
export default async function AnalyticsPanel() {
// This data fetch is independent of @activity and children
const stats = await fetchAnalytics();
return (
<div className="analytics">
<h2>This Month</h2>
<StatCard label="Visitors" value={stats.visitors} trend={stats.visitorTrend} />
<StatCard label="Conversion" value={`${stats.conversionRate}%`} />
<StatCard label="Revenue" value={formatCurrency(stats.revenue)} />
</div>
);
}
// app/dashboard/@analytics/loading.tsx
export default function AnalyticsLoading() {
return (
<div className="analytics animate-pulse">
<div className="h-6 bg-gray-200 rounded w-1/3 mb-4" />
{[1, 2, 3].map(i => (
<div key={i} className="h-16 bg-gray-200 rounded mb-2" />
))}
</div>
);
}
// app/dashboard/@activity/page.tsx
export default async function ActivityFeed() {
const events = await fetchRecentActivity();
return (
<ul className="activity-list">
{events.map(event => (
<li key={event.id} className="activity-item">
<Avatar src={event.user.avatar} />
<div>
<span className="font-medium">{event.user.name}</span>
<span> {event.description}</span>
</div>
<time>{formatRelativeTime(event.createdAt)}</time>
</li>
))}
</ul>
);
}
The three rendering slots โ children, analytics, activity โ execute concurrently. The slowest fetch does not block the others. The page begins streaming immediately, and each slot's content appears as soon as its data is ready. Each slot's loading.tsx shows a skeleton independently.
default.tsx โ Fallback for Unmatched Slots
When the user navigates to a URL that does not have a matching page.tsx in a slot's directory, Next.js needs a fallback. This is where default.tsx comes in.
Consider navigating from /dashboard to /dashboard/reports. The /dashboard/reports path does not exist under @analytics or @activity. Without default.tsx, Next.js would throw an error because it can't render those slots.
// app/dashboard/@analytics/default.tsx
// Rendered when no page.tsx in this slot matches the current URL
export default function AnalyticsDefault() {
// Return the same content as page.tsx, or a simplified version
return <AnalyticsPanel />;
}
// app/dashboard/@activity/default.tsx
export default function ActivityDefault() {
return <ActivityFeed />;
}
A common pattern is for default.tsx to simply re-export or render the same component as page.tsx. This ensures the slot continues to show its content regardless of which sub-route is active in the children slot.
The null pattern is equally important. For modal-like slots that should be empty by default:
// app/dashboard/@modal/default.tsx
export default function ModalDefault() {
return null; // Nothing shown when no modal is active
}
Complete Dashboard Example
Combining everything from this chapter into a real dashboard structure:
app/
โโโ layout.tsx # Root layout
โโโ (app)/
โโโ layout.tsx # Auth guard + sidebar
โโโ dashboard/
โโโ layout.tsx # Parallel route container
โโโ page.tsx # /dashboard โ overview metrics
โโโ @kpi/
โ โโโ default.tsx # Keep showing KPIs when subroutes active
โ โโโ loading.tsx # KPI skeleton
โ โโโ page.tsx # Key performance indicators
โโโ @chart/
โ โโโ default.tsx
โ โโโ loading.tsx
โ โโโ page.tsx # Revenue/traffic chart
โโโ reports/
โโโ [reportId]/
โโโ page.tsx # /dashboard/reports/:reportId
// app/(app)/dashboard/layout.tsx
export default function DashboardLayout({
children,
kpi,
chart,
}: {
children: React.ReactNode;
kpi: React.ReactNode;
chart: React.ReactNode;
}) {
return (
<div className="grid grid-cols-12 gap-4 p-6">
{/* KPI row โ always visible */}
<div className="col-span-12">{kpi}</div>
{/* Chart โ spans 8 columns */}
<div className="col-span-8">{chart}</div>
{/* Main content โ either /dashboard page or a report */}
<div className="col-span-4">{children}</div>
</div>
);
}
When the user navigates to /dashboard/reports/2024-q4:
- Root layout provides
<html>/<body> (app)/layout.tsxvalidates authentication and shows the sidebar- Dashboard layout renders the 12-column grid
@kpiand@chartslots fall back todefault.tsx, continuing to show their panelschildrenrendersreports/[reportId]/page.tsxwithreportId = '2024-q4'
This is a genuinely powerful composition model. The persistent panels don't re-fetch or re-render just because the user opened a report. Navigation is instant for the sidebar areas while only the new content streams in.
Summary
Dynamic segments ([slug], [...slug], [[...slug]]) handle variable portions of URLs across the full spectrum of complexity โ from simple single-parameter routes to arbitrarily deep catch-all paths. generateStaticParams bridges the gap between dynamic routes and static pre-rendering, giving you the flexibility of dynamic paths with the performance of static generation.
Route groups ((group)) resolve the tension between layout organization and URL structure. They let different areas of your application have genuinely different layouts โ with different navigation, authentication, and structure โ without those organizational distinctions polluting URLs.
Parallel routes (@slot) elevate the multi-panel dashboard from an application pattern to a framework feature. Each slot manages its own data fetching, loading state, and error boundary independently. Navigation between sub-routes leaves persistent slots untouched. default.tsx ensures graceful fallback when a slot has no matching route for the current URL. These three features, used in combination, enable routing architectures that are simultaneously flexible, performant, and maintainable.