Next.js course Β· Module 2: Routing and Layouts

Advanced Routing in Next.js 16 - Parallel Routes, Intercepting Routes, and Route Groups

13 min read Β·
In this lesson6

In the Quantum Metropolis, the advanced transport system allows simultaneous travel along multiple parallel paths, in-flight traffic interception and redirection, and logical route grouping without affecting their addresses. Similarly, Next.js 16 offers advanced routing techniques that enable creating complex user interfaces with elegance and efficiency.

In this chapter, you will learn three powerful routing patterns in the App Router:

  1. Parallel Routes - rendering multiple routes simultaneously
  2. Intercepting Routes - intercepting navigation and showing alternative views
  3. Route Groups - organizing routes without affecting the URL

Parallel Routes - Simultaneous Route Rendering

What Are Parallel Routes?

Parallel Routes allow simultaneous rendering of multiple pages (or parts of pages) in the same layout. It's like holographic displays in the Quantum Metropolis that can show multiple independent information streams simultaneously.

Naming Convention - @folder

Parallel Routes use a special naming convention with the @ prefix:

app/
β”œβ”€β”€ layout.tsx
β”œβ”€β”€ page.tsx
β”œβ”€β”€ @analytics/       # Slot "analytics"
β”‚   β”œβ”€β”€ default.tsx
β”‚   └── page.tsx
β”œβ”€β”€ @team/           # Slot "team"
β”‚   β”œβ”€β”€ default.tsx
β”‚   └── page.tsx
└── @activity/       # "activity" Slot
    β”œβ”€β”€ default.tsx
    └── page.tsx

Basic Example - Dashboard with Multiple Panels

Imagine a Quantum Metropolis control panel that simultaneously displays analytics, team activity, and recent events:

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  team,
  activity,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  team: React.ReactNode;
  activity: React.ReactNode;
}) {
  return (
    <div className="quantum-dashboard">
      <div className="main-content">
        {children}
      </div>

      <div className="grid grid-cols-3 gap-6 mt-6">
        <div className="panel">
          <h2>Quantum Analytics</h2>
          {analytics}
        </div>

        <div className="panel">
          <h2>Team</h2>
          {team}
        </div>

        <div className="panel">
          <h2>Recent Activity</h2>
          {activity}
        </div>
      </div>
    </div>
  );
}
// app/dashboard/@analytics/page.tsx
async function getAnalytics() {
  const res = await fetch('https://api.quantum.com/analytics', {
    next: { revalidate: 60 }
  });
  return res.json();
}

export default async function Analytics() {
  const data = await getAnalytics();

  return (
    <div className="analytics-panel">
      <div className="stat">
        <span className="label">Quantum Energy</span>
        <span className="value">{data.energy}%</span>
      </div>
      <div className="stat">
        <span className="label">System Efficiency</span>
        <span className="value">{data.efficiency}%</span>
      </div>
    </div>
  );
}
// app/dashboard/@team/page.tsx
async function getTeam() {
  const res = await fetch('https://api.quantum.com/team', {
    next: { revalidate: 300 }
  });
  return res.json();
}

export default async function Team() {
  const members = await getTeam();

  return (
    <ul className="team-list">
      {members.map(member => (
        <li key={member.id} className="team-member">
          <img src={member.avatar} alt={member.name} />
          <span>{member.name}</span>
          <span className="status">{member.status}</span>
        </li>
      ))}
    </ul>
  );
}
// app/dashboard/@activity/page.tsx
async function getActivity() {
  const res = await fetch('https://api.quantum.com/activity', {
    cache: 'no-store' // Always fresh data
  });
  return res.json();
}

export default async function Activity() {
  const activities = await getActivity();

  return (
    <div className="activity-feed">
      {activities.map(activity => (
        <div key={activity.id} className="activity-item">
          <span className="timestamp">{activity.time}</span>
          <p>{activity.description}</p>
        </div>
      ))}
    </div>
  );
}

Advantages of Parallel Routes

  1. Independent loading - each slot can be loaded independently with its own loading state
  2. Independent errors - an error in one slot doesn't affect others
  3. Selective navigation - you can navigate within one slot without affecting others
  4. Conditional rendering - you can conditionally show/hide slots

Default.tsx - Handling Missing Slots

When a user navigates in a way that doesn't match the slot structure (for example, reloads the page at an address the slot does not handle), Next.js uses the default.tsx file. In Next.js 16 this file is required in every slot, because without it next build fails:

// app/dashboard/@analytics/default.tsx
export default function DefaultAnalytics() {
  return <div>No analytics data available</div>;
}

