React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

intercepting-routesmodalssoft-navigationroute-interception

Intercepting Routes

Intercepting routes let you load a route within the current layout during client-side navigation while preserving the full page on direct URL access or refresh. The classic use case is a modal that opens inline but has its own shareable URL.

Recipe

Quick-reference recipe card — copy-paste ready.

Convention      Matches
(.)folder       Same level (like ./folder)
(..)folder      One level up (like ../folder)
(..)(..)folder  Two levels up (like ../../folder)
(...)folder     Root level (like /folder from anywhere)
app/
├── layout.tsx
├── @modal/
│   ├── default.tsx              # Renders nothing when no modal is active
│   └── (.)photo/[id]/page.tsx   # Intercepts /photo/:id → renders in modal
├── photo/[id]/
│   └── page.tsx                 # Full page for /photo/:id (direct access)
└── page.tsx                     # Gallery page with photo thumbnails

When to reach for this: Photo galleries with modal previews, login modals with /login URLs, item detail previews in a list, or any pattern where a route should show as a modal during navigation but as a full page on direct access.

Working Example

// app/layout.tsx — Root layout with modal slot
export default function RootLayout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {children}
        {modal}
      </body>
    </html>
  );
}
// app/@modal/default.tsx — Renders nothing when no modal is intercepted
export default function ModalDefault() {
  return null;
}
// app/@modal/(.)photo/[id]/page.tsx — Intercepted route (shows as modal)
"use client";
 
import { useRouter } from "next/navigation";
 
export default function PhotoModal({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const router = useRouter();
  // In a client component, use React.use() to unwrap params
  const { id } = require("react").use(params);
 
  return (
    <div
      className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
      onClick={() => router.back()}
    >
      <div
        className="relative max-h-[90vh] max-w-3xl overflow-hidden rounded-lg bg-white p-4"
        onClick={(e) => e.stopPropagation()}
      >
        <button
          onClick={() => router.back()}
          className="absolute right-2 top-2 text-gray-500 hover:text-gray-800"
        >
          Close
        </button>
        <img
          src={`/photos/${id}.jpg`}
          alt={`Photo ${id}`}
          className="max-h-[80vh] w-auto"
        />
        <p className="mt-2 text-center text-sm text-gray-600">Photo #{id}</p>
      </div>
    </div>
  );
}
// app/photo/[id]/page.tsx — Full page (direct URL access or refresh)
export default async function PhotoPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
 
  return (
    <div className="flex min-h-screen flex-col items-center justify-center p-8">
      <img
        src={`/photos/${id}.jpg`}
        alt={`Photo ${id}`}
        className="max-h-[80vh] rounded-lg"
      />
      <h1 className="mt-4 text-2xl font-bold">Photo #{id}</h1>
      <a href="/" className="mt-4 text-blue-600 hover:underline">
        Back to gallery
      </a>
    </div>
  );
}
// app/page.tsx — Gallery page with links that trigger interception
import Link from "next/link";
 
const photos = Array.from({ length: 12 }, (_, i) => ({
  id: String(i + 1),
  title: `Photo ${i + 1}`,
}));
 
export default function GalleryPage() {
  return (
    <div className="p-8">
      <h1 className="mb-6 text-3xl font-bold">Photo Gallery</h1>
      <div className="grid grid-cols-3 gap-4">
        {photos.map((photo) => (
          <Link key={photo.id} href={`/photo/${photo.id}`}>
            <img
              src={`/photos/${photo.id}.jpg`}
              alt={photo.title}
              className="aspect-square rounded-lg object-cover hover:opacity-80"
            />
          </Link>
        ))}
      </div>
    </div>
  );
}

Deep Dive

How It Works

  • Interception only happens on client-side navigation. When a user clicks a <Link>, Next.js renders the intercepting route instead of the target route.
  • Direct URL access or refresh bypasses interception. Typing the URL or refreshing loads the full page version at app/photo/[id]/page.tsx.
  • Intercepting routes use parallel route slots. The @modal folder is a parallel route. The intercepting route (.)photo/[id] is placed inside it.
  • The dot convention indicates relative depth. (.) means same level, (..) means one level up. This is relative to the route segment, not the file system.
  • default.tsx is required in the slot. Without it, the slot renders nothing on non-intercepted routes, and Next.js may throw errors on hard navigation.
  • router.back() dismisses the modal. Since interception uses client-side navigation, going back restores the previous view.
  • The full page still exists at its URL. Interception does not remove the original route — it shadows it during soft navigation only.

Variations

# Login modal intercepted from any page
app/
├── layout.tsx              # Includes @modal slot
├── @modal/
│   ├── default.tsx
│   └── (...)login/page.tsx # Intercepts /login from root level
├── login/
│   └── page.tsx            # Full login page (direct access)
└── page.tsx
// app/@modal/(...)login/page.tsx — Login modal
"use client";
 
import { useRouter } from "next/navigation";
 
