Parallel & Intercepting Routes

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Next.js 16.3.8 (the latest release on 2026-10-03, when the facts and examples below were checked; the official documentation reports @doc-version 16.3.8), React 19.3.0 and Node.js 20.9.0 or later. Parallel and intercepting routes exist only in the App Router; the Pages Router has no equivalent, and the last section shows what to do there instead. Rendering behaviour is the same under both caching models, with one exception that matters for modals: with the opt-in Cache Components model (cacheComponents: true) an intercepting page that reads params outside a <Suspense> boundary fails the build, so every modal example below reads params inside <Suspense> and was built and run under both the previous model (the default) and Cache Components. All examples were built with next build and exercised with curl and a real browser against Next.js 16.3.8 on Node.js 24.16, in a scratch copy of the Bookshelf — the catalogue of books that the rest of this section grows; each is followed by the official page it derives from.

Two conventions look unrelated but are designed to be used together. A parallel route lets one layout render several pages at once, each in a named slot that follows its own navigation state. An intercepting route lets a link render a route from elsewhere in the app inside the current layout while the URL changes to the intercepted route. Put the intercepting route in a slot and you get the pattern that is hard to build by hand: a modal that has its own URL, is shareable, survives a refresh as a full page, closes on Back and reopens on Forward.

The page assumes the route structure from Project Structure (folders, route groups, @slot and (.)folder in the file tree) and the layout and segment ideas from Introduction & Architecture. Navigation, prefetching and the router cache have their own page, which is only referred to here in prose.

The Conventions at a Glance

Table 1. Parallel and intercepting route conventions (App Router)
Convention What it does Affects the URL?

@name folder

Defines a slot. The enclosing layout receives it as a prop named name. The folder holds normal page, layout, loading, error and not-found files.

No. @analytics/views/page.tsx serves /views; the slot name is not a segment.

children

The implicit slot. app/page.tsx is equivalent to app/@children/page.tsx.

No.

default.js

Fallback rendered for a slot (including children) that has no match for the current URL after a hard navigation.

No.

(.)folder

Intercepts a route at the same segment level.

The browser shows the intercepted route’s URL.

(..)folder

Intercepts a route one segment above.

Same.

(..)(..)folder

Intercepts a route two segments above.

Same.

(…​)folder

Intercepts a route from the root app directory.

Same.

Two rules apply to every row. Slots are props of the layout, not route segments, so a layout that does not render the slot prop shows nothing for it. The interception conventions count route segments, not folders in the file system, so @slot folders (and route groups) are skipped when counting ...

Parallel Routes

Slots Are Props of the Layout

A slot is created by a folder whose name starts with @. The nearest layout above it receives the rendered slot as a prop with the same name, next to children. The example is a dashboard with a team slot and an analytics slot:

File layout of the dashboard
app/dashboard/
├── layout.tsx            # receives children, team and analytics
├── page.tsx              # the "children" slot at /dashboard
├── default.tsx           # fallback for "children"
├── @team/
│   ├── page.tsx          # /dashboard
│   ├── settings/page.tsx # /dashboard/settings
│   └── default.tsx
└── @analytics/
    ├── layout.tsx        # tabs, see below
    ├── page.tsx          # /dashboard
    ├── views/page.tsx    # /dashboard/views
    ├── visitors/page.tsx # /dashboard/visitors
    └── default.tsx
app/dashboard/layout.tsx — the layout renders each slot where it wants it
import Link from "next/link";
import { ActiveTab } from "@/components/active-tab";

export default function DashboardLayout({
  children,
  team,
  analytics,
}: LayoutProps<"/dashboard">) {
  return (
    <section>
      <nav>
        <Link href="/dashboard">Overview</Link>{" "}
        <Link href="/dashboard/settings">Team settings</Link>{" "}
        <Link href="/dashboard/views">Page views</Link>{" "}
        <Link href="/dashboard/visitors">Visitors</Link>{" "}
        <Link href="/dashboard/broken">Broken team</Link>
      </nav>
      <ActiveTab />
      <div data-slot="children">{children}</div>
      <div data-slot="team">{team}</div>
      <div data-slot="analytics">{analytics}</div>
    </section>
  );
}