Conditional Slot Rendering

// app/dashboard/layout.tsx
import { getCurrentUser } from '@/lib/auth';

export default async function DashboardLayout({
  children,
  analytics,
  team,
  activity,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  team: React.ReactNode;
  activity: React.ReactNode;
}) {
  // The role comes from the session: params only holds the dynamic URL segments
  const user = await getCurrentUser();
  const isAdmin = user?.role === 'admin';

  return (
    <div className="quantum-dashboard">
      <div className="main-content">{children}</div>

      <div className="panels">
        {analytics}
        {team}
        {isAdmin && activity} {/* Only for admins */}
      </div>
    </div>
  );
}

Intercepting Routes - Navigation Interception

What Are Intercepting Routes?

Intercepting Routes allow intercepting navigation and displaying an alternative view while the URL changes. It's like quantum tunnels in the Quantum Metropolis - they enable shortcuts without changing the final destination.

Naming Convention

Intercepting Routes use special prefixes:

  • (.) - intercepts segments at the same level
  • (..) - intercepts segments one level up
  • (..)(..) - intercepts segments two levels up
  • (...) - intercepts segments from the app/ root

Example 1: Image Modal

A classic use case - a photo gallery where clicking opens a modal but the URL changes:

app/
β”œβ”€β”€ layout.tsx
β”œβ”€β”€ page.tsx              # Gallery /
β”œβ”€β”€ photos/
β”‚   └── [id]/
β”‚       └── page.tsx      # Full photo page /photos/123
└── @modal/
    β”œβ”€β”€ default.tsx       # Returns null while the modal is closed (required in Next.js 16)
    └── (.)photos/
        └── [id]/
            └── page.tsx  # Intercepting modal /photos/123

@modal is a slot, not a URL segment, so photos from app/photos are intercepted by (.)photos. Writing (..)photos at this level makes next build fail with β€œCannot use (..) marker at the root level, use (.) instead”.

// app/page.tsx - Gallery
import Link from 'next/link';
import Image from 'next/image';

const photos = [
  { id: '1', url: '/photos/quantum-city-1.jpg', title: 'Quantum District' },
  { id: '2', url: '/photos/quantum-city-2.jpg', title: 'Central Plaza' },
  { id: '3', url: '/photos/quantum-city-3.jpg', title: 'Night Skyline' },
];

export default function Gallery() {
  return (
    <div className="gallery-grid">
      {photos.map(photo => (
        <Link key={photo.id} href={`/photos/${photo.id}`}>
          <div className="photo-card">
            <Image
              src={photo.url}
              alt={photo.title}
              width={300}
              height={200}
            />
            <p>{photo.title}</p>
          </div>
        </Link>
      ))}
    </div>
  );
}
// app/photos/[id]/page.tsx - Full photo page
import Image from 'next/image';

async function getPhoto(id: string) {
  // Fetch photo data
  return {
    id,
    url: `/photos/quantum-city-${id}.jpg`,
    title: 'Quantum City',
    description: 'View of Quantum Metropolis',
  };
}

export default async function PhotoPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const photo = await getPhoto((await params).id);

  return (
    <div className="photo-full-page">
      <h1>{photo.title}</h1>
      <Image
        src={photo.url}
        alt={photo.title}
        width={1200}
        height={800}
      />
      <p>{photo.description}</p>
    </div>
  );
}
// app/@modal/(.)photos/[id]/page.tsx - Modal
'use client';

import { useRouter } from 'next/navigation';
import Image from 'next/image';
import { use, useEffect, useState } from 'react';

export default function PhotoModal({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = use(params);
  const router = useRouter();
  const [photo, setPhoto] = useState(null);

  useEffect(() => {
    // Fetch photo data
    fetch(`/api/photos/${id}`)
      .then(res => res.json())
      .then(setPhoto);
  }, [id]);

  if (!photo) return null;

  return (
    <div
      className="modal-overlay"
      onClick={() => router.back()}
    >
      <div
        className="modal-content"
        onClick={(e) => e.stopPropagation()}
      >
        <button
          className="modal-close"
          onClick={() => router.back()}
          aria-label="Close"
        >
          Γ—
        </button>

        <Image
          src={photo.url}
          alt={photo.title}
          width={800}
          height={600}
        />

        <h2>{photo.title}</h2>
        <p>{photo.description}</p>
      </div>
    </div>
  );
}
// app/layout.tsx - Layout with modal
export default function RootLayout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {children}
        {modal}
      </body>
    </html>
  );
}

