Type something to search...
Intercepting Routes in Next.js: Creating Modal Experiences the Right Way

Intercepting Routes in Next.js: Creating Modal Experiences the Right Way

Most modals are built with a piece of state: const [open, setOpen] = useState(false). That works until someone wants to share what's in the modal, hits refresh, or presses the back button expecting the modal to close. State-driven modals have no URL, so none of those things work the way users expect.

Think about how a photo feed behaves on a well-built social site. Click a photo and it opens in an overlay, with the feed still visible behind it. The URL changes to /photos/42. Press back and the overlay closes. Copy the URL and send it to a friend, and they see a full photo page. Refresh while the overlay is open and you land on that same full page.

The App Router supports this pattern directly with intercepting routes, usually combined with parallel routes. This post explains how interception works, walks through building a URL-driven photo modal with an accessible dialog element, and covers the details that trip people up: the matcher syntax, default.tsx, closing the modal correctly, and when not to use the pattern.

What "Intercepting" Means

An intercepting route lets you render a different route's content inside the current layout during client-side navigation. When the user clicks a link to /photos/42 from the gallery, Next.js can "intercept" that navigation and render a modal version of the photo, while the URL in the address bar still updates to /photos/42.

The key rule: interception only happens on soft navigation. That means navigation through next/link or router.push from within the app. On a hard navigation (typing the URL, refreshing, opening in a new tab, following a shared link), there's nothing to intercept, so Next.js renders the normal /photos/42 page.

That split gives you both behaviors from the same URL:

How the user reaches /photos/42What renders
Clicks a photo in the galleryModal over the gallery
Presses back while the modal is openModal closes, gallery remains
Presses forward againModal reopens
Refreshes the pageFull photo page
Opens a shared linkFull photo page

The Matcher Syntax

Intercepting routes are folders whose names start with a matcher in parentheses. It looks similar to a route group, but the content is dots:

  • (.)segment matches segment at the same level.
  • (..)segment matches segment one level above.
  • (..)(..)segment matches segment two levels above.
  • (...)segment matches segment from the root of app.

The levels refer to route segments, not folders on disk. Route groups and parallel route slots (@modal) aren't segments, so they don't count. This is the most common source of confusion, and we'll come back to it.

The Folder Structure

Here's the structure for a photo gallery where the gallery is the home page and each photo has its own page:

app/
├── layout.tsx
├── page.tsx                      # / (gallery grid)
├── photos/
│   └── [id]/
│       └── page.tsx              # /photos/:id (full page)
└── @modal/
    ├── default.tsx               # renders nothing when no modal is open
    ├── [...catchAll]/
    │   └── page.tsx              # closes the modal on other navigations
    └── (.)photos/
        └── [id]/
            └── page.tsx          # /photos/:id intercepted (modal)

Three things work together here:

  1. photos/[id]/page.tsx is the real page. It handles direct visits and refreshes.
  2. @modal is a parallel route slot. The root layout renders it alongside children, so whatever it renders appears on top of the current page.
  3. @modal/(.)photos/[id] intercepts navigations to /photos/:id. It uses (.) because @modal isn't a segment, so from the routing system's point of view the interceptor sits at the root level, the same level as photos.

Step 1: Shared Data and Content

Both the modal and the full page show the same photo details, so put the data loading and markup in shared modules:

// lib/photos.ts
import { cache } from "react";

export type Photo = {
  id: string;
  title: string;
  author: string;
  src: string;
  width: number;
  height: number;
};

export const getPhotos = cache(async (): Promise<Photo[]> => {
  const res = await fetch("https://api.example.com/photos");
  if (!res.ok) throw new Error("Failed to load photos");
  return res.json();
});

export const getPhoto = cache(async (id: string): Promise<Photo | null> => {
  const res = await fetch(`https://api.example.com/photos/${id}`);
  if (res.status === 404) return null;
  if (!res.ok) throw new Error("Failed to load photo");
  return res.json();
});
// components/photo-details.tsx
import Image from "next/image";
import type { Photo } from "@/lib/photos";

export function PhotoDetails({ photo }: { photo: Photo }) {
  return (
    <figure className="space-y-3">
      <Image
        src={photo.src}
        alt={photo.title}
        width={photo.width}
        height={photo.height}
        className="h-auto w-full rounded-lg"
        loading="eager"
      />
      <figcaption>
        <h2 id="photo-title" className="text-lg font-semibold">
          {photo.title}
        </h2>
        <p className="text-sm text-slate-500">by {photo.author}</p>
      </figcaption>
    </figure>
  );
}

PhotoDetails is a Server Component. Keeping the content separate from the modal wrapper means the modal can stay a small Client Component while the content inside it renders on the server. (If your images come from a remote host, add it to images.remotePatterns in next.config.ts.)

Step 2: The Gallery and the Full Page

The gallery links to each photo with a normal Link:

