Back to Blog

Next.js Parallel Routes and Intercepting Routes: Modals, Tabs

Use Next.js parallel routes and intercepting routes for advanced layout patterns. Covers @slot parallel route setup, modal-as-route with intercepting routes, tab navigation within layouts, split-pane dashboards, and soft navigation vs hard navigation behavior.

Viprasol Tech Team
12 min read
Updated 2027

Quick answer. Next.js parallel routes render multiple pages simultaneously in one layout using the @slot folder convention, while intercepting routes show different UI for the same URL based on navigation type, soft navigation shows a modal and a direct refresh shows the full page. Both are App Router-only features. Parallel routes let multiple pages render simultaneously in the same layout — a sidebar and main content, a modal and the page behind it, or a split dashboard. Intercepting routes let you show a different UI for the same URL depending on how you navigate there (soft navigation shows a modal; direct navigation / refresh shows the full page).

Both features are App Router-only and solve problems that previously required complex client-state management.

Parallel Routes: @slot Convention

app/
├── dashboard/
│   ├── layout.tsx           ← Receives @analytics and @activity slots
│   ├── page.tsx             ← Default main content
│   ├── @analytics/
│   │   ├── page.tsx         ← Renders in analytics slot
│   │   └── default.tsx      ← Shown when slot has no active page
│   └── @activity/
│       ├── page.tsx         ← Renders in activity slot
│       └── default.tsx
// app/dashboard/layout.tsx
interface DashboardLayoutProps {
  children:   React.ReactNode;  // Main page (dashboard/page.tsx)
  analytics:  React.ReactNode;  // @analytics slot
  activity:   React.ReactNode;  // @activity slot
}

export default function DashboardLayout({
  children,
  analytics,
  activity,
}: DashboardLayoutProps) {
  return (
    <div className="flex h-screen overflow-hidden">
      {/* Main content area */}
      <div className="flex-1 flex flex-col overflow-hidden">
        <div className="flex-1 overflow-y-auto p-6">
          {children}
        </div>
      </div>

      {/* Right sidebar: analytics + activity stacked */}
      <aside className="w-80 border-l border-gray-200 flex flex-col overflow-hidden">
        <div className="flex-1 overflow-y-auto p-4 border-b border-gray-100">
          {analytics}
        </div>
        <div className="flex-1 overflow-y-auto p-4">
          {activity}
        </div>
      </aside>
    </div>
  );
}
// app/dashboard/@analytics/page.tsx
import { prisma } from "@/lib/prisma";
import { auth } from "@/auth";

export default async function AnalyticsSlot() {
  const session = await auth();
  const stats = await prisma.workspaceStats.findFirst({
    where: { workspaceId: session!.user.workspaceId },
  });

  return (
    <div>
      <h3 className="text-sm font-semibold text-gray-700 mb-3">Analytics</h3>
      {/* Analytics content */}
      <div className="space-y-2">
        <div className="flex justify-between text-sm">
          <span className="text-gray-500">Active projects</span>
          <span className="font-medium">{stats?.activeProjects ?? 0}</span>
        </div>
        <div className="flex justify-between text-sm">
          <span className="text-gray-500">Tasks completed</span>
          <span className="font-medium">{stats?.tasksCompleted ?? 0}</span>
        </div>
      </div>
    </div>
  );
}

// app/dashboard/@analytics/default.tsx
// Required: shown when navigating to a nested route that doesn't have
// a matching @analytics segment (prevents 404 on slot)
export default function AnalyticsDefault() {
  return null; // Slot stays empty during navigation to other pages
}

Intercepting Routes: Modals as Routes

The canonical use case: a photo gallery where clicking a photo shows it in a modal, but /photos/123 navigated directly shows the full photo page.

