Skip to main content
← Back to blogs

How do you open a modal without leaving the page? (Next.js intercepting routes)

By AntonioGitHub ↗LinkedIn ↗
Next.jsWeb DevelopmentFull StackWeb App

You tap a photo in a feed and it opens in a modal, layered over the page, but the URL still changes so you can share or refresh it. Here is how Next.js parallel and intercepting routes pull that off, and when the trick is actually worth the folders.

You are scrolling Instagram. You tap a photo in the feed and it opens up big, with the image on one side and the comments next to it, sitting on top of the feed you were just looking at. The feed is still there behind it, slightly dimmed. You hit back and you are right where you left off. But here is the part that always made me curious: the address bar changed. The URL is now that specific post's URL. You can copy it, send it to a friend, and when they open it they get a normal full page for that post, not your feed with a thing floating over it.

So the question I had was: how do you open something in a modal, keep the page behind it, and still change the URL to that item's real page? The short answer is two Next.js App Router features working together, called parallel routes and intercepting routes. The interesting part is everything in between, so let's walk through it.

What is the trick actually doing?

The thing that makes this feel like magic is that one URL renders two different things depending on how you got there. If you are already on the feed and you tap a card, you get the modal layered over the feed. If you load that same URL cold, by pasting it into a fresh tab or refreshing, you get the full standalone page. Same address, two presentations.

Think of it like a side door into a building. If you walk in from inside, through the corridor you were already in, you pop into the room as a little pop-up window over the hallway. If you arrive from the street and walk through the front door, you get the whole room, full size. The room is the same room. The door you came through decides what it looks like when you get there.

In a recipe app I built, tapping a recipe card on the home grid opened that recipe in a dialog, the URL became /recipe/[id], and refreshing loaded the same recipe as its own full page. No state flag, no modal-open boolean in React, no querystring. The URL was the source of truth, and the routing did the rest.

What is a parallel route?

A parallel route lets one layout render more than one independent page slot at the same time. Normally a layout has one hole in it, called children, and the current page drops into that hole. A parallel route gives the layout a second hole that fills itself from its own folder, on its own, regardless of what is in the main one.

You create one by making a folder whose name starts with an @ symbol. So a folder called @modal becomes a named slot. Next.js then hands that slot to the layout as a prop, named after the folder. The analogy I keep in my head: children is the main stage, and a parallel route is a second screen above the stage that can show its own thing without interrupting the play.

Here is the layout from the recipe app. The modal prop is the slot, and it renders right next to children:

tsx
export default function RootLayout({
  children,
  modal,
}: Readonly<{
  children: React.ReactNode;
  modal: React.ReactNode;
}>) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body>
          {children}
          {modal}
        </body>
      </html>
    </ClerkProvider>
  );
}

Most of the time that modal slot renders nothing. You tell it to render nothing by giving the slot folder a default.tsx that returns null. That file is the slot's resting state, the thing it shows when no route has claimed it:

tsx
export default function Default() {
  return null;
}

Skipping that file is the first mistake everyone makes, including me. Without a default.tsx the slot has no idea what to do when you refresh on a page it does not cover, and Next.js throws a 404. More on that below.

What is an intercepting route?

An intercepting route lets you catch a navigation that would normally load a full page and render something else in its place, but only when the navigation starts from inside your app. It is the side door from earlier. When you click a link to /recipe/[id] while you are already on the home grid, the intercepting route steps in front of the real page and says, I'll take this one, render it in the modal slot instead.

You set it up with a folder naming convention that looks strange the first time you see it. You put parentheses with dots inside a folder name: (.) means intercept a route on the same level, (..) means one level up, and (...) means from the root. The dots work like the ../ you already know from file paths, just wrapped in parentheses so Next.js knows it is an interception and not a literal folder.

In the recipe app the intercepting page lived at @modal/(.)recipe/[id]/page.tsx. It sits inside the @modal slot, it intercepts the same-level recipe/[id] route, and instead of returning the full page it returns the same recipe wrapped in a dialog component:

