Parallel Routes and Intercepting Routes: The Modal Pattern That Survives a Hard Refresh
Next.js parallel and intercepting routes finally make modals feel native — deep-linkable, shareable, and refresh-safe. Here's the pattern that held up in production, and the places it quietly falls apart.

Modals in single-page apps have always been a compromise. Either they're deep-linkable and break on refresh, or they're refresh-safe and don't share cleanly. Next.js App Router gives us a third option — parallel routes plus intercepting routes — and after shipping this pattern on a few client projects, it's the first modal implementation we've actually enjoyed maintaining.
This is a walkthrough of the pattern, the mental model that makes it click, and the specific places it bit us.
The problem with every modal you've built before
Here's the shape of the requirement, roughly: on a photo grid at /gallery, clicking a thumbnail should open a modal with the photo detail. The URL should update to /gallery/photo-123 so it's shareable. On refresh at that URL, the user should land on the full detail page, not a modal floating over nothing.
The naive React approach stores modal state in a useState and shoves the ID into a query param. It works until someone shares the link. Then the modal is gone, replaced by whatever your query-param handler decides to render, and now you're writing two rendering paths for the same content.
The App Router lets you express this properly: the modal is a slot layered on top of the current page, and the URL is the source of truth.
The mental model: slots and interception
Two primitives do the work.
Parallel routes let a layout render multiple route trees at once. You declare them as folders prefixed with @, and the layout receives each as a prop. A @modal slot renders alongside children, independently.
Intercepting routes let one route "catch" navigation to another URL when it happens from a specific origin. The (.), (..), and (...) prefixes control how far up the tree the interception reaches. When the user navigates via <Link>, the intercepting route renders. When they land there via a fresh page load, the real route renders.
Put together: the modal is a slot that intercepts navigation to a full page. Client-side navigation shows the modal. A hard load shows the page. Same URL, two contexts, no duplicated logic.
The file layout
Here's the structure for a gallery with photo detail modals:
app/
layout.tsx
gallery/
layout.tsx // renders children + @modal slot
page.tsx // the grid
@modal/
default.tsx // returns null when no modal is open
(.)photo/
[id]/
page.tsx // the modal version
photo/
[id]/
page.tsx // the standalone page
A few things worth calling out. The @modal/default.tsx is mandatory — without it, navigating back to /gallery will 404 the slot and Next will refuse to render. The (.)photo/[id] folder intercepts navigation to a sibling photo/[id] route within the same segment level. And the standalone photo/[id]/page.tsx is what renders on a hard refresh or direct link.
The gallery layout
// app/gallery/layout.tsx
export default function GalleryLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<>
{children}
{modal}
</>
);
}
That's it. The slot is just another prop. You can wrap it in a portal container if you need to, but you don't have to — the intercepting page component owns its own positioning.
The modal itself
// app/gallery/@modal/(.)photo/[id]/page.tsx
import { getPhoto } from '@/lib/photos';
import { Modal } from '@/components/modal';
import { PhotoDetail } from '@/components/photo-detail';
export default async function PhotoModal({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const photo = await getPhoto(id);
return (
<Modal>
<PhotoDetail photo={photo} />
</Modal>
);
}
And the standalone page reuses the same PhotoDetail component without the modal chrome. One source of truth for the content, two presentational wrappers.
The Modal component that actually behaves
The modal component is where you earn your keep on accessibility. The intercepting route gives you the URL contract; you still need focus management, escape-to-close, scroll lock, and a return path.
'use client';
import { useRouter } from 'next/navigation';
import { useEffect, useRef } from 'react';
export function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter();
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (dialog && !dialog.open) {
dialog.showModal();
}
return () => dialog?.close();
}, []);
return (
<dialog
ref={dialogRef}
onClose={() => router.back()}
onClick={(e) => {
if (e.target === dialogRef.current) router.back();
}}
className="modal"
>
{children}
</dialog>
);
}
Using the native <dialog> element with showModal() gives you focus trapping, escape handling, and the top-layer stacking context for free. router.back() is the close action, which unwinds the URL correctly because the modal navigation pushed a history entry.
Where this pattern quietly breaks
The pattern is elegant. It also has sharp edges we hit on real projects.
router.back() when there's no history
If a user lands on /gallery/photo-123 directly and your app somehow rendered a modal — for example, because your standalone photo/[id] page also uses the Modal component as a fallback — calling router.back() sends them to whatever they were browsing before, which is almost certainly not your site. You either need to detect this case and call router.push('/gallery') instead, or keep the modal and standalone renderers strictly separate.
The cleanest fix we've used:
const handleClose = () => {
if (window.history.length > 1) {
router.back();
} else {
router.push('/gallery');
}
};
It's not perfect — history.length counts entries across the whole tab session — but it catches the common cases.
The default.tsx gotcha on nested routes
Parallel slots need a default.tsx for every possible route the parent segment can render. Miss one, and you'll see a cryptic "Cannot find default for slot" error only when a user navigates a specific way. Add default.tsx to every @modal folder that sits alongside a page, and return null.
Interception doesn't survive a hard nav from an external site
The interceptor only fires on client-side navigation from within the app. If a user clicks a link to /gallery/photo-123 from an email, they get the standalone page. This is exactly the behaviour you want — but it means the standalone page needs to be a first-class experience, not a placeholder. Design it that way from day one.
Loading states inside the slot
loading.tsx works inside a slot, but the fallback renders in the slot's position, not full-screen. If your modal is a centred overlay, you probably want the loading skeleton to sit inside the modal chrome itself. Move the <Modal> wrapper into the layout of the intercepting route segment, and put the async work in a child component wrapped in <Suspense>.
Scroll restoration
When the modal closes, the gallery underneath should be exactly where the user left it. In most cases this works out of the box because the underlying page never unmounted — the slot rendered on top of it. If you notice scroll jumps, check that the modal isn't accidentally re-rendering the parent layout by writing to shared state during mount.
When not to reach for this
Parallel and intercepting routes are the right call when the modal content is a full resource that deserves its own URL — photos, product quick-views, user profiles, ticket details. They are overkill for confirmation dialogs, dropdowns, or anything ephemeral. For those, keep the state local and don't touch the URL.
We've also seen teams try to use this for multi-step wizards. Don't. Wizards have their own state model and don't benefit from URL routing for each step unless you specifically need shareable step links.
Where we'd start
If you're adding this to an existing App Router project, start with one modal. Pick a resource that already has a detail page — that gives you the standalone route for free. Add the @modal slot to the parent layout, create the (.) intercepting route that reuses your detail component inside a <Modal> wrapper, and add every default.tsx file the compiler asks for.
Get that one flow refresh-safe, share-safe, and accessible with a real <dialog> element. Then decide whether the pattern earns its complexity in the rest of the app. Usually it does — but only where the URL genuinely represents something worth linking to.
If you want a hand designing routing and rendering strategies for a Next.js build, our web development team does this every week.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Cache Tags in Next.js 15: How We Stopped Nuking the Whole Site on Every Publish
revalidatePath('/') is the loaded gun in your CMS integration. Here's how we replaced it with a cache tag taxonomy that survives editorial workflows without evicting the world.

The Server Action Retry Problem: Idempotency Keys for Next.js Mutations
Server actions look like function calls, but they're POST requests over an unreliable network. Here's how we stopped charging cards twice and duplicating orders in production.

Streaming Suspense Boundaries: Why Your LCP Got Worse After the Refactor
We wrapped everything in Suspense expecting faster pages. Instead LCP got worse. Here's what streaming actually does to Core Web Vitals, and how to place boundaries so the numbers move the right way.
