Next.js

Parallel & Intercepting Routes

25 min Lesson 31 of 40

Introduction to Parallel & Intercepting Routes

Next.js 13+ introduces powerful routing patterns that allow you to display multiple pages simultaneously and intercept navigation between routes. These advanced patterns enable sophisticated UI behaviors like modals, split views, and complex dashboards.

Parallel Routes

Parallel routes allow you to render multiple pages in the same layout simultaneously. This is particularly useful for dashboards, split views, and complex UI patterns where different sections need independent loading states and error handling.

Slots: Parallel routes are defined using named slots, which are convention-based folders starting with the @ symbol (e.g., @team, @analytics).

Basic Parallel Routes Structure

app/
  dashboard/
    @team/              # Named slot for team view
      page.tsx
    @analytics/         # Named slot for analytics view
      page.tsx
    layout.tsx          # Parent layout receiving both slots
    page.tsx            # Default page

Implementing Parallel Routes

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  team,      // @team slot
  analytics  // @analytics slot
}: {
  children: React.ReactNode;
  team: React.ReactNode;
  analytics: React.ReactNode;
}) {
  return (
    <div className="dashboard-layout">
      <div className="sidebar">{children}</div>
      <div className="main-content">
        <div className="team-section">{team}</div>
        <div className="analytics-section">{analytics}</div>
      </div>
    </div>
  );
}
// app/dashboard/@team/page.tsx
export default async function TeamSlot() {
  const teamData = await fetchTeamData();

  return (
    <div>
      <h2>Team Overview</h2>
      <ul>
        {teamData.map(member => (
          <li key={member.id}>{member.name}</li>
        ))}
      </ul>
    </div>
  );
}

// Independent loading state
export default function Loading() {
  return <div>Loading team data...</div>;
}
// app/dashboard/@analytics/page.tsx
export default async function AnalyticsSlot() {
  const analytics = await fetchAnalytics();

  return (
    <div>
      <h2>Analytics Dashboard</h2>
      <div className="stats">
        <div>Views: {analytics.views}</div>
        <div>Users: {analytics.users}</div>
      </div>
    </div>
  );
}

Tip: Each parallel route can have its own loading.tsx, error.tsx, and not-found.tsx files, providing granular control over UI states.

Default Files for Parallel Routes

When navigating to a route that doesn't match all slots, Next.js needs to know what to render. Use default.tsx files to handle this:

app/
  dashboard/
    @team/
      page.tsx
      default.tsx       # Fallback for @team slot
    @analytics/
      page.tsx
      default.tsx       # Fallback for @analytics slot
    layout.tsx
// app/dashboard/@team/default.tsx
export default function DefaultTeam() {
  return <div>Select a team to view details</div>;
}

Intercepting Routes

Intercepting routes allow you to load a route from another part of your application within the current layout. This is perfect for modals, previews, and maintaining context while navigating.

Convention: Intercepting routes use special folder naming conventions:

  • (.) - Same level
  • (..) - One level up
  • (..)(..) - Two levels up
  • (...) - From root

Modal Pattern with Intercepting Routes

app/
  photos/
    page.tsx              # Photo gallery
    [id]/
      page.tsx            # Full photo page
  @modal/
    (.)photos/
      [id]/
        page.tsx          # Intercepted modal view
    default.tsx
  layout.tsx
// app/layout.tsx
export default function RootLayout({
  children,
  modal
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <html>
      <body>
        {children}
        {modal}
      </body>
    </html>
  );
}
// app/photos/page.tsx
import Link from 'next/link';

export default function PhotoGallery() {
  const photos = [1, 2, 3, 4, 5];

  return (
    <div className="gallery">
      <h1>Photo Gallery</h1>
      <div className="grid">
        {photos.map(id => (
          <Link key={id} href={`/photos/${id}`}>
            <img src={`/photo-${id}.jpg`} alt={`Photo ${id}`} />
          </Link>
        ))}
      </div>
    </div>
  );
}
// app/@modal/(.)photos/[id]/page.tsx
import { Modal } from '@/components/Modal';

export default function PhotoModal({ params }: { params: { id: string } }) {
  return (
    <Modal>
      <img
        src={`/photo-${params.id}.jpg`}
        alt={`Photo ${params.id}`}
        className="modal-image"
      />
    </Modal>
  );
}
// components/Modal.tsx
'use client';

import { useRouter } from 'next/navigation';
import { useEffect, useRef } from 'react';