LayoutProps<"/dashboard"> is a global helper that next dev, next build and next typegen generate; it infers children, params and one prop per slot folder from the directory structure, so renaming @team to @squad turns the destructured team into a type error. The typed helper arrived in 15.5; before that the props were typed by hand as { children: React.ReactNode; team: React.ReactNode; …​ }, which still works.

The build output shows that slots are not routes. Only the URLs that the slot pages produce are listed:

next build output for the dashboard (Next.js 16.3.8, previous caching model)
├ ○ /dashboard
├ ƒ /dashboard/broken
├ ○ /dashboard/settings
├ ○ /dashboard/views
├ ƒ /dashboard/visitors

There is no /dashboard/@team and no /dashboard/team. /dashboard/settings exists only because @team/settings has a page; /dashboard/views only because @analytics/views has one. A single URL can be satisfied by pages in several slots at once, which is the "parallel" in the name.

Source: Parallel Routes

Where Slots Run

Every slot is a Server Component tree rendered by the same request, in the same RSC payload, as the rest of the page; a slot is not a separate fetch from the browser. Consequences that follow from that and that were measured:

  • Slots fetch data concurrently. The layout does not await one slot before starting the next, so the dashboard’s latency is that of its slowest slot rather than the sum, as long as the slots do not wait for each other.

  • Each slot can have its own loading.tsx and error.tsx. A slow or failing slot does not block or break the others. In the scratch dashboard @analytics/visitors/page.tsx waits 1.5 s. A hard load of /dashboard/visitors answered its first byte after 39 ms and the full document after 1.53 s, with Loading analytics… in the HTML first and the real content streamed in afterwards. A soft navigation to the same URL showed Loading analytics… in the analytics slot after 300 ms while the team slot kept its content, and the real analytics 2.3 s after the click.

  • A slot error stays in the slot. Navigating softly to /dashboard/broken, whose @team/broken/page.tsx throws, replaced only the team panel with the @team/error.tsx fallback (The team panel failed to load. and a Retry button); children and analytics were untouched.

  • There is no server-versus-client distinction between slots. A slot page is a Server Component unless it carries "use client", exactly like any other page.

app/dashboard/@analytics/loading.tsx and visitors/page.tsx — a slow slot streams on its own
// app/dashboard/@analytics/loading.tsx
export default function Loading() {
  return <p>Loading analytics…</p>;
}

// app/dashboard/@analytics/visitors/page.tsx
import { connection } from "next/server";

export default async function Visitors() {
  await connection(); // opt out of prerendering: this slot is rendered per request
  await new Promise((resolve) => setTimeout(resolve, 1500)); // simulate a slow query
  return <p>Analytics: visitors</p>;
}
app/dashboard/@team/error.tsx — a failing slot shows its own fallback
"use client";

export default function TeamError({
  error,
  retry,
}: {
  error: Error & { digest?: string };
  retry: () => void;
}) {
  return (
    <div role="alert">
      <p>The team panel failed to load.</p>
      <button type="button" onClick={() => retry()}>
        Retry
      </button>
    </div>
  );
}

The retry prop is the name of this function in 16.3 (the error.js reference documents retry); it was unstable_retry in 16.2 and reset in earlier versions. Rendering also has one hard constraint: slots at the same level cannot mix prerendered and dynamic output. If one slot at a segment level is dynamic, the whole segment level is dynamic, which is why /dashboard/visitors, whose analytics slot is dynamic, is listed as ƒ while the other three dashboard URLs are ○. Keep the slow slot on its own sub-route (as above) when the overview should stay static.

Soft and Hard Navigation

This is the behaviour that every other rule in the page hangs from. Next.js tracks the active state (the current sub-page) of each slot, but only as long as the page is alive in the browser.

  • Soft navigation is a client-side transition (a <Link> click, router.push, Back and Forward inside the app). The router makes a partial render: only the slots whose segments changed are replaced and the other slots keep whatever they showed, even when it no longer matches the URL.

  • Hard navigation is a full document load (refresh, a typed or shared URL, a new tab, a crawler). The router starts from nothing and can only match the URL against each slot. A slot with no match renders its default.js.

The dashboard shows it. These are the contents of the three regions read from the DOM after each navigation:

Table 2. Soft navigation keeps each slot’s last state; a reload re-matches every slot against the URL
Step team slot analytics slot URL