The @modal slot also needs a default.tsx file that renders nothing while the modal is closed:

// app/@modal/default.tsx
export default function Default() {
  return null;
}

Example 2: Login Modal with Intercepting Route

app/
β”œβ”€β”€ layout.tsx
β”œβ”€β”€ page.tsx
β”œβ”€β”€ login/
β”‚   └── page.tsx          # Full login page /login
└── @auth/
    β”œβ”€β”€ default.tsx       # Returns null while the modal is closed
    └── (.)login/
        └── page.tsx      # Login modal
// app/@auth/(.)login/page.tsx
'use client';

import { useRouter } from 'next/navigation';
import { useState } from 'react';

export default function LoginModal() {
  const router = useRouter();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    // Login logic
    await loginUser(email, password);
    router.back();
  };

  return (
    <div className="modal-overlay" onClick={() => router.back()}>
      <div className="modal-content" onClick={(e) => e.stopPropagation()}>
        <h2>Log in to Quantum Metropolis</h2>

        <form onSubmit={handleSubmit}>
          <input
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            placeholder="Email"
            required
          />

          <input
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            placeholder="Password"
            required
          />

          <button type="submit">Log in</button>
        </form>
      </div>
    </div>
  );
}

The root layout must also receive the auth prop and render {auth}, just like {modal} in example 1.

Advantages of Intercepting Routes

  1. Better UX - quick modals without full page reload
  2. Shareable URLs - the URL changes, so the link can be shared
  3. Graceful fallback - direct URL access shows the full page
  4. Context preservation - the background remains visible behind the modal

Route Groups - Route Organization

What Are Route Groups?

Route Groups allow logical grouping of routes without affecting the URL structure. It's like districts in the Quantum Metropolis - they organize the city but don't affect building addresses.

Naming Convention - (folder)

Route Groups use parentheses in folder names:

app/
β”œβ”€β”€ (marketing)/        # Group - doesn't affect the URL
β”‚   β”œβ”€β”€ layout.tsx      # Marketing layout
β”‚   β”œβ”€β”€ about/
β”‚   β”‚   └── page.tsx    # URL: /about
β”‚   └── contact/
β”‚       └── page.tsx    # URL: /contact
β”œβ”€β”€ (shop)/            # Group - doesn't affect the URL
β”‚   β”œβ”€β”€ layout.tsx      # Shop layout
β”‚   β”œβ”€β”€ products/
β”‚   β”‚   └── page.tsx    # URL: /products
β”‚   └── cart/
β”‚       └── page.tsx    # URL: /cart
└── (dashboard)/       # Group - doesn't affect the URL
    β”œβ”€β”€ layout.tsx      # Dashboard layout
    β”œβ”€β”€ analytics/
    β”‚   └── page.tsx    # URL: /analytics
    └── settings/
        └── page.tsx    # URL: /settings

Example 1: Different Layouts for Different Sections

// app/(marketing)/layout.tsx
import MarketingHeader from '@/components/MarketingHeader';
import MarketingFooter from '@/components/MarketingFooter';

export default function MarketingLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="marketing-layout">
      <MarketingHeader />
      <main className="marketing-content">
        {children}
      </main>
      <MarketingFooter />
    </div>
  );
}
// app/(dashboard)/layout.tsx
import DashboardSidebar from '@/components/DashboardSidebar';
import DashboardHeader from '@/components/DashboardHeader';

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="dashboard-layout">
      <DashboardHeader />
      <div className="dashboard-container">
        <DashboardSidebar />
        <main className="dashboard-content">
          {children}
        </main>
      </div>
    </div>
  );
}

Example 2: Organization by Functionality

app/
β”œβ”€β”€ (auth)/
β”‚   β”œβ”€β”€ login/
β”‚   β”‚   └── page.tsx     # /login
β”‚   β”œβ”€β”€ register/
β”‚   β”‚   └── page.tsx     # /register
β”‚   └── forgot-password/
β”‚       └── page.tsx     # /forgot-password
β”œβ”€β”€ (public)/
β”‚   β”œβ”€β”€ about/
β”‚   β”‚   └── page.tsx     # /about
β”‚   └── blog/
β”‚       └── [slug]/
β”‚           └── page.tsx # /blog/:slug
└── (protected)/
    β”œβ”€β”€ layout.tsx       # Shared group layout
    β”œβ”€β”€ dashboard/
    β”‚   └── page.tsx     # /dashboard
    └── profile/
        └── page.tsx     # /profile