app/
├── photos/
│   ├── page.tsx              ← Photo grid (hard navigation destination)
│   ├── [id]/
│   │   └── page.tsx          ← Full photo page (hard navigation)
│   └── @modal/
│       ├── default.tsx       ← null (no modal by default)
│       └── (.)photos/        ← Intercept /photos/[id]
│           └── [id]/
│               └── page.tsx  ← Modal photo view (soft navigation)
// app/photos/layout.tsx — receives @modal slot
export default function PhotosLayout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal:    React.ReactNode;
}) {
  return (
    <>
      {children}
      {modal}  {/* Renders the intercepting modal, or null from default.tsx */}
    </>
  );
}

// app/photos/@modal/default.tsx
export default function PhotoModalDefault() {
  return null;  // No modal when not intercepting
}
// app/photos/@modal/(.)photos/[id]/page.tsx
// The (.) prefix means "intercept routes at the same level"
// (..) = one level up, (...) = root level

import { prisma } from "@/lib/prisma";
import { PhotoModal } from "@/components/photos/photo-modal";

export default async function PhotoModalIntercepted({
  params,
}: {
  params: { id: string };
}) {
  const photo = await prisma.photo.findUniqueOrThrow({
    where: { id: params.id },
  });

  return <PhotoModal photo={photo} />;
}
// components/photos/photo-modal.tsx — closes on backdrop click or Escape
"use client";

import { useRouter } from "next/navigation";
import { useEffect, useCallback } from "react";
import { X } from "lucide-react";

interface PhotoModalProps {
  photo: { id: string; url: string; caption: string | null };
}

export function PhotoModal({ photo }: PhotoModalProps) {
  const router = useRouter();

  const close = useCallback(() => {
    router.back();  // Navigate back — removes the intercepting route
  }, [router]);

  useEffect(() => {
    function handleEscape(e: KeyboardEvent) {
      if (e.key === "Escape") close();
    }
    document.addEventListener("keydown", handleEscape);
    return () => document.removeEventListener("keydown", handleEscape);
  }, [close]);

  return (
    <>
      {/* Backdrop */}
      <div
        className="fixed inset-0 bg-black/70 z-50 backdrop-blur-sm"
        onClick={close}
        aria-hidden="true"
      />
      {/* Modal */}
      <div
        className="fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 max-w-3xl w-full px-4"
        role="dialog"
        aria-modal="true"
      >
        <div className="bg-white rounded-2xl overflow-hidden shadow-2xl">
          <div className="relative">
            <img
              src={photo.url}
              alt={photo.caption ?? "Photo"}
              className="w-full object-contain max-h-[70vh]"
            />
            <button
              onClick={close}
              className="absolute top-3 right-3 bg-black/50 text-white p-1.5 rounded-full hover:bg-black/70"
              aria-label="Close"
            >
              <X className="w-5 h-5" />
            </button>
          </div>
          {photo.caption && (
            <div className="px-5 py-3">
              <p className="text-sm text-gray-700">{photo.caption}</p>
            </div>
          )}
          {/* Link to full page (hard navigation) */}
          <div className="px-5 pb-4">
            <a
              href={`/photos/${photo.id}`}
              className="text-xs text-blue-600 hover:underline"
            >
              Open full page →
            </a>
          </div>
        </div>
      </div>
    </>
  );
}

🌐 Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different — senior engineers only, direct Slack access, and a 5.0★ Upwork record across 1000+ projects.

  • React, Next.js, Node.js, TypeScript — production-grade stack
  • Fixed-price contracts — no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Tab Navigation Within a Layout

app/
├── settings/
│   ├── layout.tsx           ← Renders tab nav + @content slot
│   ├── @content/
│   │   ├── default.tsx
│   │   ├── profile/
│   │   │   └── page.tsx
│   │   ├── billing/
│   │   │   └── page.tsx
│   │   └── members/
│   │       └── page.tsx
│   └── page.tsx             ← Redirects to /settings/profile
// app/settings/layout.tsx
import Link from "next/link";