Hard load of /dashboard

Team: 3 members

Analytics: pick a tab

/dashboard

Soft: click Team settings

Team settings

Analytics: pick a tab (kept)

/dashboard/settings

Soft: click Page views

Team settings (kept)

Analytics: page views

/dashboard/views

Soft: click Overview

Team: 3 members

Analytics: pick a tab

/dashboard

Hard load of /dashboard/views

empty (default.tsx returned null)

Analytics: page views

/dashboard/views

On the third row the URL is /dashboard/views but the team slot still shows the team settings: no @team/views exists, so the slot keeps its previous page. Reloading that URL cannot reproduce that combination, because the browser has lost the history of the slot; the team slot has to fall back to its default.tsx. A parallel-route UI is therefore a function of the URL and the navigation path that led to it, which is the reason default.js exists.

flowchart LR a["Hard load /dashboard
team: page
analytics: page"] -->|"soft click
Team settings"| b["/dashboard/settings
team: settings
analytics: page, kept"] b -->|"soft click
Page views"| c["/dashboard/views
team: settings, kept
analytics: views"] c -->|"refresh"| d["Hard load /dashboard/views
team: default.js
analytics: views"]

default.js: the Fallback, and the Requirement in 16

default.js (any of .js, .jsx, .tsx) renders for a slot that has no match during a hard navigation. It is also required for children, because children is an implicit slot: without app/dashboard/default.tsx, a hard load of /dashboard/views (which only @analytics matches) renders the 404 page instead of the whole dashboard, not just an empty children region.

The two usual defaults
// app/dashboard/@team/default.tsx -- render nothing
export default function Default() {
  return null;
}

// app/dashboard/@analytics/default.tsx -- keep the pre-16 behaviour: unmatched is a 404
import { notFound } from "next/navigation";

export default function Default() {
  notFound();
}

default.js may also take the params prop, a promise of the dynamic parameters from the root segment down to the slot (app/[artist]/@sidebar/default.js gets { artist }); read it with await or React’s use, as for every other request-time prop since 15.

Before Next.js 16 a slot without default.js silently produced a 404 on hard loads. Since 16 every slot must have an explicit default.js. The upgrade guide says builds fail without it, and nextjs.org/docs/messages/slot-missing-default is the error. That sentence is only partly true in 16.3.8, and the exceptions are traps:

  • The build error is raised when the page being compiled sits in a segment that has plain child routes and one of its slots has neither a matching sub-route nor a default.js. Reproduction in the scratch project: a layout with @team/settings/page.tsx, @analytics/page.tsx, a page.tsx and a second child route about/page.tsx, with no defaults. next build then fails with Missing required default.js file for parallel route at /tiny/@analytics, under Turbopack and under --webpack alike.

  • It was not raised when the layout’s only children were the slots' own pages (a leaf segment), nor for the dashboard when @team/default.tsx alone, or dashboard/default.tsx alone, was deleted. Those builds passed and the missing fallback only appeared at request time: a hard load of /dashboard/views rendered the 404 page in place of the whole dashboard.

  • It was not raised for the Bookshelf when @modal/default.tsx was deleted, with or without a @modal/[…​catchAll] page next to the intercepting route. The build succeeded and curl localhost:3305/en returned 404 on a hard load of every page under app/[locale].

The Bookshelf without app/[locale]/@modal/default.tsx (build passed, request failed)
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:3305/en
404

So treat the build error as a safety net with holes: create default.tsx for every slot as part of creating the slot, and for every children slot that has sibling slots. In next dev the same missing file simply produced the 404.

Two more observations on the fallback itself. In a prerendered route (the dashboard) the notFound() variant rendered the default 404 UI with a noindex meta tag, but the HTTP status line was still 200, because the document is prerendered; a dynamic route (the Bookshelf’s missing-default case above) answered a real 404. Do not rely on the status code when the fallback is notFound() on a prerendered route; return null for "nothing to show" and keep notFound() for slots that must never be reached.

Conditional Routes

A layout is free to render only some of its slots. The documentation shows a role switch: render the admin slot for administrators and the user slot for everyone else. The same layout in the scratch project, with the role read from a cookie (for the demonstration only; a real application reads it from a verified session):

app/account/layout.tsx — choose a slot per request
import { Suspense } from "react";
import { getRole } from "@/lib/role";

async function RoleSwitch({
  admin,
  member,
}: {
  admin: React.ReactNode;
  member: React.ReactNode;
}) {
  const role = await getRole();
  return role === "admin" ? admin : member;
}

export default function AccountLayout({ admin, member }: LayoutProps<"/account">) {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <RoleSwitch admin={admin} member={member} />
    </Suspense>
  );
}
lib/role.ts — demonstration only; derive the role from a verified session in an application
import "server-only";
import { cookies } from "next/headers";

