Chapter 3

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:

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:

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.

Rate this chapter
4.8  / 5  (82 ratings)

💬 Comments