tsx
// src/app/@modal/(.)recipe/[id]/page.tsx
export default async function RecipeModalPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const recipe = await getRecipeById(id, dbUser.id);
  if (!recipe) notFound();

  return <RecipeModal recipe={recipe} />;
}

The dialog itself is a small client component. It is open the whole time it exists, and closing it just calls router.back(), which pops the URL back to the feed and, because the slot no longer matches, makes the modal disappear. The browser back button does the exact same thing for free:

tsx
"use client";

export function RecipeModal({ recipe }: { recipe: Recipe }) {
  const router = useRouter();

  return (
    <Dialog open onOpenChange={() => router.back()}>
      <DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
        <RecipeDetail recipe={recipe} />
      </DialogContent>
    </Dialog>
  );
}

And the standalone full page still exists at the normal spot, recipe/[id]/page.tsx, with no parentheses. That is the page someone gets when they open the URL fresh. The interception never runs for them because they did not start from inside the app, so they fall through to the real thing.

Why is this a relatively new thing?

Before the App Router, the usual way to do an Instagram-style modal was all in client state. You kept a boolean like isModalOpen in React, you maybe stuffed the item id into a querystring so a refresh could reopen it, and you wrote the logic yourself to decide whether to show a modal or a page. It worked, but the URL and the visual state were two separate things you had to keep in sync by hand, and they drifted.

Parallel and intercepting routes arrived with the App Router and moved that whole dance into the router. The URL becomes the single source of truth, and the folder structure decides modal-or-page. You write fewer moving parts and the shareable URL comes for free. That is the improvement: no full redirect when you open the modal, it feels faster and smoother because the feed never unmounts, you stay in context, and the preview matches that specific item's own URL instead of some generic overlay state.

Things that surprised me

  • The URL really does change, and that is the whole point. I assumed the modal would keep the feed's URL, but no. Opening the modal updates the address bar to the item's real page so it is shareable and survives a refresh. The cleverness is that it changes the URL without doing a full-page navigation.
  • You need both halves or nothing works. The intercepting route makes the in-app click show a modal, but without the standalone page underneath it, a fresh load has nothing to render. They are a pair, not two options.
  • The missing default.tsx 404 cost me real time. If the slot has no default, refreshing on any page that the slot does not cover throws a 404, and the error message does not point at the slot at all. A three-line file that returns null fixes it.
  • The dots count from the slot, not from where you think. The (.) matches the level of the slot folder, not the page you clicked from, so it is easy to pick the wrong number of dots and have the interception silently never fire.
  • Closing the modal is just router.back(). There is no open or close state to manage. The router history is the state, which felt wrong until it felt obvious.

So when is this worth it?

Here is when I reach for parallel and intercepting routes: when the modal content is a real thing with its own URL that people will want to share or refresh, and when staying in context matters. A photo in a feed, a product in a grid, a recipe on a home page. The win is that one URL gives you a smooth modal from inside and a full page from outside, with the router doing the bookkeeping instead of you.

And here is when it is not worth the folders. If the modal is a confirm dialog, a settings panel, or anything you would never link to, skip all of this and use a plain client modal with a piece of state. There is no URL to share, so there is nothing for intercepting routes to buy you, and you would be paying in extra folders and the funny parentheses for nothing. The same goes if you do not actually need to keep the page behind it: a normal page navigation is simpler, and simpler usually wins.

My honest take after shipping it: when it works it feels like cheating, in a good way, and it is the correct tool for the shareable-modal pattern specifically. But it is fragile in quiet ways. A wrong dot count or a missing default file fails silently or with a 404 that blames the wrong file. So I'd tell anyone trying it for the first time to build both pages first, share a URL and refresh it to prove the standalone path works, and only then add the interception on top. Get the boring full page right, and the magic modal is a small, safe layer over it.

Related posts