export type Role = "admin" | "member";

export async function getRole(): Promise<Role> {
  const jar = await cookies();
  return jar.get("demo-role")?.value === "admin" ? "admin" : "member";
}
One URL, two pages
$ curl -s localhost:3305/account | grep -o "<h1>[^<]*"
<h1>My account</h1>
$ curl -s -b "demo-role=admin" localhost:3305/account | grep -o "<h1>[^<]*"
<h1>Admin console</h1>

The layout reads cookies(), so the wrapper that does it sits in <Suspense>; the cookies() call is what makes the route dynamic (ƒ /account), and under Cache Components the same placement is mandatory. The slot sub-folders need default.tsx as usual (@admin/default.tsx, @member/default.tsx and account/default.tsx above).

The security consequence is the most important sentence of this section. Both slots render on the server, whichever one the layout returns. The conditional decides what the browser is shown, not what runs: @admin/page.tsx executes its data fetches for every visitor, and its output is part of the RSC payload that the layout selects from. Do not put an authorisation check only in the layout. Authorise inside each slot’s page, or better in the data access layer that the page calls (see the data-security page), so that @admin/page.tsx cannot read admin data for a non-admin even if a later change renders it by mistake.

Tab Groups

A layout.tsx inside a slot makes the slot independently navigable: its children are the slot’s own sub-pages and the rest of the page is untouched. The analytics slot has two sub-pages and a layout with the tab links:

app/dashboard/@analytics/layout.tsx
import Link from "next/link";

export default function AnalyticsLayout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <nav>
        <Link href="/dashboard/views">Page views</Link>{" "}
        <Link href="/dashboard/visitors">Visitors</Link>
      </nav>
      <div>{children}</div>
    </>
  );
}

The tab links are normal <Link href> to the full URL; clicking Visitors changes the URL to /dashboard/visitors and re-renders only the analytics slot (the table above shows team and children unchanged). Each tab is bookmarkable because it is a real URL.

To highlight the active tab from the parent layout, or any client component below it, pass the slot name to useSelectedLayoutSegment (or useSelectedLayoutSegments for the whole list). Both hooks take an optional parallelRouteKey for this purpose:

components/active-tab.tsx — read the active segment of one slot
"use client";

import { useSelectedLayoutSegment } from "next/navigation";

// Rendered by the parent layout: reads the active segment of the "analytics" slot.
export function ActiveTab() {
  const segment = useSelectedLayoutSegment("analytics");
  return <p data-testid="active-tab">Active analytics tab: {segment ?? "none"}</p>;
}

In the browser the component rendered Active analytics tab: views on /dashboard/views and visitors on /dashboard/visitors. On /dashboard, where the slot shows its root page and no sub-segment, it rendered the placeholder string (SLOT) instead of null. The placeholder is an internal marker, not a documented value; if you compare the result with a tab name, compare with the exact segment (segment === "views") rather than testing for a non-null value.

Intercepting Routes

An intercepting route loads another route from a different part of the app into the current layout, instead of switching the whole screen to it. The URL still changes to the intercepted route’s address, so the result can be shared and refreshed. Whether a route is intercepted depends on how the navigation started:

  • On a soft navigation from a page below the intercepting folder’s layout, the intercepting page renders.

  • On a hard navigation to the same URL, no interception happens: the real route renders, as a full page.

The (.), (..), (..)(..) and (…​) Conventions

The folder prefix says where the intercepted route lives, relative to the folder that contains the intercepting route. It resembles ../ in a file path but counts route segments.

Table 3. Matchers and the segment they match
Prefix Matches the route …​ Example (folder of the intercepting page ⇒ route it intercepts)

(.)

at the same segment level

app/[locale]/@modal/(.)books/[id] ⇒ /[locale]/books/[id]

