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.
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
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Parallel routes (2 slots) | 1 dev | 1โ2 days | $400โ800 |
| Intercepting routes (modal pattern) | 1 dev | 2โ3 days | $600โ1,200 |
| Tab layout with @content slot | 1 dev | 1 day | $300โ600 |
See Also
- Next.js App Router Caching Strategies
- Next.js Server Components Patterns
- React Modal Patterns
- Next.js Middleware Auth Patterns
- Next.js Streaming Responses
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+@activityparallel slots in a dashboard layout withdefault.tsxfallbacks- Intercepting route structure:
@modal/(.)photos/[id]/page.tsxfor modal-as-route PhotoModalclient component:router.back()close, Escape handler, backdrop click, "Open full page" link- Settings layout:
@contentslot 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.
About the Author
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.
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
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.