MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr šŸ”„ tagline

Hey there šŸ‘‹ I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Next.js App Router: Parallel and Intercepting Routes

Advanced routing: parallel routes, intercepting routes, and modal patterns.

Next.jsRoutingAdvancedFrontend

By MinhVo

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.

Next.js routing architecture

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
}

Dashboard layout with parallel routes

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:

SyntaxScope
(.)folderIntercept sibling routes (same level)
(..)folderIntercept one level above
(..)(..)folderIntercept two levels above
(...)folderIntercept 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>
  );
}

Modal overlay pattern

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

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-gallery

Step 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);
}
// 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>
  );
}

Photo gallery implementation

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

  1. Always define a default.tsx for modal slots: When navigating to a route that doesn't match the intercept pattern, the slot needs a fallback. Without default.tsx, Next.js throws a 404 error.

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

  3. Prevent body scroll when modals are open: Set document.body.style.overflow = "hidden" in a useEffect cleanup to prevent the background from scrolling while the modal is visible.

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

  5. Add generateStaticParams for static intercepted routes: This enables static generation for both the full-page and modal versions, improving performance significantly.

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

  7. Use Suspense boundaries within slots: Even though slots stream independently, wrapping expensive queries in Suspense gives users immediate feedback within each panel.

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

PitfallImpactSolution
Missing default.tsx in modal slot404 error when intercept failsAlways create app/@modal/default.tsx returning null
Forgetting use client on modal componentsBuild error — server components can't use useEffectMark modal wrappers as client components
Not cleaning up overflow: hiddenBackground page becomes unscrollableReturn cleanup function from useEffect
Using redirect() in intercepted routesInfinite redirect loopsUse router.back() or router.push() instead
Slots not rendering on first loadLayout doesn't pass slot propsEnsure layout destructures all @ prefixed props
Parallel route data fetchingSlow overall page loadUse 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

FeatureParallel RoutesClient-Side TabsLayout Groups
Independent loading statesYes, per slotNo — all load togetherNo — single loading state
SEOEach slot has its own URLOnly active tab visibleEach route is separate
Error isolationPer slot error.tsxAll crash togetherPer layout group
StreamingYes, server-sideNo — client onlyYes
Browser historyCombined stateSingle URLStandard navigation
ComplexityMediumLowLow

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:

  1. Parallel routes use @folder syntax to render multiple pages in the same layout simultaneously, each with independent loading and error states.
  2. Intercepting routes use (.)folder conventions to load routes from other parts of your app within the current layout, enabling modal overlays that update the URL.
  3. Combining both patterns creates the Facebook/Instagram-style photo modal experience — a modal on click, full page on refresh.
  4. Always define default.tsx for slots that might not match the current route to prevent 404 errors.
  5. Use Suspense boundaries 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.