export function Modal({ children }: { children: React.ReactNode }) {
  const router = useRouter();
  const dialogRef = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    dialogRef.current?.showModal();
  }, []);

  const closeModal = () => {
    router.back();
  };

  return (
    <dialog
      ref={dialogRef}
      className="modal"
      onClose={closeModal}
      onClick={(e) => {
        if (e.target === dialogRef.current) {
          closeModal();
        }
      }}
    >
      <button onClick={closeModal} className="close-btn">
        Close
      </button>
      {children}
    </dialog>
  );
}

Default Modal Slot

// app/@modal/default.tsx
export default function DefaultModal() {
  return null; // Don't show modal by default
}

Advanced Parallel Routes Patterns

Conditional Slot Rendering

// app/dashboard/layout.tsx
export default async function DashboardLayout({
  children,
  team,
  analytics
}: {
  children: React.ReactNode;
  team: React.ReactNode;
  analytics: React.ReactNode;
}) {
  const session = await getSession();
  const isAdmin = session?.role === 'admin';

  return (
    <div className="dashboard">
      {children}
      {isAdmin ? (
        <>
          <div className="slot">{team}</div>
          <div className="slot">{analytics}</div>
        </>
      ) : (
        <div className="slot">{team}</div>
      )}
    </div>
  );
}

Tab-Based Navigation with Parallel Routes

app/
  profile/
    @tabs/
      posts/
        page.tsx
      followers/
        page.tsx
      following/
        page.tsx
      default.tsx
    layout.tsx
    page.tsx
// app/profile/layout.tsx
'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';

export default function ProfileLayout({
  children,
  tabs
}: {
  children: React.ReactNode;
  tabs: React.ReactNode;
}) {
  const pathname = usePathname();

  return (
    <div>
      {children}
      <nav className="tabs">
        <Link
          href="/profile/posts"
          className={pathname === '/profile/posts' ? 'active' : ''}
        >
          Posts
        </Link>
        <Link
          href="/profile/followers"
          className={pathname === '/profile/followers' ? 'active' : ''}
        >
          Followers
        </Link>
        <Link
          href="/profile/following"
          className={pathname === '/profile/following' ? 'active' : ''}
        >
          Following
        </Link>
      </nav>
      <div className="tab-content">{tabs}</div>
    </div>
  );
}

Combining Parallel and Intercepting Routes

app/
  feed/
    page.tsx
    [id]/
      page.tsx           # Full post page
  @modal/
    (.)feed/
      [id]/
        page.tsx         # Intercepted modal
    default.tsx
  layout.tsx
// app/feed/page.tsx
import Link from 'next/link';

export default function Feed() {
  const posts = await getPosts();

  return (
    <div className="feed">
      {posts.map(post => (
        <Link key={post.id} href={`/feed/${post.id}`}>
          <article>
            <h2>{post.title}</h2>
            <p>{post.excerpt}</p>
          </article>
        </Link>
      ))}
    </div>
  );
}

Warning: When using intercepting routes with parallel routes, be careful with route matching. Hard refreshes will always show the non-intercepted route.

Slot-Based Layouts Best Practices

Independent Error Boundaries

// app/dashboard/@analytics/error.tsx
'use client';

export default function AnalyticsError({
  error,
  reset
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div className="error-container">
      <h2>Analytics Failed to Load</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try Again</button>
    </div>
  );
}

Slot Navigation Utilities

// lib/parallel-routes.ts
export function createSlotUrl(base: string, slots: Record<string, string>) {
  const params = new URLSearchParams();
  Object.entries(slots).forEach(([key, value]) => {
    params.set(key, value);
  });
  return `${base}?${params.toString()}`;
}

// Usage
const url = createSlotUrl('/dashboard', {
  team: 'engineering',
  analytics: 'weekly'
});
// Result: /dashboard?team=engineering&analytics=weekly

Exercise: Build a Product Gallery with Modal Preview

Create a product gallery that:

  1. Displays products in a grid layout
  2. Opens a modal when clicking a product (intercepting route)
  3. Allows direct navigation to full product page
  4. Uses parallel routes for reviews and related products
  5. Implements independent loading states for each slot

Bonus: Add URL state management so the modal can be shared via link.

Performance Considerations

  • Streaming: Parallel routes can stream independently, improving perceived performance
  • Code Splitting: Each slot is automatically code-split
  • Suspense Boundaries: Wrap slots in Suspense for better loading UX
  • Preloading: Use Link prefetch for anticipated navigation

Tip: Use parallel routes for dashboard-style layouts where multiple data sources need to load independently. This prevents one slow query from blocking the entire page.