EdgeCases Logo
Mar 2026
Next.js
Deep
8 min read

Next.js Parallel and Intercepting Routes

Modal patterns, @parallel slots, and (.) conventions — the mental model most devs get wrong.

nextjs
routing
modals
parallel-routes
intercepting-routes
app-router

Parallel and Intercepting Routes are Next.js's answer to the "modal problem": showing content in an overlay while preserving URL shareability and back-button behavior. The mental model is counterintuitive: you're not "opening a modal" — you're rendering two routes simultaneously, with one visually overlaying the other.

The Core Concepts

Parallel Routes: Multiple Pages, One URL

Parallel routes render multiple page components within the same layout. Each "slot" (defined with @folder) is an independent route that can have its own loading states, error boundaries, and nested routes.

app/
├── layout.tsx          # Receives slots as props
├── page.tsx            # Default children slot
├── @modal/             # Named slot (doesn't affect URL)
│   ├── default.tsx     # Fallback when no modal active
└── (.)photo/[id]/
│       └── page.tsx    # Intercepts /photo/[id]
└── photo/
    └── [id]/
        └── page.tsx    # Direct navigation target

The @modal folder creates a "slot" that's passed to the parent layout as a prop. The key insight: slots don't create URL segments. @modal/page.tsx doesn't make /modal accessible.

// app/layout.tsx
export default function Layout({
  children,
  modal,  // @modal slot injected as prop
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <html>
      <body>
        {children}
        {modal}  {/* Renders on top when active */}
      </body>
    </html>
  );
}

Intercepting Routes: Hijacking Navigation

Intercepting routes "catch" client-side navigations and render alternative content while updating the URL. The (.) convention matches route segments:

  • (.) — same segment level
  • (..) — one level up
  • (..)(..) — two levels up
  • (...) — from root app/ directory

Critical: These match route segments, not filesystem paths. Slot folders (@modal) don't count as segments.

// @modal is NOT a segment, so (.) matches /photo directly
app/
├── @modal/
└── (.)photo/[id]/page.tsx   # Intercepts /photo/[id]
└── photo/
    └── [id]/page.tsx            # Full page (hard navigation)

The Modal Pattern: Step by Step

1. Create the Full Page Route

// app/photo/[id]/page.tsx
// This renders on direct navigation or page refresh
export default async function PhotoPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const photo = await getPhoto(id);

  return (
    <div className="full-page-photo">
      <img src={photo.url} alt={photo.title} />
      <h1>{photo.title}</h1>
      <p>{photo.description}</p>
    </div>
  );
}

2. Create the Intercepting Modal Route

// app/@modal/(.)photo/[id]/page.tsx
// This renders on client-side navigation (Link clicks)
import { Modal } from '@/components/modal';

export default async function PhotoModal({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const photo = await getPhoto(id);

  return (
    <Modal>
      <img src={photo.url} alt={photo.title} />
      <h1>{photo.title}</h1>
    </Modal>
  );
}

3. Add default.tsx for Inactive State

// app/@modal/default.tsx
// Required: renders when modal slot has no matching route
export default function Default() {
  return null;  // Nothing visible when no modal active
}

This is the most common mistake. Without default.tsx, you'll get 404 errors on page refresh or direct navigation.

4. Wire Up the Layout

// app/layout.tsx
export default function RootLayout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <html>
      <body>
        {children}
        {modal}
      </body>
    </html>
  );
}

Edge Cases That Break Things

1. The Hard Navigation Problem

Client-side navigation (Link) shows the modal. Direct URL access or refresh shows the full page. This is by design, but catches developers off guard.

// User clicks Link → modal opens, URL = /photo/123
// User refreshes page → full photo page renders
// User shares URL → recipient sees full page, not modal

// This is correct behavior! The modal is an "enhancement"
// for in-app navigation, not the canonical view.

2. The default.tsx Requirement

Every parallel route slot needs a default.tsx for unmatched states. Without it, Next.js can't recover slot state after hard navigation:

// ❌ Missing default.tsx causes 404
app/
├── @modal/
└── (.)photo/[id]/page.tsx
└── layout.tsx  // ← 404 on /about because @modal has no match

