Intercepting routes and modals
Open a tea or a sign-in form in a modal on top of the page with intercepting routes, keep a shareable URL, and show the full page on a reload.
- 01Name an intercepting folderPick (.), (..), (..)(..) or (...) by counting route segments, and say which clicks it intercepts.
- 02Build a modal with a real addressCombine a @modal slot, an intercepting route and default.tsx, so a click opens a modal and a reload shows the full page.
- 03Close it the right wayClose with router.back(), keep the modal from following the visitor, and make it work with the keyboard and screen readers.
One address, two ways to show it
In the tea shop's list of teas, a visitor often wants a quick look at one tea without leaving the list. The tea should still have an address of its own, like /teas/green, that the visitor can share, bookmark and reload. Intercepting routes give one address two pages: one for a client-side navigation from the right part of the app, like a click on the tea in the list, and one for every other way in.
A folder whose name starts with (.), (..) or (...) is an intercepting route. The rest of its name is another route, the one it intercepts, counted from where the folder sits. Here, app/teas/(.)[id] intercepts /teas/[id], the address of each tea ([id] is a dynamic segment). This shop is a real Next.js 16.4.3 project, built with next build, run with next start, and clicked through in real Chrome. Its root layout shows a line, You are on /teas., from a small Client Component, app/address.tsx, that reads the address with usePathname(), so each step shows where the visitor is.
app/teas/(.)[id]/page.tsximport { findTea, teas } from "@/app/_lib/teas"; export function generateStaticParams() { return teas.map((tea) => ({ id: tea.id }));} export default async function QuickLook({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const tea = findTea(id); return ( <main> <p>Quick look: {tea?.name}, ${tea?.price} a box</p> </main> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
You are on /teas/green.
Quick look: Green tea, $4 a box
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
No requests during this step: the page already had what it needed
The file shown first is app/teas/(.)[id]/page.tsx. Line 12 is the whole quick look: one line with the tea's name and price. Lines 3 to 5 list every tea's id for the build with generateStaticParams, as the real tea page does (generateStaticParams explains why a page that awaits params needs it, or a loading state, with Cache Components on). The real page is app/teas/[id]/page.tsx: the tea's name, its note, its price and a link back to the list. The Steps tab lists three steps:
- Step 1, open
/teas: the page, then the router's prefetches, which ask early for the pages the links point to. Each one carries the headernext-url: /teas: the router telling the server which address the visitor is on. - Step 2, click Green tea: the address is
/teas/green, and the page showsQuick look: Green tea, $4 a box. Whatever the router still needed for it, it asked for with the samenext-url: /teas, or had already prefetched. - Step 3, reload
/teas/green: onedocumentrequest, for a whole page, and the full tea page:Green tea,Fresh and grassy. Brew it for 3 minutes.,$4 a boxandAll teas.
One address, two pages. A click on a Link is a soft navigation: Next.js's router changes the page in place, without loading a new HTML page; so is a call to router.push. Only a soft navigation can be intercepted, and only one that starts from the intercepting folder's route or below it: here, /teas. A reload, an address typed into the browser, or a link from another site is a hard navigation: the browser asks the server for a whole new page, and gets the real one. Here, the quick look took the list's place. A modal keeps the list and puts the tea on top of it, with one more folder: a slot, from Parallel routes.
/teas/green, has two pages. A click from /teas gets the intercepting one, in (.)[id]. A reload, or any other full page load, gets the real one, in [id].In a library, every book has a call number. If you're already at a reading table and ask for one, the librarian brings it to your table, and your other books stay open around it. If you walk in from the street with the same call number, you're shown to the shelf where it lives.
- In real life: The book's call number
- In Next.js: The address,
/teas/green - In real life: You ask at your reading table, and the book is brought to you
- In Next.js: A click from
/teas: the tea opens on top of the list - In real life: You walk in from the street with the call number
- In Next.js: A reload or a shared link: a hard navigation
- In real life: You're shown to the shelf where the book lives
- In Next.js: The full page,
app/teas/[id]/page.tsx
An intercepting route shows another route's address with a different page, but only on a client-side navigation that starts from the intercepting folder's route or below it. A reload or a shared link shows the real page. Put the intercepting route in a slot, and the page you came from stays underneath: a modal with an address of its own.
(.), (..), (..)(..) and (...) count route segments
The marker at the start of the folder's name says where the intercepted route starts, like ./ and ../ in a file path:
(.)starts at the same level as the folder's own route.(..)starts one level up.(..)(..)starts two levels up.(...)starts at the root of the app.
The levels are route segments: the parts of the address between slashes. Only folders that add a segment count. A slot like @modal adds none, and neither does a route group like (shop), from Route groups and private folders. So a folder inside app/teas/@modal counts from /teas, not from @modal. Here the shop's teas get a layout with a modal slot, and the cart opens in it:
app/teas/@modal/(.)cart/page.tsximport Modal from "@/app/modal"; export default function CartModal() { return ( <Modal title="Your cart"> <p>2 boxes of Green tea</p> </Modal> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
You are on /cart.
2 boxes of Green tea
- Focus
- the link “Cart”
- Scrolled
- 0 px down
No requests during this step: the page already had what it needed
(.) is the same level as /teas, so this folder means /teas/cart. The build passes, but a click on Cart goes to /cart, which it never matches: step 4 shows the full cart page.
Before you read on, pick each marker and look at step 4, a click on Cart from /teas. With (.)cart, the build passed and listed ○ /teas/(.)cart, but step 4 shows the full cart page: (.) counted from /teas, so the folder intercepts /teas/cart, an address no link goes to. With (..)cart and (...)cart, step 4 shows Your cart in the modal, on top of the list. (..)(..)cart would start above the root, and the build stops: Cannot use (..)(..) marker at the root level or one level up. Each row is one marker, in a folder inside app/teas/@modal:
| Marker | Where it starts | What it means here |
|---|---|---|
(.) | The same segment level as the folder's route: /teas. | (.)cart is /teas/cart. |
(..) | One segment up from /teas: /. | (..)cart is /cart. |
(..)(..) | Two segments up from /teas: above the root. | The build stops. |
(...) | The root, wherever the folder is. | (...)cart is /cart. |
@modal isn't a segment, so a folder in app/teas/@modal counts from /teas. Two segments up from there is above the root, and the build stops.Next.js's own error proves that slots don't count. Put the tea modal in app/@modal/(..)teas/[id], and the build stops with Invalid interception route: /(..)teas/[id]. Cannot use (..) marker at the root level, use (.) instead. The route it names has no @modal in it: Next.js dropped the slot, found the folder at the root, and saw nothing above it.
Steps 1 and 2 show the other half of the rule: where the visitor is. In every version, step 2 clicks Cart on /, and the full cart page shows, even with (..)cart. An intercepting route only catches clicks from the route its folder belongs to, or a route below it. That's what next-url is for: app/teas/@modal/(..)cart intercepts /cart only when next-url is /teas or starts with /teas/.
Lines 166 to 211 of interception-routes.ts turn a folder into the route it intercepts. Line 166 removes groups and slots from the folder's route, then one case per marker counts segments from there. Then lines 34 to 55 of generate-interception-routes-rewrites.ts make the rule for each one: the intercepted address, and a next-url that is the folder's route or below it.
A modal: a slot, an interception and default.tsx
A modal is a box on top of the page that the visitor has to deal with, or close, before using the page behind it. With an address of its own, it's four files:
app/layout.tsxrenders the slot:{modal}on line 15, after{children}.app/@modal/default.tsxreturnsnull. It's what the slot shows when none of its pages matches the address: on every page without a modal.app/@modal/(.)teas/[id]/page.tsxis the tea, in a modal.@modalisn't a segment, so(.)is the root's level, and the folder intercepts/teas/[id].app/teas/[id]/page.tsxis the tea's full page, for every other way in.
app/@modal/(.)teas/[id]/page.tsximport { notFound } from "next/navigation";import Modal from "@/app/modal";import { findTea, teas } from "@/app/_lib/teas"; export function generateStaticParams() { return teas.map((tea) => ({ id: tea.id }));} export default async function TeaModal({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const tea = findTea(id); if (!tea) notFound(); return ( <Modal title={tea.name}> <p>{tea.note}</p> <p>${tea.price} a box</p> <a href={`/teas/${tea.id}`}>Open the full page</a> </Modal> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
You are on /teas/green.
- Green tea
- Mint tea
Fresh and grassy. Brew it for 3 minutes.
$4 a box
Open the full page- Focus
- the button “Close”
- Scrolled
- 0 px down
Requests the page made during this step (2), without the files it loaded
GET/teas/green200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /
GET/teas/mint200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /
In the file shown first, line 14 wraps the tea in Modal, from app/modal.tsx, and line 17 is a plain <a> to the full page. The Steps tab opens on step 2, the click on Green tea. The address is /teas/green. The page still shows Our teas and both links, and after them the modal: Close, the heading Green tea, its note, its price and Open the full page. The intercepted answer had nothing new for children, so the router kept the list there, the way a slot keeps its page on a click.
The Terminal tab lists /(.)teas/[id] with ○ /(.)teas/green and ○ /(.)teas/mint, beside /teas/[id]: Next.js prerendered the modal's version of each tea as well as its full page. In the Network tab, /teas/green answered with Vary naming next-url. Vary tells caches that the answer depends on those request headers, so they keep the modal and the full page apart. /teas, which nothing intercepts, doesn't name it.
@modal with the intercepting page. children has nothing new for /teas/green, so it keeps the list underneath.Next.js 16.4.3 checks that the full page is there. Take app/teas/[id]/page.tsx away, and the build stops:
app/@modal/(.)teas/[id]/page.tsximport { notFound } from "next/navigation";import Modal from "@/app/modal";import { findTea, teas } from "@/app/_lib/teas"; export function generateStaticParams() { return teas.map((tea) => ({ id: tea.id }));} export default async function TeaModal({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const tea = findTea(id); if (!tea) notFound(); return ( <Modal title={tea.name}> <p>{tea.note}</p> <p>${tea.price} a box</p> <a href={`/teas/${tea.id}`}>Open the full page</a> </Modal> );}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...⚠ Strict route matching is enabled by default. This validation indicates a bug in your app's route structure, but you can temporarily restore loose route matching by setting `deprecated.looseRouteMatching` to `true` in your Next.js config. > Build error occurredError: Turbopack build failed with 1 error:./app/@modal/(.)teas/[id]/page.tsxError: Interception routes must have a canonical routeThe following interception routes do not have a canonical route:- /(.)teas/[id] (expected /teas/[id]) Every interception route must have a matching non-interception route so the URL can be loaded directly or refreshed. at ignore-listed framesThe build says Interception routes must have a canonical route, and names it: /(.)teas/[id] (expected /teas/[id]). A canonical route is the ordinary page for the same address, the one a reload gets. Without it, a reload of /teas/green would have nothing to show. Next.js 16.4 added this check to its strict route matching, which Parallel routes met.
A click or a reload decides what shows
What shows depends on how the visitor arrived, not on the address. Each row is one thing the visitor did in the recorded steps of the modal above:
| What the visitor did | What showed | Recorded in |
|---|---|---|
Clicks Green tea on /teas | The modal, on top of the list. | Steps 2, 4 and 8. |
| Presses Escape, or clicks Close | The list, at /teas. | Steps 3 and 5. |
| Presses the browser's Back button | The list, at /teas. | Step 7. |
Clicks Open the full page, a plain <a> | The tea's full page, after a full page load. | Step 9. |
Reloads /teas/green, or opens it from a shared link | The tea's full page. | Step 10. |
Steps 9 and 10 are two ways to the full page. Step 9 clicks Open the full page, the plain <a> on line 17. A plain link isn't handled by Next.js's router, so the browser loads a whole new page: the step made one document request, and a full page load is never intercepted. Step 10 reloads, with the same result. That's how a modal can offer its full page.
Step through both below. Before you start, guess which step decides between the modal and the full page:
A click from /teasafter step 1Home Teas Cart
You are on /teas.
Our teas
Green tea
Mint tea
React isn't running yet: clicks do nothing.
The page isn't complete yet.
A full page load of the list: the HTML arrives with both teas in it.
In the click, step 4 decides. The address and next-url both match the intercepting route, so the server answers with the modal's page. In the reload, there's no next-url to read: a whole page is asked for by the browser, which knows nothing about where the visitor was. The recorded document requests carry none of the router's headers.
Closing the modal
The modal has its own address, so closing it means leaving that address, back to /teas. The recording closes it three ways:
- Escape, in step 3, and the Close button, in step 5. The button sits in a
<form method="dialog">, on lines 16 to 18 ofapp/modal.tsx: a form that closes its dialog instead of sending anything. Both close the<dialog>, and line 15'sonClosecallsrouter.back(). - The browser's Back button, in step 7. The browser goes back on its own: the address is
/teas, the modal is gone, and the address stayed/teas, one entry back, not two.
<dialog>, and its onClose calls router.back(). The browser's Back button goes back on its own. All three end on /teas, one history entry back.router.back() goes to the previous entry in the browser's history, like the Back button, as Linking and navigating showed. Closing the modal that way leaves the history as the visitor saw it: the list, then the tea they opened, and back. Next.js's docs also show closing with a Link to /teas; that's a new navigation, with a new history entry, and it needs the catch-all page further down.
router.back() assumes there's a page to go back to, inside the shop. The modal only exists after a client-side navigation from /teas or below, so for the modal that's always true. The full page is different: a visitor who opens a shared link arrives on it directly. Here the full page has a Back button that calls router.back(), and Chrome opened its address the way a shared link does:
app/teas/[id]/page.tsximport BackButton from "@/app/back-button";import { notFound } from "next/navigation";import { findTea, teas } from "@/app/_lib/teas"; export function generateStaticParams() { return teas.map((tea) => ({ id: tea.id }));} export default async function TeaPage({ params }: PageProps<"/teas/[id]">) { const { id } = await params; const tea = findTea(id); if (!tea) notFound(); return ( <main> <h1>{tea.name}</h1> <p>{tea.note}</p> <p>${tea.price} a box</p> <BackButton /> </main> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
No requests during this step: the page already had what it needed
Step 2 clicked Back, and the visitor left the shop. The recording labels the address blank: that's Chrome's empty tab, about:blank, which was this window's previous page. For a real visitor, it's the chat app the link came from, or nothing at all in a new tab. So the full page links to the list with a Link, as line 18 of the real app/teas/[id]/page.tsx does, and only the modal uses router.back().
One more trap. Give the modal a link to another page, like Go to your cart, and click it. Guess first: does the modal close?
app/@modal/(.)teas/[id]/page.tsximport Link from "next/link";import { notFound } from "next/navigation";import Modal from "@/app/modal";import { findTea, teas } from "@/app/_lib/teas"; export function generateStaticParams() { return teas.map((tea) => ({ id: tea.id }));} export default async function TeaModal({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const tea = findTea(id); if (!tea) notFound(); return ( <Modal title={tea.name}> <p>{tea.note}</p> <p>${tea.price} a box</p> <a href={`/teas/${tea.id}`}>Open the full page</a> <Link href="/cart">Go to your cart</Link> </Modal> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
You are on /cart.
2 boxes of Green tea
Fresh and grassy. Brew it for 3 minutes.
$4 a box
Open the full page Go to your cart- Focus
- the link “Go to your cart”
- Scrolled
- 0 px down
No requests during this step: the page already had what it needed
Go to your cart is a client-side navigation to /cart. @modal has no page for /cart, so it keeps its last page: the modal stays open on top of the cart.
With only default.tsx, the cart shows, and the modal stays open on top of it. It's the parallel routes rule again: on a client-side navigation, a slot with nothing for the new address keeps its last page. default.tsx only shows when no page in the slot matches the address. The fix is a page in the slot that matches the other addresses and shows nothing: app/@modal/[...rest]/page.tsx, a catch-all segment (Dynamic routes showed them) that returns null. It handles every address outside the modal that has at least one segment, like /cart, on a click and after a full page load alike. Keep default.tsx too: it's the fallback when no page in the slot matches, which here is mainly /, because [...rest] needs at least one segment.
A modal everyone can use
A modal blocks the page behind it, so people who use a keyboard or a screen reader need to know it's there and how to leave it. The HTML <dialog> element, opened with its showModal() method, does most of that for you. Here is app/modal.tsx again, with the steps that pressed Escape:
app/modal.tsx"use client"; import { useEffect, useRef } from "react";import { useRouter } from "next/navigation"; export default function Modal({ title, children }: { title: string; children: React.ReactNode }) { const router = useRouter(); const dialog = useRef<HTMLDialogElement>(null); useEffect(() => { dialog.current?.showModal(); }, []); return ( <dialog ref={dialog} aria-labelledby="modal-title" onClose={() => router.back()}> <form method="dialog"> <button>Close</button> </form> <h2 id="modal-title">{title}</h2> {children} </dialog> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
You are on /teas.
- Green tea
- Mint tea
- Focus
- the link “Green tea”
- Scrolled
- 0 px down
No requests during this step: the page already had what it needed
Line 8 makes a ref to the <dialog> element, and the Effect on lines 10 to 12 calls showModal() once the dialog is on the page. Line 15 names the dialog with aria-labelledby, which points at the heading on line 19, so screen readers can announce the dialog by its heading, like Green tea. Each row is one thing a modal needs: how <dialog> gives it, and where the recording shows it.
| What it needs | How the dialog gives it | Where you can see it |
|---|---|---|
| Focus moves into the modal. | showModal() focuses the first thing in it you can focus. | Steps 2, 4, 6 and 8: focus on Close. |
| Escape closes it. | The browser closes the dialog; onClose calls router.back(). | Step 3: back on /teas. |
| Focus goes back where it was. | Closing the dialog returns focus to what had it before. | Steps 3 and 5: focus on the Green tea link. |
| The page behind can't be used. | showModal() makes the rest of the page inert. | Not recorded: from the HTML standard. |
| Screen readers hear its name. | aria-labelledby points at the heading. | Line 15 of app/modal.tsx. |
Steps 2, 4, 6 and 8 have the focus on Close, the first thing in the dialog you can focus. Steps 3 and 5 have it back on the Green tea link, so a keyboard user carries on from where they were (Keyboard and focus explains focus). The page behind being inert (it can't be clicked, and Tab skips it) comes from the HTML standard for showModal(); the recording doesn't show it.
A <div> styled to look like a box on top of the page moves no focus, ignores Escape, and leaves the page behind it usable. A <dialog open>, shown with the open attribute instead of showModal(), is a non-modal dialog: the page behind stays usable. Use showModal(), and see Accessible React for the rest.
A sign-in form in the same slot
One slot can hold many modals. The shop adds Sign in to its links. app/@modal/(.)sign-in/page.tsx shows the form in the same Modal, and app/sign-in/page.tsx is the full page. Both import one form, from app/sign-in/sign-in-form.tsx, so they never drift apart. The form is a stand-in: it doesn't sign anyone in. The Authentication lesson builds a real one. The slot also has the catch-all page.
app/@modal/(.)sign-in/page.tsximport Modal from "@/app/modal";import SignInForm from "@/app/sign-in/sign-in-form"; export default function SignInModal() { return ( <Modal title="Sign in"> <SignInForm /> </Modal> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
You are on /sign-in.
Fresh tea, sent to your door.
- Focus
- the button “Close”
- Scrolled
- 0 px down
No requests during this step: the page already had what it needed
In the file shown first, lines 6 and 7 put the shared form inside Modal, with the heading Sign in. Step 2 clicks Sign in on the home page: the address is /sign-in, the home page is still there, and the form is in the modal, with focus on Close. (.)sign-in sits at the root's level, so it catches clicks from every page. Step 3 types asha@example.com into Email. Step 4 presses Escape: the address is / again, with focus back on the Sign in link. Step 5 reloads /sign-in, and gets the full page. The Terminal tab lists both ○ /(.)sign-in and ○ /sign-in.
Sort these visits: does the visitor get the modal, or the full page?
- Click Green tea on
/teas - Reload while the modal is open
- Open
/teas/greenfrom a link in a chat app - Click Open the full page, a plain
<a> - Click Mint tea on
/teas - Click Sign in on the home page
- Click Cart on
/, withapp/teas/@modal/(..)cart
Sort each visit by what the visitor sees, from the recordings in this shop.
Common mix-ups
- "The markers count folders." No. They count route segments: slots and route groups don't count.
(..)inapp/@modalstopped the build. - "An intercepted address always shows the modal." No. Only a client-side navigation from the folder's route or below does: Cart from
/showed the full cart. A reload or a shared link showed the full page. - "The modal's page is enough." No. Next.js 16.4.3 stops the build without the full page:
Interception routes must have a canonical route. - "
default.tsxcloses the modal when the visitor clicks away." No. On a click, a slot keeps its last page, so on a click to the cart the slot kept the modal open. A[...rest]page that returnsnullcloses it. - "A Back button with
router.back()is fine on the full page." No. A visitor from a shared link has no page of yours to go back to: the recorded Back left the shop. - "Any box on top of the page is a modal." No. A modal moves focus into itself, closes with Escape, and keeps the page behind out of reach.
showModal()does all three.
Each row is one file of the modal: when it shows, and why it's there.
| File | When it shows | Why it's there |
|---|---|---|
app/@modal/(.)teas/[id]/page.tsx | After a client-side navigation to a tea, from anywhere in the app: the slot sits at the root. | It's the modal itself. |
app/teas/[id]/page.tsx | After a reload, a typed address or a shared link. | It's the canonical route. The build stops without it. |
app/@modal/default.tsx | When no page in the slot matches the address. With [...rest] beside it, that's mainly /. | The slot needs something for every address. The build stops without it. |
app/@modal/[...rest]/page.tsx | On every other address outside the modal, like /cart, on a click and after a full page load. | It empties the slot, so the modal doesn't follow the visitor. |
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
Which address does app/@modal/(.)teas/[id]/page.tsx intercept?
app/
@modal/
(.)teas/
[id]/
page.tsx
default.tsx
_lib/
teas.ts
cart/
page.tsx
teas/
[id]/
page.tsx
page.tsx
address.tsx
layout.tsx
modal.tsx
page.tsx/teas/[id], every tea's address, like /teas/green. The recorded click on Green tea from /teas opened the modal at /teas/green.
A friend sends a visitor the address /teas/mint in a chat app, and the visitor opens it. Do they see the modal, or the full page?
import Link from "next/link";
import { notFound } from "next/navigation";
import { findTea, teas } from "@/app/_lib/teas";
export function generateStaticParams() {
return teas.map((tea) => ({ id: tea.id }));
}
export default async function TeaPage({ params }: PageProps<"/teas/[id]">) {
const { id } = await params;
const tea = findTea(id);
if (!tea) notFound();
return (
<main>
<h1>{tea.name}</h1>
<p>{tea.note}</p>
<p>${tea.price} a box</p>
<Link href="/teas">All teas</Link>
</main>
);
}
The full page, this app/teas/[id]/page.tsx: the tea's name, note, price and All teas. The recorded reload of /teas/green shows it, and a shared link is a full page load too.
The shop wants Cart to open in the modal when it's clicked on /teas. What should the folder in app/teas/@modal be called?
app/
_lib/
teas.ts
cart/
page.tsx
teas/
@modal/
????cart/
page.tsx
default.tsx
[id]/
page.tsx
layout.tsx
page.tsx
address.tsx
layout.tsx
modal.tsx
page.tsximport Modal from "@/app/modal";
export default function CartModal() {
return (
<Modal title="Your cart">
<p>2 boxes of Green tea</p>
</Modal>
);
}
(..)cart, or (...)cart. Both mean /cart from /teas. In the recordings, both opened the cart in the modal from /teas, and (.)cart didn't.
With these files, next build stops: Parallel route slots cannot render the same URLs. Which file is missing?
app/
@modal/
(.)teas/
[id]/
page.tsx
_lib/
teas.ts
cart/
page.tsx
teas/
[id]/
page.tsx
page.tsx
address.tsx
layout.tsx
modal.tsx
page.tsxexport default function NoModal() {
return null;
}
app/@modal/default.tsx, returning null. It covers @modal on every page without a modal. The recorded build without it stopped; with it, it passed.
Line 18 adds Go to your cart to the modal. A click on it shows the cart with the modal still open on top. Which file empties the slot?
import Link from "next/link";
import { notFound } from "next/navigation";
import Modal from "@/app/modal";
import { findTea, teas } from "@/app/_lib/teas";
export function generateStaticParams() {
return teas.map((tea) => ({ id: tea.id }));
}
export default async function TeaModal({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const tea = findTea(id);
if (!tea) notFound();
return (
<Modal title={tea.name}>
<p>{tea.note}</p>
<p>${tea.price} a box</p>
<a href={`/teas/${tea.id}`}>Open the full page</a> <Link href="/cart">Go to your cart</Link>
</Modal>
);
}
export default function NoModalHere() {
return null;
}
app/@modal/[...rest]/page.tsx, returning null. It matches /cart, so the click empties the slot. In the recording with it, step 3 shows the cart alone.
This full page closes with <BackButton /> on line 18, which calls router.back(). What should replace it, so a visitor from a shared link stays in the shop?
import BackButton from "@/app/back-button";
import { notFound } from "next/navigation";
import { findTea, teas } from "@/app/_lib/teas";
export function generateStaticParams() {
return teas.map((tea) => ({ id: tea.id }));
}
export default async function TeaPage({ params }: PageProps<"/teas/[id]">) {
const { id } = await params;
const tea = findTea(id);
if (!tea) notFound();
return (
<main>
<h1>{tea.name}</h1>
<p>{tea.note}</p>
<p>${tea.price} a box</p>
<BackButton />
</main>
);
}
import Link from "next/link";
import { notFound } from "next/navigation";
import { findTea, teas } from "@/app/_lib/teas";
export function generateStaticParams() {
return teas.map((tea) => ({ id: tea.id }));
}
export default async function TeaPage({ params }: PageProps<"/teas/[id]">) {
const { id } = await params;
const tea = findTea(id);
if (!tea) notFound();
return (
<main>
<h1>{tea.name}</h1>
<p>{tea.note}</p>
<p>${tea.price} a box</p>
<Link href="/teas">All teas</Link>
</main>
);
}
A Link to /teas, like <Link href="/teas">All teas</Link> on line 18. In the recording, the Back button left the shop; the real page's All teas link went to /teas.
Check your understanding
Question 1 of 7In
app/teas/@modal, which address does a folder named(..)cartintercept?Choose an answer to see the explanation.
Question 2 of 7The tea modal's folder is
app/@modal/(.)teas/[id]. Why(.), and not(..)?Read the code, then predictTextapp/ @modal/ (.)teas/ [id]/ page.tsx default.tsx _lib/ teas.ts cart/ page.tsx teas/ [id]/ page.tsx page.tsx address.tsx layout.tsx modal.tsx page.tsxChoose an answer to see the explanation.
Question 3 of 7With
app/teas/@modal/(..)cart, a visitor on/clicks Cart. What shows?Choose an answer to see the explanation.
Question 4 of 7The modal is open at
/teas/green, and the visitor reloads. What do they get?Choose an answer to see the explanation.
Question 5 of 7The visitor opened the modal with the Green tea link and presses Escape. Where does the keyboard focus go?
Choose an answer to see the explanation.
Question 6 of 7The modal has a Go to your cart link, and
@modalhas onlydefault.tsx. What happens on that click?Choose an answer to see the explanation.
Question 7 of 7Why does the server answer
/teas/greenwithVarythat namesnext-url?Choose an answer to see the explanation.
Key takeaways
- A folder starting with
(.),(..),(..)(..)or(...)intercepts another route, counted in route segments. Slots and route groups don't count. - Only a client-side navigation (a
Linkclick orrouter.push) from the folder's route, or below it, is intercepted. The router says where the visitor is withnext-url. - A reload, a typed address or a shared link is a full page load, and shows the real page.
- A modal is a
@modalslot, an intercepting route in it, adefault.tsxthat returnsnull, and the full page. Next.js 16.4 stops the build without the last two. - Close the modal with
router.back(), give the slot a catch-all page that returnsnull, and link the full page to the list with aLink. - Use
<dialog>withshowModal()andaria-labelledby: focus moves in, Escape closes it, and focus comes back.
Remember the one-liner.
A client-side navigation from the folder's route gets the modal; every other way in gets the full page.
Coming next: proxy.ts. It runs code before a request reaches any route: rewrites, redirects, headers and cookies.