export default function LoginModal() {
  const router = useRouter();
 
  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
      <div className="w-full max-w-md rounded-lg bg-white p-8">
        <h2 className="text-xl font-bold">Log In</h2>
        <form className="mt-4 space-y-4">
          <input type="email" placeholder="Email" className="w-full rounded border p-2" />
          <input type="password" placeholder="Password" className="w-full rounded border p-2" />
          <button type="submit" className="w-full rounded bg-blue-600 py-2 text-white">
            Log In
          </button>
        </form>
        <button onClick={() => router.back()} className="mt-4 text-sm text-gray-500">
          Cancel
        </button>
      </div>
    </div>
  );
}
# Nested interception — item detail in a list
app/
├── feed/
│   ├── layout.tsx
│   ├── @detail/
│   │   ├── default.tsx
│   │   └── (..)post/[id]/page.tsx  # Intercepts /post/:id
│   └── page.tsx                     # Feed list
├── post/[id]/
│   └── page.tsx                     # Full post page

TypeScript Notes

// Intercepting route pages have the same props as regular pages
interface InterceptedPageProps {
  params: Promise<{ id: string }>;
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
 
// Layout receiving a parallel route slot
interface LayoutWithModalProps {
  children: React.ReactNode;
  modal: React.ReactNode; // The @modal slot
}

Gotchas

  • The dot convention refers to route segments, not file-system directories. (..) goes up one route segment, which may not correspond to one directory level if route groups are involved.
  • default.tsx must exist in the slot. Without it, navigating away from the intercepted route and then back can cause errors.
  • Refreshing the page loads the full route, not the intercepted one. Users who share the URL will see the full page, not the modal — this is by design.
  • Route groups (group) count as a segment for the dot convention. If your intercepting route is inside a route group, you may need an extra (..) level.
  • Back navigation may not work as expected with complex histories. If the user navigated through multiple intercepted routes, router.back() pops the last entry, which might be another modal.
  • Intercepting routes add complexity. If you do not need the shareable-URL-as-full-page behavior, a simple client-side modal state is simpler.
  • Parallel route slots must be direct children of a layout. You cannot nest @modal arbitrarily — it must be a sibling of the layout that renders it.

Alternatives

ApproachWhen to Use
Client-side modal stateSimple modals without shareable URLs
Query parameter modal (?modal=photo&id=1)Shareable state without route interception complexity
Parallel Routes without interceptionSide-by-side views that are not modals
Dialog element with useSearchParamsLightweight modals driven by URL search params

FAQs

When does route interception happen and when does it not?
  • Interception happens only during client-side navigation (clicking a <Link>)
  • Direct URL access, page refresh, or shared links bypass interception and load the full page version
What do the dot conventions (.), (..), (..)(..), and (...) mean?
  • (.) matches the same route level
  • (..) matches one route level up
  • (..)(..) matches two route levels up
  • (...) matches from the root level
Why is default.tsx required in the @modal slot?

Without default.tsx, navigating away from the intercepted route and then back can cause errors. It provides a fallback (typically returning null) when no modal is active.

How do you dismiss an intercepted modal?

Call router.back() from the useRouter hook. Since interception uses client-side navigation, going back restores the previous view.

"use client";
import { useRouter } from "next/navigation";
const router = useRouter();
// In the close button:
<button onClick={() => router.back()}>Close</button>
Gotcha: Do the dot conventions refer to file-system directories or route segments?

Route segments, not file-system directories. This distinction matters when route groups are involved, because route groups (group) count as a segment for the dot convention and may require an extra (..) level.

Does the intercepting route replace the original route?

No. The original full page still exists at its URL. Interception only shadows it during soft (client-side) navigation. The original renders on direct access or refresh.

What is the relationship between intercepting routes and parallel routes?

Intercepting routes are placed inside parallel route slots (e.g., @modal). The slot is rendered as a prop in the parent layout alongside children, allowing the modal and page to display simultaneously.

What are the TypeScript types for an intercepted route page?
interface InterceptedPageProps {
  params: Promise<{ id: string }>;
  searchParams: Promise<{
    [key: string]: string | string[] | undefined;
  }>;
}
 
interface LayoutWithModalProps {
  children: React.ReactNode;
  modal: React.ReactNode;
}
How do you type the params in a Client Component intercepted route?

Use React.use() to unwrap the Promise since you cannot use await in Client Components.

"use client";
import { use } from "react";
 
export default function Modal({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = use(params);
  return <div>Item {id}</div>;
}
Gotcha: What happens with complex navigation histories and multiple intercepted routes?

router.back() pops the last history entry, which might be another modal rather than the non-modal page. Users navigating through multiple intercepted routes may need to go back multiple times.

When should you use a simple client-side modal instead of intercepting routes?

When you do not need a shareable URL for the modal content. Intercepting routes add file-system complexity. If the modal does not need its own URL or full-page fallback, client-side state is simpler.

Can @modal be nested inside another @slot?

No. Parallel route slots must be direct children of a layout directory. You cannot nest @modal inside another slot.