Intercepting routes and URL-backed modals
Next.js intercepting routes, the App Router convention that gives a modal a real, shareable URL by rendering it over the page it opened from.
Picture a photo feed: a grid of thumbnails. Click one and a lightbox opens over the grid, enlarging the photo while the feed dims behind it. Click another and the next photo slides in; hit back and the lightbox closes, returning you to the grid. Instagram and Dribbble both work this way.
You already know how to build a version of this with local state: a selectedId, a setSelectedId, and a <Dialog> that renders when something is selected.
'use client';
export const Feed = ({ photos }: { photos: Photo[] }) => { const [selectedId, setSelectedId] = useState<string | null>(null);
return ( <> <div className="grid grid-cols-3 gap-2"> {photos.map((photo) => ( <button key={photo.id} onClick={() => setSelectedId(photo.id)}> <Image src={photo.src} alt={photo.alt} width={300} height={300} /> </button> ))} </div>
{selectedId != null && ( <Dialog open onOpenChange={() => setSelectedId(null)}> <DialogContent> <PhotoDetail id={selectedId} /> </DialogContent> </Dialog> )} </> );};This opens, closes, and shows the photo. But there are four things it can’t do, all for one reason: the modal lives in component state, and component state is invisible to the URL.
- You can’t share it. Copy the address bar and send it to a colleague, and they land on the bare feed; the photo isn’t in the link.
- Refresh closes it. Reload and
selectedIdresets tonull, so the modal is gone. - Back leaves the page. The back button navigates away instead of closing the modal, because as far as the browser knows, nothing changed when the modal opened.
- Cmd-click does nothing useful. With no URL behind the photo, you can’t open it in a new tab.
Every one of those is a missing URL. The user treats the modal as a place, a real and addressable view, but it has no address. The fix is to give it one: a deep link . With an address, the modal becomes shareable, survives a refresh, and works with back and forward, while still rendering over the feed instead of sending you to a separate page.
Next.js ships a convention built for exactly this: intercepting routes. The parallel-route slots from the last lesson already do most of the work; this lesson adds one new piece, a folder prefix.
Soft navigation intercepts, hard navigation renders the real page
Section titled “Soft navigation intercepts, hard navigation renders the real page”The whole pattern rests on one idea: a single URL can render two different things, and which one you get depends on how you arrived. You met the two ways in the Navigation primitives lesson; the feature turns on the difference between them.
Soft navigation is movement inside the running app, through a <Link> click or a router.push. The browser doesn’t reload the document; React swaps the part of the tree that changed and leaves the rest mounted, so the layout you were on stays put. This is the path interception fires on.
Hard navigation is a full document load: pasting a URL into a fresh tab, refreshing, Cmd-clicking, following a link from an email. The browser throws away what was on screen and resolves the URL from scratch. Interception does not fire here. There’s no running app to intercept, so the browser asks the server for the URL and renders whatever it returns.
So the two kinds of navigation need two different results, and every intercepting route is paired with a non-intercepting sibling, one file per path: the intercepter handles the soft-nav render in context, the real page handles the standalone hard-nav render. Build the intercepter and forget its sibling, and you ship a modal that works until the first refresh or pasted link, then 404s. It’s the most common mistake with interception, and it comes up again when we write the files.
The diagram below reaches the same destination, a photo with id 42, two ways. Flip between the tabs and watch what renders.
Rendered by the intercepter app/feed/@modal/(..)photo/[id]/page.tsx, layered over the still-mounted feed. The address bar shows the photo’s real URL, so the link is shareable even though the feed never unmounted.
Rendered by the real route app/photo/[id]/page.tsx. With no running feed to layer over, the browser renders the standalone page. The @modal slot has no match, so its empty default.tsx renders nothing.
This is the subtlety that makes the pattern pay off. On soft navigation the modal renders, yet the address bar shows /photo/42, the real route’s URL: the URL is masked to the standalone page’s address while the feed stays mounted underneath. So the user can copy, bookmark, or send a genuine URL, and when someone opens it cold, hard navigation gives them the full page. The two paths meet at the same address from opposite directions.
Counting the intercepting prefix
Section titled “Counting the intercepting prefix”You mark a route folder as an intercepter by prefixing its name with one of four markers. The folder then says: “intercept soft navigations heading to this URL, and render me in their place.” Each marker encodes a distance: how far up the URL tree the intercepted segment sits, measured from the intercepter’s own folder.
| Prefix | Intercepts a route… |
|---|---|
(.)folder | at the same URL level |
(..)folder | one URL level up |
(..)(..)folder | two URL levels up |
(...)folder | from the root app directory |
If you’ve used relative file paths in a terminal, the first three will feel familiar: same level, one up, two up. (...) is the special “jump to the top” case. The resemblance is deliberate, but it hides a catch.
(..) means one URL segment up, not one folder up. Usually those match, so you never notice. They diverge the moment a folder in the path contributes no URL segment, and two kinds do exactly that: a parallel-route slot like @modal, and a route group . Both are invisible to the prefix count.
This is the case that decides the prefix for the pattern we’re about to build. Our intercepter lives inside the feed’s @modal slot, so when we count how far up the photo route sits, the slot doesn’t count. It’s two folders up on disk, but only one URL segment up, which is why the canonical prefix is (..)photo, not (...).
appfeed@modal 0 URL segments (..)photo[id]/feedphoto42(..) = one URL segment up
@modal is a slot, not a URL segment, so it drops out of the count: two folders up on disk is one URL segment up, making the prefix (..)photo.
On disk, the path from @modal up to the photo route crosses two folders; on the URL it’s a single segment, because @modal isn’t there. The prefix counts the bottom strip, never the top. Count folders instead, and the prefix points at the wrong level: the intercepter never matches, and the modal just doesn’t open, with no error to tell you why. Route groups are skipped under the same rule, for the same reason: no URL segment, no count.
Wiring the modal
Section titled “Wiring the modal”Almost every piece is something you already have: the @modal slot and its default.tsx from Parallel routes, the [id] segment and the await params reflex from Dynamic segments, and the shadcn <Dialog> from the accessibility chapter. The one new idea is the prefix. Everything else is recombination.
Read each annotation against the dual-path picture from earlier: which file is the soft-nav render, which is the hard-nav render, and which is the closed-modal fallback.
Directorysrc/
Directoryapp/
Directoryfeed/
- page.tsx the feed grid, the layout’s
childrenslot - layout.tsx receives the
@modalslot as a prop, besidechildren Directory@modal/ the parallel slot the modal renders into
- default.tsx
return null, the closed-modal fallback on hard nav Directory(..)photo/
(..)is one URL segment up, because@modaladds noneDirectory[id]/
- page.tsx the intercepter: renders on soft nav, wraps the detail in a modal
- default.tsx
- page.tsx the feed grid, the layout’s
Directoryphoto/
Directory[id]/
- page.tsx the real route: renders on hard nav, full standalone page
Directory_components/
- photo-detail.tsx the shared content, imported by both pages
- modal.tsx the dialog wrapper (Client Component)
The intercepter and the real page render the same content, the photo detail, and differ only in their wrapper. You write the photo-detail view once as a shared component and present it two ways: one path wraps it in a modal, the other gives it a full page. We’ll walk the five files in the order that builds understanding, starting with these two.
import { notFound } from 'next/navigation';import { z } from 'zod';
import { Modal } from '@/app/_components/modal';import { PhotoDetail } from '@/app/_components/photo-detail';
const paramsSchema = z.object({ id: z.uuid() });
export default async function PhotoModal({ params,}: PageProps<'/feed/@modal/(..)photo/[id]'>) { const parsed = paramsSchema.safeParse(await params); if (!parsed.success) notFound();
return ( <Modal> <PhotoDetail id={parsed.data.id} /> </Modal> );}Renders on soft navigation. It wraps the shared detail in a <Modal>, layering it over the still-mounted feed. The wrapper is all this file adds. The PageProps<…> literal is illustrative: Next.js generates it for typed routes, so you autocomplete it rather than hand-type it.
import { notFound } from 'next/navigation';import { z } from 'zod';
import { PhotoDetail } from '@/app/_components/photo-detail';
const paramsSchema = z.object({ id: z.uuid() });
export default async function PhotoPage({ params,}: PageProps<'/photo/[id]'>) { const parsed = paramsSchema.safeParse(await params); if (!parsed.success) notFound();
return ( <main className="mx-auto max-w-2xl p-6"> <PhotoDetail id={parsed.data.id} /> </main> );}Renders on hard navigation: a full standalone page, no modal. Same <PhotoDetail>, dropped into page chrome instead of a dialog. There’s no feed to float over here, so the bare component is the page.
Both pages capture params, validate the id, and bail with notFound() on a miss, the same discipline from the Dynamic segments lesson. Both render <PhotoDetail id={id} />. The only difference is the wrapper. Change what the photo view shows in <PhotoDetail>, and both paths update together.
Next, the empty slot. On hard navigation, interception doesn’t fire, so the @modal slot has no route to match. You learned in the Parallel routes lesson that an unmatched slot with no fallback 404s the entire route. The fix is the same default.tsx.
export default function Default() { return null;}return null means “closed modal, render nothing.” On hard navigation, the real photo page renders standalone and the @modal slot renders nothing: there’s no feed for a modal to float over, so there’s no modal. This is the file that’s easiest to forget, and forgetting it is the 404-on-refresh bug from earlier. Three lines keep a hard load from crashing.
Last is the wrapper itself. Pay attention to how the modal closes, because that’s where the pattern earns its keep.
'use client';
import { useRouter } from 'next/navigation';
import { Dialog, DialogContent } from '@/components/ui/dialog';
export const Modal = ({ children }: { children: React.ReactNode }) => { const router = useRouter();
return ( <Dialog defaultOpen onOpenChange={() => router.back()}> <DialogContent>{children}</DialogContent> </Dialog> );};A Client Component: it reads a hook (useRouter) and handles an event (onOpenChange), both of which need the client.
'use client';
import { useRouter } from 'next/navigation';
import { Dialog, DialogContent } from '@/components/ui/dialog';
export const Modal = ({ children }: { children: React.ReactNode }) => { const router = useRouter();
return ( <Dialog defaultOpen onOpenChange={() => router.back()}> <DialogContent>{children}</DialogContent> </Dialog> );};The router hook comes from next/navigation in the App Router, not the old next/router. A recap from the Navigation primitives lesson.
'use client';
import { useRouter } from 'next/navigation';
import { Dialog, DialogContent } from '@/components/ui/dialog';
export const Modal = ({ children }: { children: React.ReactNode }) => { const router = useRouter();
return ( <Dialog defaultOpen onOpenChange={() => router.back()}> <DialogContent>{children}</DialogContent> </Dialog> );};The dialog opens immediately, with no trigger button. Reaching this route is the open signal: if this component is rendering, the user navigated here, so it should already be open.
'use client';
import { useRouter } from 'next/navigation';
import { Dialog, DialogContent } from '@/components/ui/dialog';
export const Modal = ({ children }: { children: React.ReactNode }) => { const router = useRouter();
return ( <Dialog defaultOpen onOpenChange={() => router.back()}> <DialogContent>{children}</DialogContent> </Dialog> );};The key step. When the dialog asks to close, via Esc, the X, or a backdrop click, all funneled through Radix, the handler doesn’t toggle state. It navigates. router.back() pops the URL from /photo/42 back to /feed, the @modal slot loses its match, and the modal unmounts. Closing the modal is navigation.
'use client';
import { useRouter } from 'next/navigation';
import { Dialog, DialogContent } from '@/components/ui/dialog';
export const Modal = ({ children }: { children: React.ReactNode }) => { const router = useRouter();
return ( <Dialog defaultOpen onOpenChange={() => router.back()}> <DialogContent>{children}</DialogContent> </Dialog> );};The modal never imports <PhotoDetail>. The intercepter page passes the detail in as a child, so this Client Component wrapper stays generic and the server-rendered detail composes inside it. A Client Component composes a Server Component by taking it as children, never by importing it.
The focus trap, Esc-to-close, and return-focus all come from shadcn’s <Dialog>, built in the accessibility chapter and relied on here.
That fourth step is the whole trade. Opening the modal was navigation, a <Link> to /photo/42, so closing it is navigation too, a router.back() to /feed. Frame closing as navigation and every browser affordance comes along for free: back closes the modal, forward reopens it, the URL stays truthful, and refresh, sharing, and Cmd-click all just work. A hand-rolled useState modal hides its open state in a component, where the browser can’t see it. Spend a real URL on the modal instead, and the browser, which already knows how to manage URLs, does the rest.
When intercepting routes earn their weight
Section titled “When intercepting routes earn their weight”This is a power tool, and it costs something: two render paths, an extra route, and a parallel slot, for what looks like just a modal. The senior question isn’t whether you can build a URL-backed modal, since you just did. It’s whether this modal deserves one.
Reach for an intercepting-route modal when both are true: the UI shows or edits something in context without leaving the list, and that detail view deserves a real URL, one that is shareable, deep-linkable, refreshable, and survives back and forward. The photo feed is the textbook shape, but you’ll meet this most on web-app surfaces: an invoice row opens /invoices/42 so a teammate can paste the link and land on the same invoice, modal and all; an inbox message opens its thread in context; an “edit settings” panel can be sent to a colleague directly. The reflex is worth building: when a piece of UI deserves a URL, give it one.
The other half is just as much a senior call. Most modals don’t deserve a URL, and forcing this pattern onto them is over-engineering. A confirm-delete prompt is ephemeral and app-internal: nobody shares it, nobody deep-links it, and a refresh should dismiss it. A command palette, a form-validation popover, and a transient menu are the same story. Those stay a plain useState plus <Dialog>, the pattern you opened the lesson with, which for unshareable UI is simply the correct answer. The skill is telling the two apart.
There’s a middle option too. Sometimes you want a modal to persist in the URL, surviving a refresh and staying shareable, but it isn’t the detail page of a list item, so a whole intercepted route is more than you need. For those, key a dialog off a query string instead, like /settings?modal=invite, where a searchParams value rather than a route decides whether the dialog is open. It’s lighter: no extra route, no slot, no intercepter. The course reaches searchParams-as-state later; for now, file it as the lighter cousin of today’s pattern.
Walk this decision aid for a modal you’re actually considering.
URL-invisible state is the correct answer here, not a workaround: a confirm-delete prompt, a command palette, a form-validation popover. This is the pattern you opened the lesson with.
The modal gets a real, shareable URL and renders in context over the still-mounted list, while the non-intercepting sibling page handles hard navigation. The textbook fit: a photo lightbox, an invoice row, an inbox thread.
URL persistence, shareable and refresh-proof, without a whole intercepted route, slot, and sibling. The lighter cousin of today’s pattern, for an “edit settings” or “invite” panel that isn’t a list item. Covered later in the course.
Now the two ideas most likely to trip you up. First, the dual-path model, where beginners forget the sibling.
Using the file tree above: a user is sitting on /feed, clicks a thumbnail, and the app <Link>-navigates to /photo/42 without reloading the page. Which file renders the photo?
app/feed/@modal/(..)photo/[id]/page.tsxapp/photo/[id]/page.tsxapp/feed/@modal/default.tsxapp/feed/page.tsx<Link> click without a reload is a soft navigation, the one path interception fires on. So the intercepter inside the @modal slot wins, rendering the detail in a <Modal> layered over the feed that never unmounted. app/photo/[id]/page.tsx is the real page, reached only on a hard load; default.tsx is the closed-modal fallback (it renders nothing here); app/feed/page.tsx is the grid still sitting underneath.Now the same destination reached the other way, the one that 404s in real projects when the sibling is missing.
Same file tree, opposite arrival. The feed isn’t running yet: a user pastes /photo/42 into a fresh browser tab and hits Enter. Which file renders the photo?
app/photo/[id]/page.tsxapp/feed/@modal/(..)photo/[id]/page.tsxapp/feed/@modal/default.tsx/photo/42, so it 404s.@modal/default.tsx does run on this load, but returns null: no feed underneath means no modal to float, so the slot is correctly empty.Now the rule that catches the most people: count URL segments, not folders, when a group is in the way.
A dashboard nests everything under a (dashboard) route group. The settings page lives at app/(dashboard)/settings/page.tsx, and the members page at app/(dashboard)/members/page.tsx — so their live URLs are /settings and /members. You want clicking a row in settings to open the members view as a panel over settings, so you add a slot and put the intercepter at app/(dashboard)/settings/@panel/<prefix>members/[id]/page.tsx. Which prefix goes in <prefix>?
(..)(...)(..)(..)(.)@panel (a slot) and (dashboard) (a route group), and neither contributes a URL segment, so both are invisible to the count. Strip them away and the live URLs tell the story: /settings and /members are siblings, exactly one segment apart. That’s (..). The folder tree tempts you toward (..)(..) (two folders up) or (...) (root the count at the group), but those count disk, and the disk count is the trap. (.) would mean members sits at the same level as the intercepter’s own URL, which it doesn’t.What comes next
Section titled “What comes next”Two threads pick this up later. The list-plus-detail project wires this exact pattern to real data and server-side mutations, and the async UI chapter adds loading.tsx and error.tsx at the slot boundary, so the modal can stream while it loads and recover when it fails.
External resources
Section titled “External resources”The official convention reference for the (.) / (..) / (...) prefixes, with the soft-vs-hard navigation diagrams.
The @slot and default.js machinery this lesson builds on, plus the full modal walkthrough end to end.
A runnable photo-feed app from the Next.js team — the exact intercepted-modal pattern you can clone and read.