Introduction
The Next.js App Router introduced a paradigm shift in how developers structure and navigate between pages in React applications. Among its most powerful features are parallel routes and intercepting routes ā two patterns that enable sophisticated UI layouts like dashboards with multiple simultaneous panels, modal overlays that preserve context, and photo galleries that expand in-place without losing scroll position.
Before the App Router, achieving these patterns required complex client-side state management, custom routing logic, or third-party libraries. Now, Next.js provides these capabilities natively through the filesystem convention with the @folder syntax for parallel routes and the (.)folder convention for intercepting routes.
This guide dives deep into both patterns, showing you how to build real-world applications like Facebook-style photo modals, dashboard layouts with independent loading states, and authentication flows that don't disrupt the user experience.
Understanding Parallel Routes: Core Concepts
Parallel routes allow you to render multiple pages in the same layout simultaneously. They are defined using named slots, which are folders prefixed with the @ symbol. Each slot receives its own page.tsx file and loads independently, enabling streaming and independent error/loading states.
How Slots Work
In the App Router, a layout can accept props corresponding to each defined slot. If your app/layout.tsx defines two slots ā @analytics and @team ā the layout component receives both as React node props:
// app/layout.tsx
export default function Layout({
children,
analytics,
team,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
team: React.ReactNode;
}) {
return (
<div className="dashboard">
<main>{children}</main>
<aside>{analytics}</aside>
<section>{team}</section>
</div>
);
}Each slot has its own directory:
app/
āāā @analytics/
ā āāā page.tsx # renders at / alongside children
āāā @team/
ā āāā page.tsx # renders at / alongside children
āāā layout.tsx # receives analytics and team as props
āāā page.tsx # renders as "children"
Independent Loading and Error States
The real power of parallel routes lies in their ability to independently handle loading and error states. Each slot can define its own loading.tsx and error.tsx:
// app/@analytics/loading.tsx
export default function AnalyticsLoading() {
return (
<div className="animate-pulse bg-gray-200 rounded-lg h-64">
<p className="p-4 text-gray-500">Loading analytics...</p>
</div>
);
}
// app/@team/loading.tsx
export default function TeamLoading() {
return (
<div className="animate-pulse bg-gray-200 rounded-lg h-64">
<p className="p-4 text-gray-500">Loading team data...</p>
</div>
);
}This means if the analytics API is slow, the team section still renders immediately ā they stream independently.
Conditional Slots
You can conditionally render slots based on authentication, feature flags, or other runtime logic. If a slot returns null, it simply doesn't render:
// app/@auth/login/page.tsx
import { cookies } from "next/headers";
export default async function AuthSlot() {
const cookieStore = await cookies();
const session = cookieStore.get("session");
if (!session) {
return <LoginForm />;
}
return null; // Don't render auth slot when logged in
}Understanding Intercepting Routes: Core Concepts
Intercepting routes allow you to load a route from another part of your app within the current layout. This is the pattern behind Instagram or Facebook's photo feed ā clicking a photo opens a modal overlay, but refreshing the page shows the full photo page.
The Convention
Next.js uses folder prefixes to define interception scope:
| Syntax | Scope |
|---|---|
(.)folder | Intercept sibling routes (same level) |
(..)folder | Intercept one level above |
(..)(..)folder | Intercept two levels above |
(...)folder | Intercept from the root app directory |
For example, in a photo gallery:
app/
āāā @modal/
ā āāā (..)photos/
ā āāā [id]/
ā āāā page.tsx # modal version
āāā photos/
ā āāā page.tsx # gallery listing
ā āāā [id]/
ā āāā page.tsx # full page version
āāā layout.tsx
When a user clicks a photo from /photos, the (..)photos/[id] route intercepts it and renders in the @modal slot as an overlay. If the user navigates directly to /photos/123, the full page version at photos/[id]/page.tsx renders instead.
Combining Both Patterns
Intercepting routes become truly powerful when combined with parallel routes. The intercepted route renders in a separate slot (the modal), while the underlying page remains visible underneath:
// app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html>
<body>
{children}
{modal}
</body>
</html>
);
}Architecture and Design Patterns
Pattern 1: Dashboard with Multiple Panels
The most common use case for parallel routes is a dashboard layout where different data panels load independently:
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
metrics,
activity,
notifications,
}: {
children: React.ReactNode;
metrics: React.ReactNode;
activity: React.ReactNode;
notifications: React.ReactNode;
}) {
return (
<div className="grid grid-cols-12 gap-4 p-6">
<div className="col-span-8">
{children}
<div className="grid grid-cols-2 gap-4 mt-4">
{metrics}
{activity}
</div>
</div>
<aside className="col-span-4">
{notifications}
</aside>
</div>
);
}Each slot fetches its own data independently using server components:
// app/dashboard/@metrics/page.tsx
import { getMetrics } from "@/lib/analytics";
export default async function MetricsPanel() {
const metrics = await getMetrics();
return (
<div className="bg-white rounded-xl shadow p-6">
<h2 className="text-lg font-semibold mb-4">Key Metrics</h2>
<div className="grid grid-cols-2 gap-4">
<MetricCard label="Revenue" value={`$${metrics.revenue}`} />
<MetricCard label="Users" value={metrics.activeUsers} />
<MetricCard label="Conversion" value={`${metrics.conversionRate}%`} />
<MetricCard label="Avg Session" value={`${metrics.avgSession}m`} />
</div>
</div>
);
}Pattern 2: Photo Gallery with Modal Overlay
This is the classic Facebook/Instagram pattern where clicking a photo opens a modal but direct navigation shows the full page:
// app/photos/[id]/page.tsx (full page version)
import { getPhoto } from "@/lib/photos";
import { notFound } from "next/navigation";
export default async function PhotoPage({ params }: { params: { id: string } }) {
const photo = await getPhoto(params.id);
if (!photo) notFound();
return (
<div className="max-w-4xl mx-auto p-8">
<img src={photo.url} alt={photo.title} className="w-full rounded-lg" />
<h1 className="text-2xl font-bold mt-4">{photo.title}</h1>
<p className="text-gray-600 mt-2">{photo.description}</p>
<div className="mt-6 flex items-center gap-4">
<img src={photo.author.avatar} className="w-10 h-10 rounded-full" />
<span className="font-medium">{photo.author.name}</span>
</div>
</div>
);
}// app/@modal/(..)photos/[id]/page.tsx (modal version)
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function PhotoModal({ params }: { params: { id: string } }) {
const router = useRouter();
useEffect(() => {
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = ""; };
}, []);
return (
<div
className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center"
onClick={() => router.back()}
>
<div
className="bg-white rounded-xl max-w-3xl w-full mx-4 p-4"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => router.back()}
className="absolute top-4 right-4 text-white text-2xl"
>
ā
</button>
<PhotoDetail id={params.id} />
</div>
</div>
);
}Pattern 3: Authentication Flow
Parallel routes excel at conditionally showing authentication UI without redirecting users:
// app/layout.tsx
export default function RootLayout({
children,
auth,
}: {
children: React.ReactNode;
auth: React.ReactNode;
}) {
return (
<html>
<body>
{children}
{auth}
</body>
</html>
);
}
// app/@auth/(.)login/page.tsx
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
export default function LoginModal() {
const router = useRouter();
const [loading, setLoading] = useState(false);
async function handleLogin(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
// ... authentication logic
router.refresh();
}
return (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center">
<div className="bg-white p-8 rounded-xl shadow-xl max-w-md w-full">
<h2 className="text-2xl font-bold mb-6">Sign In</h2>
<form onSubmit={handleLogin} className="space-y-4">
<input type="email" placeholder="Email" className="w-full p-3 border rounded-lg" />
<input type="password" placeholder="Password" className="w-full p-3 border rounded-lg" />
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white p-3 rounded-lg hover:bg-blue-700"
>
{loading ? "Signing in..." : "Sign In"}
</button>
</form>
</div>
</div>
);
}Step-by-Step Implementation
Let's build a complete photo gallery application with both parallel and intercepting routes.
Step 1: Project Setup
npx create-next-app@latest photo-gallery --typescript --tailwind --app
cd photo-galleryStep 2: Create the Data Layer
// lib/photos.ts
export interface Photo {
id: string;
title: string;
url: string;
description: string;
author: {
name: string;
avatar: string;
};
createdAt: string;
}
const photos: Photo[] = [
{
id: "1",
title: "Mountain Sunrise",
url: "/photos/mountain.jpg",
description: "Golden light breaking over the peaks at dawn.",
author: { name: "Alice", avatar: "/avatars/alice.jpg" },
createdAt: "2024-01-15",
},
// ... more photos
];
export async function getPhotos(): Promise<Photo[]> {
return photos;
}
export async function getPhoto(id: string): Promise<Photo | undefined> {
return photos.find((p) => p.id === id);
}Step 3: Build the Gallery Page
// app/photos/page.tsx
import Link from "next/link";
import { getPhotos } from "@/lib/photos";
export default async function PhotosPage() {
const photos = await getPhotos();
return (
<div className="grid grid-cols-3 gap-4 p-8">
{photos.map((photo) => (
<Link
key={photo.id}
href={`/photos/${photo.id}`}
className="group relative aspect-square overflow-hidden rounded-lg"
>
<img
src={photo.url}
alt={photo.title}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
<p className="absolute bottom-4 left-4 text-white font-medium">{photo.title}</p>
</div>
</Link>
))}
</div>
);
}Step 4: Create the Full Page Photo View
// app/photos/[id]/page.tsx
import { getPhoto, getPhotos } from "@/lib/photos";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
export async function generateStaticParams() {
const photos = await getPhotos();
return photos.map((photo) => ({ id: photo.id }));
}
export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
const photo = await getPhoto(params.id);
if (!photo) return {};
return { title: photo.title, description: photo.description };
}
export default async function PhotoPage({ params }: { params: { id: string } }) {
const photo = await getPhoto(params.id);
if (!photo) notFound();
return (
<article className="max-w-4xl mx-auto p-8">
<img src={photo.url} alt={photo.title} className="w-full rounded-xl" />
<h1 className="text-3xl font-bold mt-6">{photo.title}</h1>
<p className="text-gray-600 mt-3 text-lg">{photo.description}</p>
<div className="flex items-center gap-3 mt-6 pt-6 border-t">
<img src={photo.author.avatar} className="w-12 h-12 rounded-full" />
<div>
<p className="font-semibold">{photo.author.name}</p>
<p className="text-sm text-gray-500">{photo.createdAt}</p>
</div>
</div>
</article>
);
}Step 5: Create the Modal Version (Intercepted Route)
// app/@modal/(..)photos/[id]/page.tsx
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
export default function PhotoModal({ params }: { params: { id: string } }) {
const router = useRouter();
const [photo, setPhoto] = useState<any>(null);
useEffect(() => {
document.body.style.overflow = "hidden";
fetch(`/api/photos/${params.id}`)
.then((res) => res.json())
.then(setPhoto);
return () => { document.body.style.overflow = ""; };
}, [params.id]);
if (!photo) return null;
return (
<div
className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4"
onClick={() => router.back()}
>
<div
className="bg-white rounded-2xl max-w-3xl w-full max-h-[90vh] overflow-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="relative">
<img src={photo.url} alt={photo.title} className="w-full rounded-t-2xl" />
<button
onClick={() => router.back()}
className="absolute top-4 right-4 bg-white/90 rounded-full w-10 h-10 flex items-center justify-center shadow hover:bg-white"
>
ā
</button>
</div>
<div className="p-6">
<h2 className="text-xl font-bold">{photo.title}</h2>
<p className="text-gray-600 mt-2">{photo.description}</p>
</div>
</div>
</div>
);
}Step 6: Wire Up the Layout
// app/@modal/default.tsx
export default function Default() {
return null;
}
// app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html lang="en">
<body className="min-h-screen bg-gray-50">
{children}
{modal}
</body>
</html>
);
}Real-World Use Cases
Use Case 1: SaaS Dashboard with Real-Time Panels
A project management tool like Linear or Notion uses parallel routes to render a sidebar navigation, main content area, and detail panel simultaneously. Each panel streams independently ā if the activity feed takes 3 seconds to load, the task list and metrics still render immediately. The @sidebar, @content, and @detail slots each define their own loading.tsx with skeleton UIs, preventing the entire page from showing a spinner.
Use Case 2: E-Commerce Product Quick View
When browsing a product catalog, clicking a product card can intercept the route and render a quick-view modal with price, images, and an "Add to Cart" button. The URL updates to /products/123 for SEO and sharing purposes. If the user refreshes, they see the full product page instead of the modal. This is identical to how Amazon's "Quick Look" feature works.
Use Case 3: Multi-Step Form Wizard
A complex onboarding flow can use parallel routes to show the form wizard alongside a progress indicator and contextual help panel. The @wizard, @progress, and @help slots render independently, and each step in the wizard can update its own route without causing the help panel to re-render or lose its scroll position.
Best Practices for Production
-
Always define a
default.tsxfor modal slots: When navigating to a route that doesn't match the intercept pattern, the slot needs a fallback. Withoutdefault.tsx, Next.js throws a 404 error. -
Use
router.back()for modal dismissal: This ensures the browser history stays clean. Users expect the back button to close the modal, not navigate away from the page. -
Prevent body scroll when modals are open: Set
document.body.style.overflow = "hidden"in auseEffectcleanup to prevent the background from scrolling while the modal is visible. -
Keep intercepted routes lightweight: Since the modal version overlays the current page, avoid heavy data fetching. Use a summary endpoint or pass data via search params when possible.
-
Add
generateStaticParamsfor static intercepted routes: This enables static generation for both the full-page and modal versions, improving performance significantly. -
Handle the "not found" case in both versions: Both the full page and modal versions should gracefully handle missing data ā the full page uses
notFound(), while the modal should close and redirect. -
Use
Suspenseboundaries within slots: Even though slots stream independently, wrapping expensive queries inSuspensegives users immediate feedback within each panel. -
Test direct navigation AND intercepted navigation: The same URL must work correctly both as a full page load (direct URL visit) and as an intercepted route (clicking a link). Write integration tests covering both paths.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
Missing default.tsx in modal slot | 404 error when intercept fails | Always create app/@modal/default.tsx returning null |
Forgetting use client on modal components | Build error ā server components can't use useEffect | Mark modal wrappers as client components |
Not cleaning up overflow: hidden | Background page becomes unscrollable | Return cleanup function from useEffect |
Using redirect() in intercepted routes | Infinite redirect loops | Use router.back() or router.push() instead |
| Slots not rendering on first load | Layout doesn't pass slot props | Ensure layout destructures all @ prefixed props |
| Parallel route data fetching | Slow overall page load | Use Suspense boundaries and streaming |
Performance Optimization
Parallel routes inherently improve perceived performance by enabling independent streaming. However, you can optimize further:
// app/dashboard/@metrics/page.tsx
import { Suspense } from "react";
import { getMetrics } from "@/lib/analytics";
export default function MetricsPanel() {
return (
<Suspense fallback={<MetricsSkeleton />}>
<MetricsContent />
</Suspense>
);
}
async function MetricsContent() {
const metrics = await getMetrics();
return (
<div className="bg-white rounded-xl shadow p-6">
<h2 className="text-lg font-semibold mb-4">Key Metrics</h2>
<div className="grid grid-cols-2 gap-4">
<MetricCard label="Revenue" value={`$${metrics.revenue}`} />
<MetricCard label="Users" value={metrics.activeUsers} />
</div>
</div>
);
}
function MetricsSkeleton() {
return (
<div className="bg-white rounded-xl shadow p-6 animate-pulse">
<div className="h-6 bg-gray-200 rounded w-1/3 mb-4" />
<div className="grid grid-cols-2 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-20 bg-gray-200 rounded" />
))}
</div>
</div>
);
}Additionally, use loading.tsx files in each slot directory for automatic Suspense wrapping, and leverage generateStaticParams for ISR-compatible slot pages.
Comparison with Alternatives
| Feature | Parallel Routes | Client-Side Tabs | Layout Groups |
|---|---|---|---|
| Independent loading states | Yes, per slot | No ā all load together | No ā single loading state |
| SEO | Each slot has its own URL | Only active tab visible | Each route is separate |
| Error isolation | Per slot error.tsx | All crash together | Per layout group |
| Streaming | Yes, server-side | No ā client only | Yes |
| Browser history | Combined state | Single URL | Standard navigation |
| Complexity | Medium | Low | Low |
Advanced Patterns
Route Groups with Parallel Routes
Combine route groups to organize parallel slots without affecting the URL:
app/
āāā (dashboard)/
ā āāā @analytics/
ā āāā @team/
ā āāā layout.tsx
ā āāā page.tsx
āāā (marketing)/
ā āāā @features/
ā āāā @pricing/
ā āāā layout.tsx
ā āāā page.tsx
āāā layout.tsx
Unmatched Routes
When a parallel route doesn't match the current URL, Next.js renders default.tsx. This is critical for modals ā when the user navigates to a page that doesn't trigger interception, the modal slot must render nothing:
// app/@modal/default.tsx
export default function Default() {
return null;
}Keyboard Navigation for Modals
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export function ModalShell({ children }: { children: React.ReactNode }) {
const router = useRouter();
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") router.back();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [router]);
return (
<div className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center">
<div className="bg-white rounded-2xl max-w-3xl w-full mx-4 max-h-[90vh] overflow-auto">
{children}
</div>
</div>
);
}Testing Strategies
// __tests__/parallel-routes.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import { PhotoGallery } from "@/components/photo-gallery";
describe("Parallel and Intercepting Routes", () => {
it("renders gallery with photo cards", async () => {
render(<PhotoGallery photos={mockPhotos} />);
const cards = await screen.findAllByRole("link");
expect(cards).toHaveLength(mockPhotos.length);
});
it("navigates to full page on direct URL visit", async () => {
const response = await fetch("http://localhost:3000/photos/1");
expect(response.status).toBe(200);
const html = await response.text();
expect(html).toContain("Mountain Sunrise");
});
it("renders modal when clicking from gallery", async () => {
render(<PhotoGallery photos={mockPhotos} />);
const firstCard = screen.getAllByRole("link")[0];
fireEvent.click(firstCard);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
});
it("closes modal on Escape key", async () => {
render(<PhotoModal id="1" />);
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
});Future Outlook
The Next.js team continues to refine parallel and intercepting routes. In Next.js 15, the params prop became async, requiring await for access. The useRouter API is stabilizing, and there are RFCs for improved type safety across slot props. The React team's work on server components and streaming directly enables the performance benefits of parallel routes, and as RSC adoption grows, these patterns will become the standard for complex UI layouts.
Upcoming improvements include better dev tools for visualizing slot rendering, improved caching strategies for intercepted routes, and potential support for nested parallel routes that would enable even more granular loading states.
Conclusion
Parallel and intercepting routes are two of the most powerful features in the Next.js App Router. They enable sophisticated UI patterns ā dashboards with independent panels, photo galleries with modal overlays, and authentication flows that don't disrupt context ā all using simple filesystem conventions.
Key takeaways:
- Parallel routes use
@foldersyntax to render multiple pages in the same layout simultaneously, each with independent loading and error states. - Intercepting routes use
(.)folderconventions to load routes from other parts of your app within the current layout, enabling modal overlays that update the URL. - Combining both patterns creates the Facebook/Instagram-style photo modal experience ā a modal on click, full page on refresh.
- Always define
default.tsxfor slots that might not match the current route to prevent 404 errors. - Use
Suspenseboundaries within each slot for optimal streaming performance.
Start by building a simple photo gallery with the modal pattern ā it's the best way to internalize how these conventions work. From there, you'll find dozens of places in your application where parallel and intercepting routes simplify complex UI requirements.
For more details, see the Next.js Parallel Routes documentation and the Intercepting Routes guide.