(..)

one segment above

app/feed/@preview/(..)photo/[id] ⇒ /photo/[id]

(..)(..)

two segments above

app/gallery/albums/@preview/(..)(..)photo/[id] ⇒ /photo/[id]

(…​)

from the root app directory

app/gallery/albums/2024/@preview/(…​)photo/[id] ⇒ /photo/[id]

Read the first row carefully: the intercepting page is inside @modal, two folders below [locale] in the file system ([locale]/@modal/), and still uses (.). The slot folder is not a route segment, so books/[id] is at the same segment level as the [locale] layout that owns the slot. Using (..) there is the most common mistake, and it produces a route that never matches.

All four conventions were built and requested. next build lists the intercepting routes under their literal names, and no slot appears as a segment:

next build output (Next.js 16.3.8) — intercepting routes appear with their matcher
├ ƒ /[locale]/(.)books/[id]
├ ƒ /[locale]/[...catchAll]
├ ƒ /feed/(..)photo/[id]
├ ƒ /gallery/albums/(..)(..)photo/[id]
├ ƒ /gallery/albums/2024/(...)photo/[id]
└ ƒ /photo/[id]

The matchers are exercised by the page tree below. /photo/[id] is the real, full page; the other three folders intercept it from three different depths:

Where each matcher sits (all intercept /photo/[id])
app/
├── photo/[id]/page.tsx                                   # the real page, /photo/42
├── feed/
│   ├── layout.tsx, page.tsx, default.tsx
│   └── @preview/(..)photo/[id]/page.tsx                  # (..): one segment above feed
├── gallery/albums/
│   ├── layout.tsx, page.tsx, default.tsx
│   └── @preview/(..)(..)photo/[id]/page.tsx              # (..)(..): two segments above albums
└── gallery/albums/2024/
    ├── layout.tsx, page.tsx, default.tsx
    └── @preview/(...)photo/[id]/page.tsx                 # (...): from the app root

Each intercepting folder needs the usual companions: a layout that renders preview, a default.tsx in @preview and the sibling default.tsx for children. The page that was verified for (..):

app/feed/layout.tsx and @preview/(..)photo/[id]/page.tsx
// app/feed/layout.tsx
export default function FeedLayout({ children, preview }: LayoutProps<"/feed">) {
  return (
    <>
      {children}
      {preview}
    </>
  );
}

// app/feed/@preview/(..)photo/[id]/page.tsx
import { Suspense } from "react";
import { PhotoId } from "@/components/photo-id";

export default function Page({ params }: { params: Promise<{ id: string }> }) {
  return (
    <aside>
      Photo (intercepted) <Suspense fallback="…"><PhotoId params={params} /></Suspense>
    </aside>
  );
}

