DEV Community

Cover image for Next.js Parallel & Intercepting Routes: Modals Done Right
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

Next.js Parallel & Intercepting Routes: Modals Done Right

You build a photo grid. Clicking a thumbnail should pop up a modal with the full photo — the feed stays visible and scrolled to where the user left it. useState and a {open && <PhotoModal />} conditional get this working in about ten minutes. Then someone refreshes the page while the modal is open, and the photo is just gone — back to the bare feed, because that boolean lived in memory and the URL never knew a modal was open. Someone else shares the link expecting to send a specific photo, and it opens to... the feed. The modal was never a place; it was a client state flag.

This is one of the few UI problems the App Router's own routing model was built to solve, and it does it with two conventions that are easy to skim past in the docs and hard to use correctly from memory: parallel routes and intercepting routes. This article is written against Next.js 16.3 (the current Active LTS release, verified against the framework's own file-convention docs and its GitHub releases in September 2026); the conventions below have been stable since Next.js 13 and are not part of the newer Cache Components model, so nothing here changes if you're on an app that hasn't adopted cacheComponents yet.

What you'll learn

By the end of this article you'll be able to:

  • Explain what a parallel route slot (@slot) actually is, and why it doesn't add a segment to the URL
  • Use default.tsx correctly, and explain exactly when Next.js needs it and why its absence produces a 404
  • Read the (.), (..), (..)(..), and (...) intercepting-route matchers and know which one a given folder move needs
  • Combine both conventions to build a modal that has a real, shareable, refreshable URL
  • Recognize the difference between a client-side navigation into an intercepted route and a hard navigation to the same URL, and why they render different things on purpose

Who this is for

You've built at least a small App Router project — you know what page.tsx and layout.tsx do, and you've used <Link> for client-side navigation. You don't need any prior experience with parallel or intercepting routes; we build both from nothing.

Table of contents

The problem: a modal that isn't really a place

Here's the naive version, and it's genuinely how most people reach for this first:

// app/feed/page.tsx — the "wrong way first"
"use client";
import { useState } from "react";