// app/page.tsx
import Image from "next/image";
import Link from "next/link";
import { getPhotos } from "@/lib/photos";

export default async function GalleryPage() {
  const photos = await getPhotos();

  return (
    <main className="mx-auto max-w-6xl p-6">
      <h1 className="mb-6 text-2xl font-bold">Gallery</h1>
      <ul className="grid grid-cols-2 gap-4 md:grid-cols-4">
        {photos.map((photo) => (
          <li key={photo.id}>
            <Link href={`/photos/${photo.id}`} scroll={false}>
              <Image
                src={photo.src}
                alt={photo.title}
                width={300}
                height={300}
                className="aspect-square rounded-lg object-cover"
              />
            </Link>
          </li>
        ))}
      </ul>
    </main>
  );
}

scroll={false} keeps the gallery's scroll position when the modal opens. Without it, Next.js may scroll to the top of the new page content.

The full page is an ordinary dynamic route:

// app/photos/[id]/page.tsx
import { notFound } from "next/navigation";
import Link from "next/link";
import { getPhoto } from "@/lib/photos";
import { PhotoDetails } from "@/components/photo-details";

export default async function PhotoPage({ params }: PageProps<"/photos/[id]">) {
  const { id } = await params;
  const photo = await getPhoto(id);
  if (!photo) notFound();

  return (
    <main className="mx-auto max-w-3xl p-6">
      <Link href="/" className="mb-4 inline-block text-sm text-slate-500">
        Back to gallery
      </Link>
      <PhotoDetails photo={photo} />
    </main>
  );
}

At this point the app already works without any modal. Every photo link goes to a full page. Interception is a progressive enhancement on top.

Step 3: Render the Modal Slot in the Root Layout

The root layout receives the @modal slot as a modal prop and renders it after children:

// app/layout.tsx
import "./globals.css";

export default function RootLayout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {children}
        {modal}
      </body>
    </html>
  );
}

When no modal is active, the slot must render nothing. That's what default.tsx is for:

// app/@modal/default.tsx
export default function ModalDefault() {
  return null;
}

In Next.js 16 every parallel route slot must have a default.tsx, and the build fails without one. Here it also does real work: on a hard navigation to any URL, the @modal slot has no matching page, so default.tsx renders and the modal stays closed.

Step 4: The Intercepting Page

The intercepting route loads the same photo and wraps it in a modal:

// app/@modal/(.)photos/[id]/page.tsx
import { notFound } from "next/navigation";
import { getPhoto } from "@/lib/photos";
import { PhotoDetails } from "@/components/photo-details";
import { Modal } from "@/components/modal";

export default async function PhotoModal({
  params,
}: PageProps<"/photos/[id]">) {
  const { id } = await params;
  const photo = await getPhoto(id);
  if (!photo) notFound();

  return (
    <Modal labelledBy="photo-title">
      <PhotoDetails photo={photo} />
    </Modal>
  );
}

The route literal passed to PageProps is "/photos/[id]", the URL this page renders for. The slot and the interception marker aren't part of the URL.

Step 5: An Accessible Modal Component

The modal wrapper is a Client Component built on the native dialog element. Calling showModal() gives you a lot for free: the rest of the page becomes inert, focus moves into the dialog, Escape closes it, and a ::backdrop pseudo-element is available for styling.

// components/modal.tsx
"use client";

import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";

export function Modal({
  children,
  labelledBy,
}: {
  children: React.ReactNode;
  labelledBy?: string;
}) {
  const router = useRouter();
  const dialogRef = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    const dialog = dialogRef.current;
    if (dialog && !dialog.open) dialog.showModal();

    const previousOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => {
      document.body.style.overflow = previousOverflow;
    };
  }, []);

  return (
    <dialog
      ref={dialogRef}
      aria-labelledby={labelledBy}
      onClose={() => router.back()}
      onClick={(event) => {
        // A click directly on the dialog element is a click on the backdrop.
        if (event.target === event.currentTarget) dialogRef.current?.close();
      }}
      className="w-full max-w-2xl rounded-xl p-0 backdrop:bg-black/60"
    >
      <div className="relative p-6">
        <button
          type="button"
          onClick={() => dialogRef.current?.close()}
          aria-label="Close"
          className="absolute right-3 top-3 rounded px-2 py-1 text-xl"
        >
          ×
        </button>
        {children}
      </div>
    </dialog>
  );
}

All three ways of closing (the close button, a backdrop click, and Escape) end up closing the dialog, which fires its close event. The onClose handler then calls router.back(). That single exit path is what makes the modal behave like part of the browser history: the URL goes back to the gallery, the @modal slot returns to rendering nothing, and pressing forward reopens the photo.

The effect locks body scrolling while the modal is open and restores the previous value on unmount. The p-0 on the dialog and the padding on the inner div make sure the backdrop-click check works: clicks inside the content land on the inner elements, not on the dialog itself.

Step 6: Closing the Modal When Navigating Elsewhere

