DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Next.js 14 parallel routes and intercepting routes: the patterns that unlock complex UIs

Parallel routes and intercepting routes are the most underused features in Next.js 14. They solve two problems that every complex app hits: showing multiple independent views simultaneously and rendering modals without losing page context.

Parallel routes: multiple views, one layout

A parallel route renders a different segment of the page independently. Think: a dashboard where the left panel, main content, and sidebar all load and update separately.

app/
  layout.tsx          # Wraps all three slots
  @analytics/page.tsx # Left panel
  @main/page.tsx      # Main content  
  @sidebar/page.tsx   # Right sidebar
Enter fullscreen mode Exit fullscreen mode
// app/layout.tsx
export default function Layout({
  analytics,
  main,
  sidebar,
}: {
  analytics: React.ReactNode;
  main: React.ReactNode;
  sidebar: React.ReactNode;
}) {
  return (
    <div className="grid grid-cols-[250px_1fr_300px] h-screen">
      <div>{analytics}</div>
      <div>{main}</div>
      <div>{sidebar}</div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Each slot (@analytics, @main, @sidebar) is a parallel route. They load independently — if the sidebar takes 3 seconds, the main content doesn't wait. Each slot has its own loading and error states.

Why this matters: Without parallel routes, slow data in one section blocks the entire page. With them, each section streams independently. The user sees content as fast as each section can deliver it.

The loading pattern

// app/@analytics/loading.tsx
export default function AnalyticsLoading() {
  return <div className="animate-pulse h-full bg-gray-800/50 rounded" />;
}

// app/@main/loading.tsx  
export default function MainLoading() {
  return <Skeleton className="h-full" />;
}
Enter fullscreen mode Exit fullscreen mode

Each slot gets its own loading.tsx. The result: progressive rendering where each panel appears when ready. No spinner for the entire page.

Intercepting routes: modals done right

This is the killer feature. Intercepting routes let you render a route as a modal while keeping the current page visible underneath.

app/
  feed/
    page.tsx           # The feed
  photos/
    [id]/
      page.tsx         # Full photo page (direct navigation)
  feed/
    (.)photos/[id]/
      page.tsx         # Photo modal (intercepted from feed)
Enter fullscreen mode Exit fullscreen mode

The (.) prefix means "intercept this route from the current level." When a user clicks a photo link from the feed, instead of navigating away, it opens as a modal overlay. The feed stays visible underneath.

// app/feed/(.)photos/[id]/page.tsx — the modal version
import { Modal } from '@/components/modal';

export default async function PhotoModal({ params }: { params: { id: string } }) {
  const photo = await getPhoto(params.id);

  return (
    <Modal>
      <img src={photo.url} alt={photo.title} className="max-h-[80vh]" />
      <h2>{photo.title}</h2>
    </Modal>
  );
}
Enter fullscreen mode Exit fullscreen mode
// app/photos/[id]/page.tsx — the full page version (direct URL or refresh)
export default async function PhotoPage({ params }: { params: { id: string } }) {
  const photo = await getPhoto(params.id);

  return (
    <div className="max-w-4xl mx-auto py-8">
      <img src={photo.url} alt={photo.title} />
      <h2 className="text-2xl mt-4">{photo.title}</h2>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The UX result:

  • Click photo from feed → modal opens, feed visible behind, URL changes to /photos/123
  • Share that URL → friend opens it → full page renders (not a modal)
  • Refresh → full page renders
  • Close modal → back to feed, URL reverts

This is how Instagram, Twitter, and Pinterest work. Now you can build it with zero client-side routing code.

Interception levels

(.)   — same level
(..)  — one level up
(..)(..) — two levels up
(...)  — from the root
Enter fullscreen mode Exit fullscreen mode

For a typical e-commerce app:

app/
  products/
    page.tsx                    # Product listing
    (.)products/[id]/page.tsx   # Quick-view modal from listing
  products/
    [id]/
      page.tsx                  # Full product page
Enter fullscreen mode Exit fullscreen mode

Combining parallel routes with interception

The real power: use parallel routes for the modal slot.

app/
  layout.tsx
  @modal/
    default.tsx    # Returns null when no modal is active
    (.)photos/[id]/
      page.tsx     # The modal content
  feed/
    page.tsx       # The feed
Enter fullscreen mode Exit fullscreen mode
// app/layout.tsx
export default function Layout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <>
      {children}
      {modal}
    </>
  );
}

// app/@modal/default.tsx
export default function Default() {
  return null; // No modal by default
}
Enter fullscreen mode Exit fullscreen mode

The @modal slot is empty by default. When a photo link is clicked, the intercepted route fills the slot with the modal. The underlying page stays rendered.

When to use each pattern

Parallel routes when:

  • Dashboard with independent panels
  • Split views (email client: inbox + message)
  • Multi-step wizards where steps have independent data needs
  • Any layout where sections should load/error independently

Intercepting routes when:

  • Modals that should be shareable URLs
  • Quick-view/preview overlays
  • Image/media lightboxes
  • "Edit" overlays that keep the list visible

Both together when:

  • Complex apps with modal systems (social media, e-commerce)
  • Admin dashboards with detail panels

These patterns eliminate 90% of the client-side modal state management code in a typical React app. No useState(isOpen), no portal management, no URL sync logic.

The AI SaaS Starter Kit uses intercepting routes for the subscription management modal — users can share the billing URL directly, but opening it from the dashboard shows it as an overlay.

Top comments (0)