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 100+ 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>
  );
}

๐Ÿš€ 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

Cost and Timeline 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

See Also


Working With Viprasol

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.

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 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

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.