Note that the (protected) group has no proxy.ts file: there can be only one proxy (middleware.ts in Next.js 15 and earlier), in the project root. You protect these routes with a matcher in the root proxy.ts and by checking the session close to the data.

Example 3: Multiple Root Layouts

Route Groups allow creating multiple root layouts in the same application:

app/
β”œβ”€β”€ (shop)/
β”‚   β”œβ”€β”€ layout.tsx       # Root layout for the shop
β”‚   └── products/
β”‚       └── page.tsx
└── (blog)/
    β”œβ”€β”€ layout.tsx       # Root layout for the blog
    └── posts/
        └── page.tsx
// app/(shop)/layout.tsx
export default function ShopRootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="pl">
      <body className="shop-theme">
        {children}
      </body>
    </html>
  );
}
// app/(blog)/layout.tsx
export default function BlogRootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="pl">
      <body className="blog-theme">
        {children}
      </body>
    </html>
  );
}

Advantages of Route Groups

  1. Code organization - logical grouping without affecting the URL
  2. Different layouts - ability to apply different layouts for different sections
  3. Better project structure - easier management of large applications
  4. Separation of concerns - different teams can work on different groups

Combining All Three Techniques

The true power of the App Router reveals itself when you combine all three techniques together:

app/
β”œβ”€β”€ (marketing)/
β”‚   β”œβ”€β”€ layout.tsx
β”‚   β”œβ”€β”€ page.tsx
β”‚   └── @modal/
β”‚       β”œβ”€β”€ default.tsx
β”‚       └── (.)contact/
β”‚           └── page.tsx
β”œβ”€β”€ (dashboard)/
β”‚   β”œβ”€β”€ layout.tsx
β”‚   β”œβ”€β”€ @analytics/
β”‚   β”‚   β”œβ”€β”€ default.tsx
β”‚   β”‚   └── page.tsx
β”‚   β”œβ”€β”€ @team/
β”‚   β”‚   β”œβ”€β”€ default.tsx
β”‚   β”‚   └── page.tsx
β”‚   └── @activity/
β”‚       β”œβ”€β”€ default.tsx
β”‚       └── page.tsx
└── (shop)/
    β”œβ”€β”€ layout.tsx
    β”œβ”€β”€ products/
    β”‚   └── [id]/
    β”‚       └── page.tsx
    └── @cart/
        β”œβ”€β”€ default.tsx
        └── (.)products/
            └── [id]/
                └── page.tsx

Example: E-commerce with Advanced Routing

// app/(shop)/layout.tsx
export default function ShopLayout({
  children,
  cart,
}: {
  children: React.ReactNode;
  cart: React.ReactNode;
}) {
  return (
    <div className="shop-layout">
      <ShopHeader />
      <main>{children}</main>
      {cart}
      <ShopFooter />
    </div>
  );
}
// app/(shop)/products/[id]/page.tsx
async function getProduct(id: string) {
  const res = await fetch(`https://api.quantum.com/products/${id}`);
  return res.json();
}

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const product = await getProduct((await params).id);

  return (
    <div className="product-page">
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <button>Add to cart</button>
    </div>
  );
}
// app/(shop)/@cart/(.)products/[id]/page.tsx
'use client';

import { use } from 'react';
import { useRouter } from 'next/navigation';

