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 was opened from.
Picture a photo feed, a grid of thumbnails. You click one, and a lightbox opens over the grid: the photo gets bigger, the feed dims behind it, and you can still see the page you came from underneath. Click another, and the next photo slides in. Hit the back button, and the lightbox closes and you’re back at the grid. Instagram and Dribbble both work this way. It is one of the most familiar interactions on the web.
You already know how to build a version of it. You’ve written modals with local state before: 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 works. It opens, it closes, and it shows the photo, and most users will never complain. But there are four things it can’t do, all for the same reason: the modal lives in a component’s 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 you wanted them to see isn’t in the link.
- Refresh closes it. Reload the page and
selectedIdresets tonull, so the modal is gone. - Back leaves the page. The browser back button doesn’t close the modal; it navigates away entirely, because as far as the browser knows, nothing changed when the modal opened.
- Cmd-click does nothing useful. There’s no URL behind the photo, so you can’t open it in a new tab the way you’d open any other piece of content.
Every one of those is a missing URL. The user thinks of 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 in context over the feed instead of sending you off to a separate page.
Next.js ships a convention built for exactly this: intercepting routes. You already have most of the machinery, because the parallel-route slots from the last lesson do almost all the work. This lesson adds exactly one new thing: a folder prefix.
Two ways to arrive at the same URL
Section titled “Two ways to arrive at the same URL”Before any folder syntax, hold onto one idea, because the entire pattern depends on it: a single URL can render two different things, and which one you get depends on how you got there.
You met the two ways to get there in the navigation lesson. They’re worth revisiting, because the whole 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 everything else mounted, so the layout you were looking at stays exactly where it was. This is the path interception fires on.
Hard navigation is a full document load. Pasting a URL into a fresh tab, refreshing, Cmd-clicking into a new tab, following a link from someone’s email: in every one of these, the browser throws away whatever was on screen and resolves the URL from scratch. Interception does not fire here. There’s no running app to intercept, so the browser just asks the server for that URL and renders whatever the server returns.
Those two paths give us the rule the rest of the lesson builds on:
That rule has a direct consequence. Because the two kinds of navigation need two different results, every intercepting route is paired with a non-intercepting sibling, one file for each path. The intercepter handles the in-context soft-nav render, and the real page handles the standalone hard-nav render. If you build the intercepter and forget its sibling, you’ve shipped a modal that works fine until the first person refreshes or pastes the link, at which point it 404s. This is the mistake people hit most often with interception, and it comes up again when we write the files.
The dual path is easier to grasp concretely, before we touch any syntax. The diagram below shows the same destination, a photo with id 42, reached two different 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. There is no running feed to layer over, so the browser renders the standalone page. The @modal slot has no match, so its empty default.tsx renders nothing.
Read those two tabs against each other. The URL is identical, /photo/42. On soft navigation the feed is still there and the photo floats over it in a modal. On hard navigation there’s no feed to float over, so you get the full page. Same address, two renders, and the only difference is how the user arrived. Keep this picture in mind, because every code sample below builds on it.
One subtlety is worth pinning down, because it is what makes the pattern pay off. On soft navigation, even though the modal is what renders, the browser’s 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. That mask is what lets the pattern work: the address bar shows a genuine, shareable URL, so the user can copy it, bookmark it, or send it. When that person opens it cold, hard navigation kicks in and they get the full page. The two paths meet at the same address from opposite directions.
The intercepting prefix
Section titled “The intercepting prefix”The syntax is small. 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 instead of the real page.”
The marker isn’t decoration; it encodes a distance, namely how far up the URL tree the segment you’re intercepting sits, measured from the intercepter’s own folder. There are four markers:
| 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, (.), (..), and (..)(..) will feel familiar: same level, one up, two up. The (...) marker is the special “jump all the way to the top” case. The resemblance is deliberate, but it hides a catch that trips up almost everyone, so the next few paragraphs take it slowly.
(..) means “one URL segment up,” not “one folder up.” Most of the time those are the same number and you never notice the difference. They diverge the moment a folder in your path contributes no URL segment, and two kinds of folder do exactly that. A parallel-route slot like @modal adds no URL segment; you learned in the last lesson that @slot folders organize the render tree without appearing in the address. A route group , a (folder) in parens, adds no URL segment either, which you learned a few lessons back. Both are invisible to the prefix count.
This isn’t an edge case to file away for later; it’s the case that decides the prefix for the pattern we’re about to build. Our intercepter is going to live inside the feed’s @modal slot. So when we count how far up the photo route sits, the @modal folder doesn’t count: it’s two folders up on disk, but only one URL segment up. That’s why the canonical example uses (..)photo and not (...). The figure below shows the divergence directly.
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, which is why the prefix is (..)photo and not (...).
Look at the two strips. On disk, the path from @modal up to the photo route crosses two folders. On the URL, it’s a single segment, because @modal simply isn’t there. The prefix counts the bottom strip, never the top one. So (..)photo, one URL segment up, is correct, even though walking the folder tree tempts you to type (...) or count two. If you do count the folders, 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 behave the same way: a (marketing) folder in the path is skipped under the same rule. No URL segment, no count.
Keep (..)photo in mind: it’s the exact prefix we’ll write next, and it shows up identically in the file tree and the code.
Wiring the modal
Section titled “Wiring the modal”Now we can assemble it. Almost every piece here 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 routes, and the shadcn <Dialog> from the accessibility chapter. The one genuinely new idea is the prefix, which you just learned. Everything else is recombination.
Here’s the complete file tree. Read each annotation against the dual-path picture from earlier, asking 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)
Five files do the work. We’ll walk them in the order that builds understanding, starting with the one that carries the new idea.
The intercepter and the real page render the same content, the photo detail, and differ only in their wrapper. That’s what keeps the pattern maintainable: you write the photo-detail view once, as a shared component, and present it two ways. One path wraps it in a modal, and the other gives it a full page. Compare them side by side.
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 the only thing this file adds; the content is identical to the other tab. 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.
Notice what’s the same and what’s different. Both pages capture params, validate the id, and bail with notFound() on a miss, which is the same discipline from the dynamic-segments lesson, so it isn’t re-walked here. Both render <PhotoDetail id={id} />. The only difference is the wrapper: the intercepter wraps it in <Modal>, while the real page sets it in page chrome. You write the detail once and present it twice. If you ever need to change what the photo view shows, you change it in <PhotoDetail> and both paths update together.
Next comes the empty slot. Recall the pairing rule: on hard navigation, interception doesn’t fire, so the @modal slot has no route to match. You learned in the parallel-routes lesson what an unmatched slot does without a fallback: it 404s the entire route. The fix is the same default.tsx you used there.
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, exactly as it should: 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 exactly the 404-on-refresh bug from earlier. Those three lines are what 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> );};This is a Client Component. It reads a hook (useRouter) and handles an event (onOpenChange), and both of those 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. That’s a recap from the navigation 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 to the modal, 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, whether through Esc, the X button, or a backdrop click, all funneled through Radix, the handler doesn’t toggle a piece of 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> itself. 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.
The focus trap, Esc-to-close, and return-focus all come from shadcn’s <Dialog>, which the accessibility chapter built and which we simply rely on here. One more thing about that children prop is worth stating outright: a Client Component composes a Server Component by taking it as children, never by importing it directly. That’s why the intercepter page passes the server-rendered detail in, rather than the wrapper importing it, and the detail renders untouched inside the dialog.
Step 4 is worth sitting with. Opening the modal was navigation: the user clicked a <Link> to /photo/42. So closing it is navigation too, through router.back(), which pops the URL back to /feed. The moment you frame closing as navigation, every browser affordance comes along for free. The back button closes the modal, because back is what closes it. Forward reopens it. The URL stays truthful the entire time. You didn’t wire any of that; you got it because you spent a real URL on the modal, and the browser already knows how to manage URLs.
This is the trade the whole lesson has been building toward. The hand-rolled useState modal kept its open/closed state in a component, where the browser couldn’t see it. This pattern keeps that state in the URL, where the browser does everything. Opening is navigation, closing is navigation, and the back button, refresh, sharing, and Cmd-click all just work.
When intercepting routes earn their weight
Section titled “When intercepting routes earn their weight”This is a power tool, and power tools cost something. You’re now maintaining two render paths, an extra route, and a parallel slot, for what is, on the surface, just a modal. So 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 of these are true: the UI shows details or edits something in context, without leaving the list, and that detail view deserves a real URL, meaning it should be shareable, deep-linkable, refreshable, and survive back and forward. The photo feed is the textbook shape, but you’ll reach for this most on SaaS surfaces. An invoice row in a table opens an invoice detail at /invoices/42, so a teammate can paste that link and land on the same invoice, modal and all. A message in an inbox opens the full thread in context. An “edit settings” panel can be linked to a colleague directly. It’s the same pattern every time, and it’s worth turning into a reflex: 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, the “Are you sure?” dialog, 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 exact pattern you opened the lesson with. That pattern isn’t a beginner’s shortcut you’ve now outgrown; for ephemeral, unshareable UI it is simply the correct answer. The skill is telling the two apart.
There’s also a middle option worth naming so you recognize it when you meet it. Sometimes you want a modal to persist in the URL, surviving a refresh and staying shareable, but it isn’t really a detail page of a list item, so a whole intercepted route is heavier than you need. For those, you can key a dialog off a query string instead, something 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 gets to searchParams-as-state in a later chapter. For now, file it as the lighter cousin of today’s pattern, for when you want URL persistence without a full route behind it.
Here’s a quick decision aid for the three-way choice. Walk it 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.
Let’s check the two ideas most likely to trip you up. First, the dual-path model, the one beginners get wrong by forgetting 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, and that’s 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, but it’s only reached 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, not the thing that renders the photo.Now the same destination reached the other way. This is 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 it returns null: no feed underneath means no modal to float, so the slot is correctly empty.Next, lock in the prefix table. This is pure correspondence, so match each prefix to its meaning.
Match each intercepting prefix to how far it reaches up the URL tree. Click an item on the left, then its match on the right. Press Check when done.
(.)folder(..)folder(..)(..)folder(...)folderapp directoryFinally, 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 they’re both invisible to the count. Strip them away and the live URLs tell the real 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 you can reach for now
Section titled “What you can reach for now”You added one convention, the intercepting-route prefix, and turned a state-bound modal into a URL-backed view. Here’s the muscle memory to carry forward. The intercepter is a soft-navigation render override, and it always ships alongside its non-intercepting sibling, the real page that handles hard navigation. It lives in a parallel slot, so the modal renders beside the page underneath rather than instead of it. That slot needs an empty default.tsx so a hard load doesn’t 404. And closing the modal is router.back(), not a state toggle, which is why back, forward, refresh, and sharing all work for free. The reflex underneath all of it, URL-backed UI for anything worth sharing or deep-linking, comes back in every list-and-detail surface you build from here on.
A few threads pick up later. The list-plus-detail project wires this exact pattern to real data and server-side mutations. The chapter on async UI adds loading.tsx and error.tsx at the slot boundary, so the modal can stream and recover. And the focus trapping, Esc-to-close, and return-focus the <Modal> leans on are all owned by the accessibility chapter you’ve already done; the <Dialog> was doing that work the whole time.
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.