There's one more case to handle. Suppose the modal shows a link to the photographer's profile at /users/ada. The user clicks it. That's a soft navigation, and the @modal slot has no page for /users/ada. On soft navigation, an unmatched slot keeps showing what it was showing. So the modal stays open on top of the profile page.

The fix is a catch-all inside the slot that renders nothing:

// app/@modal/[...catchAll]/page.tsx
export default function CatchAll() {
  return null;
}

Now any navigation to a URL that isn't /photos/:id matches the catch-all, and the slot renders null. The interceptor at (.)photos/[id] is more specific, so it still wins for photo URLs.

The catch-all doesn't match / itself, since catch-all segments need at least one segment. If links inside the modal can point to the home page, add app/@modal/page.tsx returning null as well.

Getting the Matcher Right

The matcher counts route segments from the folder the interceptor lives in. Here are a few layouts and the correct matcher for each:

Interceptor locationTarget URLFolder name
app/@modal/ (root slot)/photos/[id](.)photos
app/feed//photos/[id](..)photos
app/feed/@modal//photos/[id](..)photos
app/shop/(browse)/products//cart(..)(..)cart
anywhere, deeply nested/login(...)login

The app/feed/@modal/ row is the classic gotcha: on disk the modal folder is two levels below app, but @modal isn't a segment, so it's only one segment away from the root. Route groups like (browse) don't count either. When in doubt, (...) matches from the root and is the easiest to reason about, at the cost of being less local.

Modals Scoped to One Section

You don't have to put the slot in the root layout. If the photo modal only makes sense on the feed, render it from the feed's layout:

app/
├── feed/
│   ├── layout.tsx               # renders children + modal
│   ├── page.tsx                 # /feed
│   └── @modal/
│       ├── default.tsx
│       └── (..)photos/
│           └── [id]/
│               └── page.tsx     # intercepts /photos/:id from /feed
└── photos/
    └── [id]/
        └── page.tsx             # /photos/:id

Interception only happens when the navigation starts inside the feed's layout. A link to /photos/42 from anywhere else does a regular navigation to the full page.

Common Pitfalls

The modal shows on refresh. It shouldn't. If it does, you probably rendered the modal from the real photos/[id]/page.tsx instead of from the intercepting route. The full page should never render the Modal wrapper.

The modal won't close after clicking a link inside it. Add the [...catchAll] page (and a page.tsx for the slot root if needed) so the slot renders null for other URLs.

The build fails with a missing default error. Every slot needs default.tsx in Next.js 16. Add app/@modal/default.tsx returning null.

Interception doesn't happen at all. Check the matcher against segments, not folders. Also make sure you're navigating with Link or router.push; a plain a tag does a full page load, which never intercepts.

Closing the modal goes somewhere unexpected. router.back() assumes the modal was opened by a navigation inside your app, which is always true for an intercepted route. If you also navigate between photos inside the modal (next/previous buttons), use router.replace or Link with replace for those, so back closes the modal instead of stepping through every photo.

Hidden state lingers with Cache Components. With cacheComponents enabled, Next.js keeps recently visited routes mounted but hidden instead of unmounting them. Because this modal's open state comes from the URL rather than useState, it's unaffected. If you add your own client state inside the modal (a zoom level, a comment draft), decide whether it should be preserved or reset when the user comes back.

When Not to Use Intercepting Routes

Intercepting routes are for modals that represent a place: a photo, a product, a post, a login screen. Content someone might link to or expect to reach with the back button.

They're overkill for confirmations ("Delete this item?"), small forms, menus, and anything that doesn't deserve its own URL. A regular client-side dialog with useState is the right tool there. If you want a modal that's linkable but doesn't need a full-page fallback, a search param (?edit=true) is often simpler than a route.

Conclusion

Intercepting routes give modals a real URL. Pair an interceptor folder like (.)photos/[id] with a parallel route slot like @modal, render the slot from a layout, and give it a default.tsx that returns null. Soft navigations show the modal over the current page; refreshes and shared links show the full page. Close the modal with router.back() so history behaves, add a catch-all to clear the slot on other navigations, and remember that the matcher counts route segments, not folders.

If you haven't used slots before, the post on parallel routes covers how they render and why default.tsx exists in more depth.

Tags :
Share :

Related Posts

A Deep Dive into next.config Options Every Developer Should Know

A Deep Dive into next.config Options Every Developer Should Know

next.config.ts is the one file every Next.js project has and almost nobody reads end to end. It starts as an empty object, then slowly collects a r

Continue Reading
Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Search engines are good at reading pages, but they still guess. Is "4.7" a rating or a version number? Is that date when the article was published or

Continue Reading
Adding Page Transitions and Animations to Next.js with Framer Motion

Adding Page Transitions and Animations to Next.js with Framer Motion

Animation is one of the easiest ways to make an app feel polished, and one of the easiest ways to make it feel slow. A subtle fade when a page loads,

Continue Reading