export default function Feed() {
  const [openPhoto, setOpenPhoto] = useState<string | null>(null);

  return (
    <>
      <PhotoGrid onSelect={(id) => setOpenPhoto(id)} />
      {openPhoto && (
        <PhotoModal id={openPhoto} onClose={() => setOpenPhoto(null)} />
      )}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

This works exactly as long as the user never leaves the tab. The moment they do any of the following, it falls apart:

  • Refresh the page. openPhoto was never anywhere but React state — it's gone. The URL is still just /feed.
  • Share the link. There's nothing to share; the modal was never addressable.
  • Use the back button. The browser doesn't know a modal was "opened" — there's no history entry for it.

The fix people reach for next is a separate route, /photo/[id]/page.tsx. That solves the URL problem, but now clicking a thumbnail navigates away from the feed entirely — the grid, its scroll position, and any in-flight state are gone, replaced by a page whose whole job is to show one photo. You've traded "not a real place" for "a real place that destroys the one you were just looking at."

What you actually want is a route that is real — refreshable, shareable, back-button-able — but that, when reached by clicking a link from the feed, renders as an overlay on top of the feed instead of replacing it. That's not a state management problem. It's a routing problem, and Next.js has a routing answer.

The mental model: slots, and routes that fill them differently

The mental model: a layout can have more than one independently-rendered subtree — Next.js calls each one a slot, written as a folder named @slotname. A slot is not a route segment; it doesn't appear in the URL and doesn't count as a level of nesting for anything else in the app. It exists purely so a layout can accept several pieces of UI as named "slots" and place them wherever it wants, each one navigable on its own.

An intercepting route is the second, separate piece: a way for one route to say "when the user gets to me by clicking a link from somewhere specific, render this UI instead of the destination's normal page — but if they land on me any other way (a fresh visit, a refresh, a shared link), render the real thing." The folder name encodes how far away "somewhere specific" is, using a dot convention measured in route segments: (.) the same level, (..) one level up, (..)(..) two levels up, (...) all the way from the app's root. Because slots aren't segments, they don't count when you're counting dots — this is the detail that trips people up first, and it's covered below.

Put together: the feed's layout gets a @modal slot. Normally that slot renders nothing. A link to /photo/[id] from inside the feed gets intercepted and rendered into the @modal slot as an overlay — same URL, same address bar, same shareable link, but rendered as a modal because of how the user arrived. Land on /photo/[id] directly, and the intercepting route steps aside; the real, full /photo/[id]/page.tsx renders instead.

Stage 1: a parallel route slot on its own

Start with just the slot mechanic, no interception yet. A layout can declare extra props beyond children by naming folders @something:

app/
  dashboard/
    layout.tsx
    page.tsx
    @analytics/
      page.tsx
    @team/
      page.tsx
Enter fullscreen mode Exit fullscreen mode
// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  team,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  team: React.ReactNode;
}) {
  return (
    <div className="dashboard-grid">
      <main>{children}</main>
      <aside>{analytics}</aside>
      <aside>{team}</aside>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Key concept: @analytics and @team are props on the layout, matched by folder name, not routes a visitor can navigate to directly. Each one is its own subtree with its own loading.tsx and error.tsx if you want them — the analytics panel can stream in behind its own <Suspense> boundary while the team panel is already sitting there rendered, because Next.js renders each slot independently. This alone is useful even with zero interception: it's how you give one section of a page its own loading and error state without wrapping the whole route in a single boundary.

Stage 2: default.tsx and the 404 it prevents

Slots need a default.tsx for a specific reason: Next.js has to render something in every slot on every request, and on a hard navigation — a fresh visit, a refresh, a link from outside the app — it has no idea what a slot "was previously showing." It can only know that from client-side navigation history. So it needs a fallback per slot to fall back to when it has nothing else to go on.

// app/dashboard/@analytics/default.tsx
export default function Default() {
  return null; // or a placeholder — whatever the slot should look like when idle
}
Enter fullscreen mode Exit fullscreen mode

Skip this file, and a hard navigation to a route that doesn't explicitly fill every slot renders a 404 for the whole page — not a silently empty slot, a 404. This is the single most common first bug with parallel routes, and it looks nothing like its cause: a page that works fine when you click into it from elsewhere in the app, then 404s the instant you hit refresh.

Stage 3: intercepting the photo route into the slot

Now the actual feature. The full, real photo page lives at its own route:

app/
  photo/
    [id]/
      page.tsx        # the real, standalone page for a direct visit
  feed/
    layout.tsx
    page.tsx           # the grid — links to /photo/[id]
    @modal/
      default.tsx       # renders null — no modal open
      (.)photo/
        [id]/
          page.tsx      # the INTERCEPTED version, rendered into @modal
Enter fullscreen mode Exit fullscreen mode

The folder (.)photo sits inside feed/@modal. Reading the dot convention: (.) matches a segment at the same level — and because @modal is a slot, not a segment, "the same level" here means the same level as feed itself. That's the detail from the mental model section made concrete: if @modal counted as a level, you'd reach for (..) instead, and it would be wrong.

// app/feed/@modal/(.)photo/[id]/page.tsx
import { PhotoModal } from "@/components/photo-modal";

export default async function InterceptedPhoto({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  return <PhotoModal id={id} />;
}
Enter fullscreen mode Exit fullscreen mode
// app/feed/layout.tsx
export default function FeedLayout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <>
      {children}
      {modal}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Click a <Link href="/photo/42"> from inside the feed, and Next.js's client-side router resolves it through the interception: the URL becomes /photo/42, but the component that renders is the one in @modal/(.)photo/[id]/page.tsx — layered over the still-mounted feed. Paste that same /photo/42 URL into a new tab, or hit refresh while it's open, and there's no "previous client-side location" to intercept from — Next.js renders the real app/photo/[id]/page.tsx instead, full-page, no feed underneath.

Key concept: the interception only fires for a client-side navigation whose previous route matches the dot-convention target. The URL is identical either way; only how you arrived decides which component runs. That's what makes the link genuinely shareable — the person you send it to always gets the real, full page, never a modal with no feed behind it.

Stage 4: closing the modal

Closing is just a navigation back to a URL that doesn't render the intercepted route — most simply, the browser back button, or a <Link> back to /feed, or router.back() from a close button:

// app/feed/@modal/(.)photo/[id]/page.tsx (excerpt)
"use client";
import { useRouter } from "next/navigation";

function CloseButton() {
  const router = useRouter();
  return <button onClick={() => router.back()}>Close</button>;
}
Enter fullscreen mode Exit fullscreen mode

Once the route no longer matches (.)photo/[id], the @modal slot falls back to its default.tsx — which renders null — and the modal disappears while the feed underneath was never unmounted.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

Edge cases and gotchas

  • Slots don't count toward the dot level. This is the error that produces no error message — the interception simply never fires, and a link just does an ordinary full navigation. If a dot convention "should" work by folder depth but silently doesn't, recount the levels using only real route segments, ignoring every @slot folder in between.
  • A missing default.tsx 404s on hard navigation, not on client navigation. Test parallel routes with an actual page refresh, not just by clicking around — clicking around is exactly the case that already works.
  • Parallel slots render sequentially within their shared layout, not concurrently with each other in the sense of wall-clock overlap on the server — each one still needs its own render pass. Three heavy slots are three render passes, not one; give the expensive ones their own loading.tsx so the cheap ones don't wait behind them.
  • Route groups aren't slots. A folder in parentheses without an @, like (marketing), organizes routes without adding a segment — a different feature that happens to share the "doesn't affect the URL" property. Don't reach for one when you mean the other.

Best practices

  • Reach for this when the UI is genuinely two things at once: a list and an overlay detail, a page and a login prompt, a cart and a drawer — cases where the underlying page must stay mounted and the overlay needs its own shareable URL.
  • Skip it for UI that has no reason to be a URL — a confirm-delete dialog, a tooltip, a dropdown. Reaching for parallel + intercepting routes there is solving a problem you don't have; plain component state is simpler and correct.
  • Always ship the real route. The whole value of this pattern comes from the full page at /photo/[id] existing and being correct on its own — never make it a stub that assumes it's always reached through the modal.
  • Give each slot its own loading and error boundaries rather than one boundary for the whole layout — that's what lets, say, an analytics panel stream independently of a sidebar that's already ready.

FAQ

Do parallel route slots show up in the URL?

No. A slot folder (@modal, @analytics) is a prop-passing mechanism for the layout above it, not a route a visitor can navigate to, and it adds no segment to the URL.

What happens if I forget default.tsx in a slot?

A hard navigation (a fresh visit or a refresh) to any route that doesn't explicitly fill that slot renders a 404 for the whole page, because Next.js has no client-side history to fall back on and no explicit fallback to use instead.

Can I nest intercepting routes more than one dot deep?

Yes — (..) for one segment up, (..)(..) for two, and (...) to intercept all the way from the app's root, however many real segments that spans. Count only actual route segments; folders that are slots or route groups don't add to the count.

Is this the same as a client-side modal library?

No, and that's the point. A client-side modal (a portal plus some open/close state) has no URL of its own — refreshing or sharing the page loses the modal entirely. Parallel + intercepting routes give the modal a real route, so it survives a refresh and can be shared as a link, while still rendering as an overlay when reached by clicking through the app.

Does this work with the Pages Router?

No — parallel routes and intercepting routes are App Router conventions. A Pages Router app building this same pattern has to reach for a client-side modal library or a custom routing layer instead; there's no filesystem equivalent to migrate.

Cheat sheet

Convention Syntax What it does
Parallel route slot @slotname/ folder Adds a named prop to the parent layout; doesn't add a URL segment
Slot fallback @slotname/default.tsx Rendered when Next.js has no other match for the slot (required to avoid a 404 on hard navigation)
Intercept same level (.)segment/ Intercepts a route at the same level as the intercepting folder
Intercept one level up (..)segment/ Intercepts a route one segment above
Intercept two levels up (..)(..)segment/ Intercepts a route two segments above
Intercept from root (...)segment/ Intercepts a route from the app's root, regardless of depth
Route group (not a slot) (name)/ folder Organizes routes without a @; also adds no URL segment, but carries no slot prop
// The whole pattern, minimal
// app/photo/[id]/page.tsx            — the real, standalone route
// app/feed/layout.tsx                — accepts `modal` as a prop
// app/feed/@modal/default.tsx        — returns null when idle
// app/feed/@modal/(.)photo/[id]/page.tsx — the intercepted, modal version

export default function FeedLayout({ children, modal }: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (<>{children}{modal}</>);
}
Enter fullscreen mode Exit fullscreen mode

🧠 Test yourself

Think it clicked? Take the 8-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

Key takeaways

  • A parallel route slot (@slotname) is a named subtree passed to a layout as a prop — it never appears in the URL and doesn't count as a segment for anything else.
  • Every slot needs a default.tsx, or a hard navigation that doesn't fill it renders a 404 for the whole page.
  • Intercepting routes ((.), (..), (..)(..), (...)) render different UI for the same URL depending on whether the user arrived by client-side navigation from a matching location or by a hard navigation — the URL itself never lies about what's really there.
  • Together, they build a modal, drawer, or overlay that is a real, shareable, refreshable route — not client state pretending to be one.

That photo modal from the top of this article can now survive a refresh, get shared as a link, and still feel like an overlay to anyone who clicked their way there — because it was never one thing pretending to be another. It's a route, and a modal, at the same time, and the App Router's file conventions are what make that not a contradiction.

If you've hit a case where the dot-level math didn't add up the way you expected, or a slot 404'd on you before you found default.tsx, drop it in the comments — that's exactly the kind of gotcha worth comparing notes on.

Earlier in this series: Next.js Cache Components Explained covers how the App Router decides what's static, cached, or streamed — a good companion if you're deciding how each slot here should fetch its data. And Next.js Server Actions: Mutations & Security is the natural next step if your modal needs to submit a mutation without a full page navigation.


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.