// components/photo-id.tsx
export async function PhotoId({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return <>{id}</>;
}

How Interception Is Decided

The decision is made on the server from one request header. When the client router fetches the RSC payload for a soft navigation it sends the URL of the page the user is on in the Next-Url request header. The server compares the target URL with the intercepting routes that exist below the layouts of that current URL; on a match it renders the intercepting route instead. A hard load carries no Next-Url, so nothing is intercepted.

The mechanism was observed directly. The Bookshelf request below is the one a soft navigation from /en to /en/books/2 makes (RSC: 1 asks for the payload, Next-Url names the current page, _rsc is a cache-busting token); the response reports the internal rewrite in x-nextjs-rewritten-path:

The same URL, three requests
$ curl -s -L -i -H "RSC: 1" -H "Next-Url: /en" "localhost:3305/en/books/2?_rsc=x" | grep -a -o "x-nextjs-rewritten-path: [^ ]*"
x-nextjs-rewritten-path: /en/(.)books/2

$ curl -s -L -i -H "RSC: 1" -H "Next-Url: /dashboard" "localhost:3305/photo/42?_rsc=x" | grep -a -o "full page"
full page

$ curl -s localhost:3305/photo/42 | grep -a -o "full page"
full page

The first request is intercepted because the current page /en is under the layout that owns @modal. The second asks for /photo/42 while the browser is on /dashboard, which is not under app/feed, so the real page renders. The third is a hard load. The same header with Next-Url: /feed against /photo/42 returned x-nextjs-rewritten-path: /feed/(..)photo/42, /gallery/albums returned /gallery/albums/(..)(..)photo/7 and /gallery/albums/2024 returned /gallery/albums/2024/(…​)photo/7.

flowchart TD req["Request for /en/books/2"] --> how{"How did it start?"} how -->|"soft: Link click or router.push
RSC request with Next-Url /en"| m{"An intercepting route under
the layouts of /en matches
/en/books/2?"} m -->|"yes: (.)books/[id]"| i["Render /en/(.)books/2 in the modal slot
children keep the catalogue
URL shows /en/books/2"] m -->|"no"| n["Render the real route books/[id]
as a normal navigation"] how -->|"hard: refresh, shared link,
new tab, crawler
no Next-Url"| h["Render the real route books/[id]
as a full page
@modal falls back to default.js, null"]

The same flow as a time line for the Bookshelf, including what the history stack does:

sequenceDiagram actor U as User participant B as Browser router participant S as Server Note over U,S: Soft load U->>B: click a cover link, href /en/books/2 B->>S: RSC request /en/books/2, Next-Url /en S-->>B: payload with the @modal slot filled by the intercepting page B-->>U: modal over the catalogue, URL /en/books/2, new history entry U->>B: Back, or Close and Escape call router.back B-->>U: URL /en, modal slot unmounted, catalogue still on screen Note over U,S: Hard load U->>B: refresh or open /en/books/2 directly B->>S: document request /en/books/2, no Next-Url S-->>B: HTML of the real book page, @modal renders default.js B-->>U: full book page, no modal

The documentation lists four problems that this pattern solves, and each can be checked in the Bookshelf:

  • The modal content is shareable through a URL: /en/books/2 is a real page.

  • On a refresh, the URL renders the full page rather than a modal floating over a catalogue that was not loaded (see the hard-load behaviour above; the documentation’s bullet about "preserving context" means that the URL keeps its meaning, not that the modal stays open).

  • Back closes the modal instead of leaving the catalogue, because opening it pushed a history entry.

  • Forward reopens it.

The Bookshelf’s catalogue links to /en/books/2 with a normal <Link>. What turns that link into a modal is one slot, one intercepting page, three "no modal" pages and one client component. They extend the layout described in Project Structure: the root layout under [locale] receives modal next to children.

The files that make the cover modal
app/[locale]/
├── layout.tsx                     # renders {children}{modal}
├── page.tsx                       # the catalogue with <Link href="/en/books/2">
├── books/[id]/page.tsx            # the real, full book page (hard load)
└── @modal/
    ├── default.tsx                # hard load: no modal
    ├── page.tsx                   # soft navigation to /en: no modal
    ├── [...catchAll]/page.tsx     # soft navigation to other URLs: no modal
    └── (.)books/[id]/page.tsx     # the modal, shown on soft navigation
components/modal.tsx               # client component: dialog + router.back()
app/[locale]/layout.tsx — the layout renders the slot beside children
import { notFound } from "next/navigation";
import { locales, isLocale } from "@/lib/i18n";

export function generateStaticParams() {
  return locales.map((locale) => ({ locale }));
}

export default async function RootLayout({
  children,
  modal,
  params,
}: LayoutProps<"/[locale]">) {
  const { locale } = await params;
  if (!isLocale(locale)) notFound();
  return (
    <html lang={locale}>
      <body>
        {children}
        {modal}
      </body>
    </html>
  );
}
The three "no modal" files of the @modal slot
// app/[locale]/@modal/default.tsx -- hard load: nothing to show
export default function Default() {
  return null;
}

// app/[locale]/@modal/page.tsx -- soft navigation to /en (the layout's own URL): close the modal
export default function ModalHome() {
  return null;
}

// app/[locale]/@modal/[...catchAll]/page.tsx -- soft navigation to any deeper URL: close the modal
export default function CatchAll() {
  return null;
}

The last two files are not decoration. After a soft navigation the slot keeps its last state, so when the modal is open and the user follows a normal <Link> or router.push, the @modal slot still shows the book unless it matches the new URL with a page that renders null. Both were observed. With only default.tsx, pushing /en?q=sol from the open modal left the dialog open on top of the new catalogue. Adding @modal/page.tsx closed it. The catch-all ([…​catchAll] needs at least one segment, so it cannot match /en itself) does the same for deeper URLs: after router.push("/en/nope") the dialog was gone. The documentation’s own example makes the same point with an @auth/page.tsx returning null. Going back with router.back() needs neither, because the slot returns to its previous entry. They do not replace default.tsx: with default.tsx deleted, the build passed and /en answered 404 on a hard load (see the default.js section), because default.tsx is what a hard load renders for a slot with no match.

app/[locale]/@modal/(.)books/[id]/page.tsx — the intercepting page
import { Suspense } from "react";
import { notFound } from "next/navigation";
import { BookDetail } from "@/app/[locale]/books/[id]/book-detail";
import { Modal } from "@/components/modal";
import { dict, isLocale } from "@/lib/i18n";

async function ModalContent({
  params,
}: {
  params: PageProps<"/[locale]/books/[id]">["params"];
}) {
  const { locale, id } = await params;
  if (!isLocale(locale)) notFound();
  return <BookDetail id={id} label={dict[locale].send} />;
}

export default function BookModal({ params }: PageProps<"/[locale]/books/[id]">) {
  return (
    <Modal>
      <Suspense fallback={<p>Loading…</p>}>
        <ModalContent params={params} />
      </Suspense>
    </Modal>
  );
}
components/modal.tsx — the only client code; the book content stays a Server Component
"use client";

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

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

  useEffect(() => {
    const dialog = dialogRef.current;
    if (dialog && !dialog.open) dialog.showModal(); // modal: focus trap, Escape, ::backdrop
  }, []);

  return (
    <dialog
      ref={dialogRef}
      aria-label="Book details"
      // Escape fires "cancel": let the router close the modal (and pop the history entry)
      onCancel={(event) => {
        event.preventDefault();
        router.back();
      }}
    >
      <button type="button" onClick={() => router.back()}>
        Close
      </button>
      {children}
    </dialog>
  );
}