const TABS = [
  { href: "/settings/profile",  label: "Profile" },
  { href: "/settings/billing",  label: "Billing" },
  { href: "/settings/members",  label: "Members" },
  { href: "/settings/security", label: "Security" },
];

// Note: @content slot allows each tab to be a separate Server Component
// that loads independently — the layout doesn't re-render between tabs
export default function SettingsLayout({
  children,
  content,
}: {
  children: React.ReactNode;
  content:  React.ReactNode;
}) {
  return (
    <div className="max-w-4xl mx-auto py-8 px-4">
      <h1 className="text-2xl font-bold text-gray-900 mb-6">Settings</h1>

      {/* Tab navigation */}
      <nav className="flex gap-1 border-b border-gray-200 mb-6">
        {TABS.map((tab) => (
          <Link
            key={tab.href}
            href={tab.href}
            className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300 transition-colors"
          >
            {tab.label}
          </Link>
        ))}
      </nav>

      {/* Tab content */}
      {content ?? children}
    </div>
  );
}

// app/settings/page.tsx — redirect to default tab
import { redirect } from "next/navigation";
export default function SettingsPage() {
  redirect("/settings/profile");
}

Soft vs Hard Navigation Behavior

// Key mental model:
// - Soft navigation (Link click, router.push): intercepting routes ACTIVATE
//   → @modal slot gets the intercepting page, URL updates
//   → Refresh or back → modal closes, @modal returns to default.tsx
// - Hard navigation (direct URL, page refresh): intercepting routes BYPASS
//   → Goes directly to /photos/[id]/page.tsx (full photo page)

// Photos grid — Link creates soft navigation (triggers interception)
// components/photos/photo-grid.tsx
import Link from "next/link";

export function PhotoGrid({ photos }: { photos: Photo[] }) {
  return (
    <div className="grid grid-cols-3 gap-3">
      {photos.map((photo) => (
        <Link
          key={photo.id}
          href={`/photos/${photo.id}`}  // Intercepted by @modal slot on soft nav
          className="aspect-square overflow-hidden rounded-lg"
        >
          <img
            src={photo.thumbnailUrl}
            alt={photo.caption ?? ""}
            className="w-full h-full object-cover hover:scale-105 transition-transform"
          />
        </Link>
      ))}
    </div>
  );
}
Next.js - Next.js Parallel Routes and Intercepting Routes: Modals, Tabs

🚀 Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4–8 weeks, full platforms in 3–5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Pricing and Delivery Estimates

ScopeTeamTimelineCost Range
Parallel routes (2 slots)1 dev1–2 days$400–800
Intercepting routes (modal pattern)1 dev2–3 days$600–1,200
Tab layout with @content slot1 dev1 day$300–600

Explore More


How Viprasol Helps

Parallel and intercepting routes are two of the most powerful and underused App Router features. Our team implements photo-gallery modal patterns (soft nav → modal, hard nav → full page), split dashboard layouts with independent slot loading, and tab navigation where each tab is a separate Server Component — without any client-state management for routing.

What we deliver:

  • @analytics + @activity parallel slots in a dashboard layout with default.tsx fallbacks
  • Intercepting route structure: @modal/(.)photos/[id]/page.tsx for modal-as-route
  • PhotoModal client component: router.back() close, Escape handler, backdrop click, "Open full page" link
  • Settings layout: @content slot with tab <nav>, default redirect from /settings/settings/profile
  • Soft vs hard navigation explanation: intercepting activates on soft nav, bypassed on refresh/direct URL

Talk to our team about your Next.js layout architecture →

Or explore our web development services.

Next.jsApp RouterParallel RoutesTypeScriptRoutingModalsLayouts
Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms — we build it all with Next.js and React.

Free consultation • No commitment • Response within 24 hours

Viprasol · Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse ≥90 scores, mobile-first design, and full source code ownership. Senior engineers only — from architecture through deployment.