Back to Blog

Next.js Parallel Routes and Intercepting Routes: Modals, Tabs, and Split-View Layouts

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
June 1, 2027
12 min read

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, and Split-View Layouts

🚀 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.