Skip to content
Chapter 29Lesson 6

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.

feed.tsx
'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 selectedId resets to null, 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.

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.

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.

PrefixIntercepts a route…
(.)folderat the same URL level
(..)folderone URL level up
(..)(..)foldertwo URL levels up
(...)folderfrom 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 (...).

On disk
app
feed
@modal 0 URL segments
(..)photo
[id]
In the URL
/
feed
(nothing)
photo
42
(..) = 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.

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 children slot
        • layout.tsx receives the @modal slot as a prop, beside children
        • 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 @modal adds none
            • Directory[id]/
              • page.tsx the intercepter: renders on soft nav, wraps the detail in a modal
      • 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.

app/feed/@modal/(..)photo/[id]/page.tsx
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.

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.

app/feed/@modal/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.

1 / 1

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.

Does this modal deserve a URL?

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.tsx
app/photo/[id]/page.tsx
app/feed/@modal/default.tsx
app/feed/page.tsx

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.tsx
app/feed/@modal/(..)photo/[id]/page.tsx
app/feed/@modal/default.tsx
Nothing — a fresh tab has no route for /photo/42, so it 404s.

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>?

(..)
(...)
(..)(..)
(.)

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.