Intercepting Routes in the Next.js App Router

After reading this you will be able to use the (.), (..) and (...) intercepting-route conventions together with a parallel-route slot so a soft client navigation opens content in a modal, while a hard load or shared link deep-links to the very same URL as a full standalone page.

← Back to Next.js App Router vs Pages Router

Prerequisites

Core Concept

An intercepting route lets one route render another route’s segment in the current layout without leaving the page, and Next.js selects it based on the relative position of the two segments. You mark an intercepting folder with a parenthesised prefix that reads like a relative path: (.)segment intercepts a sibling at the same level, (..)segment intercepts one level up, (..)(..)segment two levels up, and (...)segment intercepts from the app root. Crucially the prefix describes the position in the route tree, not the filesystem, so a (..) can cross a route group boundary. The classic use is the photo-modal: clicking a thumbnail on /feed soft-navigates to /photo/123, the router intercepts that navigation and paints the photo inside a modal slot layered over the feed, and the address bar reads /photo/123. Because the URL is real, refreshing the tab, opening it in a new one, or sharing the link bypasses the interception entirely and renders the full-page version of /photo/123. Interception only fires on a soft, in-app navigation; a hard load always resolves to the underlying page. This is why interception is paired with a parallel @modal slot — the slot holds the intercepted UI, and a default.tsx keeps it empty on a hard load.

Soft navigation intercepts into a modal while a hard load deep-links to the full page A click on the feed intercepts /photo/123 into a modal slot; a refresh or shared link of the same URL renders the standalone page. /feed click a thumbnail (.)photo/123 modal rendered over the feed /photo/123 full page standalone route URL: /photo/123 real, shareable soft nav refresh / share
The same URL resolves to a modal on soft navigation and to the full page on a hard load.

Implementation

The folder layout wires a @modal parallel slot into the feed layout and places an intercepting (..)photo/[id] folder inside it. The (..) climbs from the @modal slot up to the route level where photo lives.

app/
  layout.tsx
  feed/
    layout.tsx          // renders children AND the @modal slot
    page.tsx            // the feed grid, links to /photo/[id]
    @modal/
      default.tsx       // renders null on a hard load
      (..)photo/
        [id]/
          page.tsx      // the intercepted modal version
  photo/
    [id]/
      page.tsx          // the full standalone page (hard load / deep link)
// Next.js 14 App Router — app/feed/layout.tsx
import type { ReactNode } from "react";

// The @modal slot arrives as a typed prop alongside children, exactly like
// any parallel route. It sits over the feed when interception fires.
export default function FeedLayout(props: {
  children: ReactNode;
  modal: ReactNode;
}) {
  return (
    <section>
      {props.children}
      {props.modal}
    </section>
  );
}
// Next.js 14 App Router — app/feed/@modal/default.tsx
// A hard load has no intercepted segment for the slot, so it renders nothing.
export default function ModalDefault(): null {
  return null;
}
// Next.js 14 App Router — app/feed/@modal/(..)photo/[id]/page.tsx
"use client";
import { useRouter } from "next/navigation";

// This component ONLY renders when /photo/[id] is reached by soft navigation
// from within /feed. A refresh of the same URL renders app/photo/[id] instead.
export default function PhotoModal({ params }: { params: { id: string } }) {
  const router = useRouter();
  return (
    <div role="dialog" aria-modal="true" aria-label="Photo detail">
      <div className="backdrop" onClick={() => router.back()} />
      <figure>
        <img src={`/api/photos/${params.id}`} alt={`Photo ${params.id}`} />
      </figure>
      <button onClick={() => router.back()}>Close</button>
    </div>
  );
}

Dismissing the modal is just router.back() — because the modal was reached by a real navigation, popping the History API entry returns the user to /feed and unmounts the slot. The standalone app/photo/[id]/page.tsx shares no code path with the modal; it is what any hard load of /photo/123 resolves to.

Verification

Assert both halves of the contract: a soft in-app click opens the modal at the deep-link URL, and a direct load of that URL renders the full page instead.

// @playwright/test v1.44
import { test, expect } from "@playwright/test";

test("soft navigation intercepts into the modal at the real URL", async ({ page }) => {
  await page.goto("/feed");
  await page.getByRole("link", { name: /photo 123/i }).click();
  await expect(page).toHaveURL(/\/photo\/123/);
  // The intercepted slot renders as a dialog layered over the feed.
  await expect(page.getByRole("dialog")).toBeVisible();
});

test("a hard load of the same URL deep-links to the full page, not the modal", async ({ page }) => {
  await page.goto("/photo/123"); // fresh document load, no interception
  await expect(page.getByRole("dialog")).toHaveCount(0);
  await expect(page.getByRole("heading")).toBeVisible();
});

For a manual check, click through from the feed and confirm the address bar updates to /photo/123 with a modal open, then press the browser refresh: the modal should disappear and the standalone page should render. If refresh keeps showing the modal, the (..) level is wrong or the full-page route is missing.

Gotchas

  • The parenthesised prefix counts route levels, not folders on disk; a route group like (marketing) does not add a level, so (..) may need to be (...) when a group sits between the slot and the target segment.
  • Interception fires only on client-side navigations initiated with <Link> or router.push; a full page load, a window.location assignment, or an external referrer always resolves to the underlying page — which is the intended deep-link behaviour, not a bug.
  • Omitting default.tsx in the parallel slot makes a hard load throw a 404 for the unmatched slot, because the slot has no fallback for a URL it did not intercept.
  • The intercepted modal and the standalone page are separate route components; duplicate shared data-fetching logic into a helper rather than importing one page into the other, or you couple two routes that are meant to diverge in presentation.

FAQ

What do the (.), (…) and (…) prefixes actually mean? They describe how far up the route tree to look for the segment being intercepted: (.) matches a sibling at the same level, (…) matches one level up, (…)(…) two levels up, and (…) matches from the app root. The count is over route segments, not filesystem folders.

Why does refreshing the modal show the full page instead? Interception only applies to soft, in-app navigations. A refresh is a hard document load, so Next.js resolves the URL to the underlying standalone route rather than the intercepted slot — which is exactly what makes the modal URL shareable and deep-linkable.

Do I always need a parallel route to intercept? In practice yes for the modal pattern. The intercepted UI lives inside a named slot such as @modal so it can layer over the current page, and the slot’s default.tsx keeps it empty on a hard load. Interception and parallel routes are designed to work together.

How do I close an intercepting modal correctly? Call router.back(). Because the modal was opened by a genuine navigation that pushed a history entry, going back pops that entry, returns to the originating page, and unmounts the slot, keeping the browser back button consistent with the visible UI.

Can interception cross a route group boundary? Yes. Route groups written in parentheses do not create a routing level, so a (…) can reach across a group like (shop). If your interception resolves to the wrong page, recount the real route levels ignoring any groups and adjust the number of (…) prefixes.