The design choices that are easy to get wrong:

  • The modal shell is a Client Component, its content is not. Modal takes children, so BookDetail (which reads data on the server and contains a form with a Server Action) stays a Server Component and is passed through the client boundary as a prop. Importing the content into modal.tsx would pull it, and its server-only dependencies, into the client bundle.

  • Use router.back() to close. It pops the history entry that opening the modal pushed, so the URL and the Back button agree. A <Link href="/en"> also works, and then needs @modal/page.tsx returning null so the slot matches /en.

  • Use the native <dialog> with showModal(). A plain <dialog open> or a styled <div> is not modal: focus is not trapped and Escape does nothing. showModal() gives the focus trap, Escape (the cancel event, which the handler turns into router.back()) and the ::backdrop. The !dialog.open guard avoids a second call under React Strict Mode in development.

  • The first line of the modal page is a normal page. It receives params and can read anything a page can, which is the point of intercepting a route instead of fetching the data in the client.

The same files were built and exercised in a real browser (Next.js 16.3.8, production build):

Table 4. Cover modal behaviour, observed
Action URL afterwards Modal Catalogue behind it

Hard load of /en

/en

none

shown

Click the link to book 2 (soft)

/en/books/2

open, native modal dialog

still rendered

Browser Back

/en

closed

shown

Browser Forward

/en/books/2

open

shown

Click Close

/en

closed

shown

Press Escape (the cancel event)

/en

closed

shown

Hard load of /en/books/2

/en/books/2

none (curl found no <dialog> and one <article>)

not part of the page

Under Cache Components the same tree built and the intercepted request returned the same x-nextjs-rewritten-path: /en/(.)books/2 header; the route table then shows ◐ Partial Prerender for the intercepting route instead of ƒ.

The Cache Components Lesson

An intercepting page has no generateStaticParams, and its params are request-time data. With cacheComponents: true, awaiting params directly in the page component, outside <Suspense>, fails the build:

Awaiting params at the top of an intercepting page, under Cache Components (Next.js 16.3.8)
$ CACHE_COMPONENTS=1 npx next build
Error: Route "/feed/(..)photo/[id]": Next.js encountered uncached or runtime data during prerendering.

`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>`
prevents the route from being prerendered, blocking the page load and leading to a slower user experience.

Ways to fix this:
  - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
  - [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)
  - [block] Set `export const instant = false` to allow a blocking route

Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic

The previous model builds the same page without complaint, so the error only appears once the project switches models. The fix is the one the Bookshelf modal already uses: read params in a child component under <Suspense>, so the modal’s frame (the dialog and its Close button) is part of the static shell and the book content streams in. The [block] option (export const instant = false) makes the route block instead, which for a modal means the whole navigation waits. The same applies to the children slot of a layout that awaits params and to the real /photo/[id] page; the full page was fixed in the same way in the examples above.

Pages Router: No Equivalent

The Pages Router has no slots and no interception: one page file renders per URL inside _app. The usual substitute for a modal with its own address is state in the query string: the catalogue page renders the modal when a query parameter such as ?book=2 is present, opens it with router.push and the shallow option (so no data fetching reruns), and the real /books/2 page remains the target for shared links and crawlers. It gives a deep link and working Back and Forward, but the URL shown is the query-string one, the modal content is fetched on the client or by the catalogue’s own data functions, and nothing keeps the two renderings in sync. If a Pages Router application needs interception semantics (a different component for soft and hard loads of the same URL), that is a reason to move that area to the App Router, next to pages/ as the migration guidance describes.

Pitfalls and Errors

Table 5. Parallel and intercepting route pitfalls
Pitfall Symptom Fix

No default.js for a slot (or for children)

Hard load answers 404 for every page under that layout; sometimes Missing required default.js file for parallel route at build time (nextjs.org/docs/messages/slot-missing-default), but the build check has holes

Create default.tsx returning null (or calling notFound()) for every slot and for children whenever sibling slots exist

Wrong interception depth, (..) where (.) is right

The link navigates to the full page; no modal; no error

Count route segments, not folders; skip @slot folders and route groups; read the route name in the next build table (/[locale]/(.)books/[id])

Layout does not render the slot prop

The slot’s page never appears

Destructure the slot (typed by LayoutProps) and render it

Mixed static and dynamic slots at one level

The whole segment level becomes dynamic (ƒ)

Keep the dynamic slot on its own sub-route; do not expect one slot to stay static next to a dynamic sibling

A modal that stays open after a normal link

Soft navigation keeps the slot’s last state; the dialog stays on top of the next page

Add @modal/page.tsx returning null (for the layout’s own URL) and @modal/[…​catchAll]/page.tsx (for deeper URLs)

Closing with router.push("/")

Back reopens the modal; the history grows

Close with router.back() when the modal was opened by a soft navigation

Reading params outside <Suspense> in an intercepting page under Cache Components

Build error blocking-prerender-dynamic

Read params in a child under <Suspense>

Layout-only authorisation in a conditional slot

Admin data is fetched and sent for everyone

Authorise in each slot’s page or in the data access layer

Expecting useSelectedLayoutSegment("slot") to be null on the slot’s root

The value (SLOT) is returned

Compare with exact segment names

Importing server content into the Client Component Modal

Server-only code in the client bundle

Pass the content as children

What Changed Recently

Table 6. Version history of parallel and intercepting routes
Version Change

13.3 (2023-04-06)

Parallel routes and intercepting routes introduced.

15

params in default.js, layouts and pages became promises; default.js reads them with await or use.

15.5

Typed route helpers: global LayoutProps (with slot props inferred from the file tree), PageProps and RouteContext; next typegen.

16.0

Every parallel-route slot needs an explicit default.js; notFound() reproduces the old behaviour. The build check described above is incomplete in 16.3.8.

16.2 and 16.3

The error boundary retry function is unstable_retry in 16.2 and stable retry in 16.3.

16.3

cacheComponents stays opt-in; under it the intercepting page must keep params under <Suspense>. The parallel-routes reference (last updated 2026-08-25) now states that both slots of a conditional layout render on the server.

Where the Books Are Outdated

A full-text search of both requester-provided books found no discussion of parallel routes, intercepting routes, @slot folders or default.js. Real-World Next.js (Packt, 2022) predates them and mentions modals only as a CSR example and as a Headless UI component; Practical Next.js for E-Commerce (Apress, 2023) does not mention them either. Both are written around the Pages Router, so every example needs the translation in the previous section. A reader who follows the books and needs a quick-view modal, a login overlay or a cart drawer will find the technique only in the official documentation, in this page, and in the nextgram example.