export default function QuickViewModal({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = use(params);
  const router = useRouter();

  return (
    <div className="modal-overlay" onClick={() => router.back()}>
      <div className="quick-view" onClick={(e) => e.stopPropagation()}>
        <ProductQuickView productId={id} />
        <button onClick={() => router.back()}>Close</button>
      </div>
    </div>
  );
}

Both modals are Client Components, so they cannot use await. Since Next.js 15 params is a Promise, and Next.js 16 no longer lets you read it synchronously, which is why we unwrap it with React's use hook.

Best Practices

1. Use Parallel Routes for Independent Sections

// Good - independent panels
app/dashboard/
β”œβ”€β”€ @analytics/
β”œβ”€β”€ @team/
└── @notifications/

// Bad - dependent components
app/dashboard/
β”œβ”€β”€ @header/      // This should be a component, not a slot
β”œβ”€β”€ @footer/      // This should be a component, not a slot
└── @content/

2. Use Intercepting Routes for Modals and Overlays

// Good
app/
β”œβ”€β”€ photos/[id]/page.tsx
└── @modal/(.)photos/[id]/page.tsx

// Bad - modal as a separate route
app/
β”œβ”€β”€ photos/[id]/page.tsx
└── modal/photos/[id]/page.tsx

3. Use Route Groups for Organization

// Good - logical grouping
app/
β”œβ”€β”€ (marketing)/
β”œβ”€β”€ (dashboard)/
└── (auth)/

// Bad - grouping by file type
app/
β”œβ”€β”€ (pages)/
β”œβ”€β”€ (components)/
└── (api)/

Summary

Advanced routing techniques in Next.js 16 are like the advanced transport network of the Quantum Metropolis - they enable creating complex, efficient, and elegant user interfaces:

  1. Parallel Routes - render multiple routes simultaneously for independent sections
  2. Intercepting Routes - intercept navigation for better UX with modals and overlays
  3. Route Groups - organize code without affecting the URL

These techniques give you incredible control over the application structure and user experience. Just as engineers of the Quantum Metropolis design multi-level transport systems for maximum efficiency, you too can design advanced routing structures for optimal UX.

The key to success is understanding when to use each technique and how to combine them to create a cohesive, efficient Next.js 16 application.

Code for this lesson: App.tsx
import React, { useState } from 'react';

// Simulation of app/layout.tsx with the @feed and @stats slots and the intercepting route @modal/(.)photos/[id]
const photos = [
  { id: '1', title: 'Quantum District' },
  { id: '2', title: 'Central Plaza' },
  { id: '3', title: 'Skyline at night' },
];

const panel = { flex: 1, padding: '12px', border: '1px solid #64ffda', borderRadius: '8px' };
const button = { margin: '4px', padding: '6px 10px', background: '#1a1a2e', color: '#64ffda', border: '1px solid #64ffda', borderRadius: '6px', cursor: 'pointer' };

export default function ParallelInterceptingDemo() {
  const [url, setUrl] = useState('/');
  const [modalId, setModalId] = useState<string | null>(null);

  // A Link click (in-app navigation): the route is intercepted by @modal/(.)photos/[id]
  const openPhoto = (id: string) => { setUrl('/photos/' + id); setModalId(id); };
  // A page refresh (F5): no interception, the full page app/photos/[id]/page.tsx renders
  const refresh = () => setModalId(null);
  // router.back(): back to the gallery
  const back = () => { setUrl('/'); setModalId(null); };

  const fullPage = url !== '/' && modalId === null;
  const photo = photos.find((p) => '/photos/' + p.id === url);

  return (
    <div style={{ fontFamily: 'monospace', background: '#0f0f23', color: '#e0e0e0', minHeight: '100vh', padding: '16px' }}>
      <p>Address: <strong>{url}</strong></p>
      {fullPage && photo ? (
        <div style={panel}>
          <h2>Full page: app/photos/[id]/page.tsx</h2>
          <p>{photo.title}</p>
          <button style={button} onClick={back}>Back to the gallery</button>
        </div>
      ) : (
        <div style={{ display: 'flex', gap: '12px' }}>
          <div style={panel}>
            <h3>Slot @feed</h3>
            {photos.map((p) => (
              <button key={p.id} style={button} onClick={() => openPhoto(p.id)}>{p.title}</button>
            ))}
          </div>
          <div style={panel}>
            <h3>Slot @stats</h3>
            <p>Photos in the gallery: {photos.length}</p>
            <p>Both slots render at the same time in the same layout.</p>
          </div>
        </div>
      )}
      {modalId && photo && (
        <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
          <div style={{ ...panel, flex: 'none', background: '#1a1a2e', maxWidth: '320px' }}>
            <h3>Modal from @modal/(.)photos/[id]</h3>
            <p>{photo.title}</p>
            <p>The gallery stayed underneath and the address changed to {url}.</p>
            <button style={button} onClick={back}>Close (router.back)</button>
            <button style={button} onClick={refresh}>Refresh the page (F5)</button>
          </div>
        </div>
      )}
    </div>
  );
}

Docs entries used in this lesson

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What is the default.tsx file used for in Parallel Routes?

  2. 2. Which notation intercepts a route segment one level above?

Hands-on tasks in the game

  • Code editor

    Build a layout with two parallel slots @main and @sidebar: default-export a DashboardLayout({ main, sidebar }) component that displays both slots side by side in one container with display: flex or grid (e.g. a div with style={{ display: 'flex' }} or the flex class).

  • Code editor

    Combine several App Router features in one file, app/products/[id]/page.tsx, step by step: 1) export async function generateMetadata({ params }) returns { title: 'Product ' + id }; 2) export default async function ProductPage({ params }) reads id with await params; 3) if id is not made of digits only, call notFound() from next/navigation; 4) otherwise return a main element with an h1 heading β€œProduct {id}” and a Link component (next/link) that leads to /products.

Useful articles