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
| Convention | What it does | Affects the URL? |
|---|---|---|
|
Defines a slot. The enclosing layout receives it as a prop named |
No. |
|
The implicit slot. |
No. |
|
Fallback rendered for a slot (including |
No. |
|
Intercepts a route at the same segment level. |
The browser shows the intercepted route’s URL. |
|
Intercepts a route one segment above. |
Same. |
|
Intercepts a route two segments above. |
Same. |
|
Intercepts a route from the root |
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:
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
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:
├ ○ /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.tsxanderror.tsx. A slow or failing slot does not block or break the others. In the scratch dashboard@analytics/visitors/page.tsxwaits 1.5 s. A hard load of/dashboard/visitorsanswered its first byte after 39 ms and the full document after 1.53 s, withLoading analytics…in the HTML first and the real content streamed in afterwards. A soft navigation to the same URL showedLoading 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.tsxthrows, replaced only the team panel with the@team/error.tsxfallback (The team panel failed to load.and a Retry button);childrenandanalyticswere 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
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>;
}
"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.
Source: Parallel Routes, loading.js, error.js
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:
| Step | team slot |
analytics slot |
URL |
|---|---|---|---|
Hard load of |
Team: 3 members |
Analytics: pick a tab |
|
Soft: click Team settings |
Team settings |
Analytics: pick a tab (kept) |
|
Soft: click Page views |
Team settings (kept) |
Analytics: page views |
|
Soft: click Overview |
Team: 3 members |
Analytics: pick a tab |
|
Hard load of |
empty ( |
Analytics: page 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.
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.
// 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, apage.tsxand a second child routeabout/page.tsx, with no defaults.next buildthen fails withMissing required default.js file for parallel route at /tiny/@analytics, under Turbopack and under--webpackalike. -
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.tsxalone, ordashboard/default.tsxalone, was deleted. Those builds passed and the missing fallback only appeared at request time: a hard load of/dashboard/viewsrendered the 404 page in place of the whole dashboard. -
It was not raised for the Bookshelf when
@modal/default.tsxwas deleted, with or without a@modal/[…catchAll]page next to the intercepting route. The build succeeded andcurl localhost:3305/enreturned404on a hard load of every page underapp/[locale].
$ 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):
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>
);
}
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";
}
$ 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:
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:
"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.
| Prefix | Matches the route … | Example (folder of the intercepting page ⇒ route it intercepts) |
|---|---|---|
|
at the same segment level |
|
|
one segment above |
|
|
two segments above |
|
|
from the root |
|
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:
├ ƒ /[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:
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
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}</>;
}
Source: Intercepting Routes: Convention
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:
$ 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.
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:
Source: Intercepting Routes
The Deep-Linkable Cover Modal for the Bookshelf
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/2is 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 Files
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.
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()
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>
);
}
// 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.
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>
);
}
"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.
Modaltakeschildren, soBookDetail(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 intomodal.tsxwould 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.tsxreturningnullso the slot matches/en. -
Use the native
<dialog>withshowModal(). 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 (thecancelevent, which the handler turns intorouter.back()) and the::backdrop. The!dialog.openguard avoids a second call under React Strict Mode in development. -
The first line of the modal page is a normal page. It receives
paramsand can read anything a page can, which is the point of intercepting a route instead of fetching the data in the client.
Verified Behaviour
The same files were built and exercised in a real browser (Next.js 16.3.8, production build):
| Action | URL afterwards | Modal | Catalogue behind it |
|---|---|---|---|
Hard load of |
|
none |
shown |
Click the link to book 2 (soft) |
|
open, native modal dialog |
still rendered |
Browser Back |
|
closed |
shown |
Browser Forward |
|
open |
shown |
Click Close |
|
closed |
shown |
Press Escape (the |
|
closed |
shown |
Hard load of |
|
none ( |
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:
$ 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
| Pitfall | Symptom | Fix |
|---|---|---|
No |
Hard load answers 404 for every page under that layout; sometimes |
Create |
Wrong interception depth, |
The link navigates to the full page; no modal; no error |
Count route segments, not folders; skip |
Layout does not render the slot prop |
The slot’s page never appears |
Destructure the slot (typed by |
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 |
Closing with |
Back reopens the modal; the history grows |
Close with |
Reading |
Build error |
Read |
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 |
The value |
Compare with exact segment names |
Importing server content into the Client Component |
Server-only code in the client bundle |
Pass the content as |
What Changed Recently
| Version | Change |
|---|---|
13.3 (2023-04-06) |
Parallel routes and intercepting routes introduced. |
15 |
|
15.5 |
Typed route helpers: global |
16.0 |
Every parallel-route slot needs an explicit |
16.2 and 16.3 |
The error boundary retry function is |
16.3 |
|
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.