// ✓ Add default.tsx
app/
├── @modal/
│   ├── default.tsx        // Returns null
└── (.)photo/[id]/page.tsx
└── layout.tsx             // ← Works on all routes

3. Segment Matching Confusion

The (..) convention counts route segments, not folders. Slot folders are invisible:

// Filesystem structure
app/
├── @modal/
└── (.)photo/[id]/page.tsx
└── photo/
    └── [id]/page.tsx

// Route segment structure (what (.) sees)
/                 ← root
└── photo/[id]    ← one segment deep

// @modal is NOT a segment!
// So (.) in @modal matches photo at the same level (root)

For nested interception:

// Intercept /dashboard/settings from /dashboard/@panel
app/dashboard/
├── @panel/
└── (.)settings/page.tsx   # (.) works - same level
└── settings/
    └── page.tsx

// Intercept /photo/[id] from /gallery/@modal
app/
├── gallery/
│   └── @modal/
└── (..)photo/[id]/page.tsx  # (..) - one level up
└── photo/
    └── [id]/page.tsx

4. Modal Closing with router.back()

Using router.back() closes the modal but may not go where you expect:

// User journey:
// /gallery → /gallery (modal: /photo/1) → /gallery (modal: /photo/2)

// router.back() from photo/2:
// Goes to /gallery (modal: /photo/1), NOT /gallery with no modal

// Solution: Use router.push() for predictable closing
'use client';
import { useRouter } from 'next/navigation';

function Modal({ children }: { children: React.ReactNode }) {
  const router = useRouter();

  const closeModal = () => {
    router.back();  // Or router.push('/gallery') for explicit target
  };

  return (
    <div className="modal-overlay" onClick={closeModal}>
      <div className="modal-content" onClick={(e) => e.stopPropagation()}>
        {children}
      </div>
    </div>
  );
}

5. Soft vs Hard Navigation State

Parallel routes maintain independent state during soft (client-side) navigation. But hard navigation (refresh, direct URL) resets everything:

// Soft navigation preserves slot state
// /dashboard with @analytics showing /visitors
// Navigate to /dashboard/settings
// @analytics STILL shows /visitors (preserved)

// Hard navigation cannot recover state
// Refresh on /dashboard/settings
// @analytics shows default.tsx (state lost)

Production Patterns

Login Modal with Fallback Page

app/
├── layout.tsx
├── @auth/
│   ├── default.tsx           # null when logged in
└── (.)login/page.tsx     # Modal version
├── login/
│   └── page.tsx              # Full page version
└── page.tsx
// app/@auth/(.)login/page.tsx
import { Modal } from '@/components/modal';
import { LoginForm } from '@/components/login-form';

export default function LoginModal() {
  return (
    <Modal>
      <LoginForm />
    </Modal>
  );
}

// app/login/page.tsx
import { LoginForm } from '@/components/login-form';

export default function LoginPage() {
  return (
    <div className="login-page">
      <LoginForm />
    </div>
  );
}

Conditional Slot Rendering

// app/dashboard/layout.tsx
import { checkUserRole } from '@/lib/auth';

export default function DashboardLayout({
  children,
  admin,
  user,
}: {
  children: React.ReactNode;
  admin: React.ReactNode;
  user: React.ReactNode;
}) {
  const role = checkUserRole();

  return (
    <div>
      {children}
      {role === 'admin' ? admin : user}
    </div>
  );
}

Debugging Checklist

  • 404 on refresh? Add default.tsx to your slot
  • Modal not appearing? Check segment matching ((.) vs (..))
  • Wrong content in slot? Verify slot folder name matches layout prop
  • State lost on navigation? That's expected for hard navigation
  • Both modal and page rendering? Check your layout renders slot correctly

Parallel and Intercepting Routes are powerful but require understanding the dual-render mental model: you're always rendering multiple routes, with interception controlling which version appears based on navigation type.

Advertisement

Related Insights

Explore related edge cases and patterns

Next.js
Surface
Next.js 16: Dynamic by Default, Turbopack Stable, proxy.ts
8 min
Next.js
Deep
Next.js 'use cache': Explicit Caching with Automatic Keys
9 min
Next.js
Deep
Turbopack: Next.js 16 Default Bundler (2-10× Faster)
8 min

Advertisement