Sponsored Content

DEV Community

Ahmed Mahmoud
Ahmed Mahmoud

Posted on Originally published at devya.dev

Parallel Routes and Intercepting Routes in the Next.js App Router: Field Notes on the Photo-Modal Pattern

Headline: Parallel routes let a Next.js App Router layout render more than one page at the same URL through named @slot folders, and intercepting routes — the (.), (..), (..)(..), and (...) folder conventions — let a link swap in a different component for that slot without changing the URL. Together they're how an Instagram-style photo modal stays shareable: the same URL renders a modal on a soft navigation and a full page on a hard refresh, and default.tsx is the seam between the two.

Key takeaways

  • A parallel route is a named slot — a folder prefixed with @, like @modal — that Next.js renders as a prop into the nearest layout.tsx, alongside the implicit children slot. Slot folders don't add a segment to the URL.
  • Intercepting route conventions — (.), (..), (..)(..), (...) — match a route relative to the file system, not the URL, so a link clicked inside the feed can render /photo/[id] as a modal without any folder named (.)photo ever appearing in the URL.
  • default.tsx is what Next.js renders for a slot when the current URL doesn't match anything inside that slot. Skip it and a hard navigation to a route that doesn't fill every slot 404s.
  • A hard refresh or a shared link to /photo/123 is not supposed to show the modal — it's supposed to render app/photo/[id]/page.tsx as an ordinary full page. That fallback is the entire point of the pattern, not a bug to route around.
  • Each slot is its own subtree with its own loading.tsx and error.tsx, so a modal can suspend and stream independently of the page rendering behind it.

What problem do parallel routes actually solve?

A parallel route renders more than one page in the same layout at the same time, each addressed by a named slot instead of a URL segment. I first reached for this on a photo grid: clicking a thumbnail should open a lightbox without leaving the grid, but the lightbox also needed its own shareable URL so a link to one photo would open directly on a full page. A client-side modal driven by a boolean gets the first half for free and can't do the second half at all — there's no URL for a piece of state that lives in useState.

The folder convention is a name prefixed with @. Next.js passes each slot's matched content into the nearest layout as a prop named after the folder, alongside the implicit children slot every layout already receives.

app/
  layout.tsx
  page.tsx
  @modal/
    default.tsx
    (.)photo/
      [id]/
        page.tsx
  photo/
    [id]/
      page.tsx
Enter fullscreen mode Exit fullscreen mode
// app/layout.tsx
export default function RootLayout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {children}
        {modal}
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Nothing here is modal-specific yet — a layout with two slots is just a layout that renders two independent subtrees. I've since used the same mechanism for a dashboard with @team and @analytics panes that navigate independently: clicking a link inside @analytics re-renders only that slot, and @team keeps whatever it was showing.

How does the (.) convention decide what gets intercepted?

The dot-segment conventions match a target route relative to where the intercepting file sits in the file system, not relative to the current URL:

Convention Matches
(.) A route at the same folder level
(..) A route one folder level above
(..)(..) A route two folder levels above
(...) A route from the app root

app/@modal/(.)photo/[id]/page.tsx intercepts a client-side navigation to /photo/[id] when that navigation is triggered from a route sitting at the same level as the @modal folder. Follow a <Link href="/photo/123"> from that page and Next.js renders the intercepting component into the @modal slot instead of swapping out the whole tree. Open /photo/123 as a fresh page load — refresh, paste the URL, click a link from an external site — and Next.js resolves app/photo/[id]/page.tsx the ordinary way. The interception only fires on a client-side transition; it was never meant to change what a full page load returns.

Why does default.tsx exist, and what happens if I skip it?

The first time I skipped it, everything worked in dev until I hit refresh on a route with the modal open and got a 404. A slot without a matching segment for the current URL needs something to render, and on a full page load there's no prior client state to fall back to — Next.js has to render the slot from scratch. default.tsx is that fallback.

// app/@modal/default.tsx
export default function Default() {
  return null;
}
Enter fullscreen mode Exit fullscreen mode

Client-side navigation is more forgiving: if a slot has no default.tsx and the new URL doesn't match anything inside it, Next.js keeps rendering whatever that slot last showed rather than unmounting it. That's genuinely useful for the dashboard-tabs case — switching tabs in @analytics shouldn't reset @team — but it means a missing default.tsx can hide itself in every manual click-through test and only surface on a hard reload, which is exactly the path a shared link takes.

Why does refreshing the modal route show the full page instead of the modal?

Because it's supposed to. That was the part I fought before I read the pattern correctly: I wanted the modal to somehow survive a refresh, and the actual design is that it shouldn't. app/photo/[id]/page.tsx is a complete, independent page — same content, no modal chrome, reachable without a single line of client JavaScript executing first. That's what makes the URL genuinely shareable and indexable: a search engine or a link preview bot that doesn't run your client-side router gets the real page, not an empty shell waiting for JavaScript to open a dialog.

How do I close the modal and get back to where I came from?

The modal component is a Client Component that calls router.back() from next/navigation, which reverses the soft navigation that opened it and lets the intercepted slot fall back to its default.tsx.

'use client';
import { useRouter } from 'next/navigation';

export function Modal({ children }: { children: React.ReactNode }) {
  const router = useRouter();
  return (
    <div className="modal-overlay" onClick={() => router.back()}>
      <div className="modal-content" onClick={(e) => e.stopPropagation()}>
        {children}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The gap I found in production: a visitor who opens /photo/123 directly in a new tab from a shared link has no history entry to go back to, so router.back() either does nothing or leaves the app. I ended up rendering an explicit close link with a real href back to the gallery alongside the overlay handler, so closing the modal never depends on history existing.

Client-state modal vs. intercepting-route modal — what's the actual trade-off?

Boolean-state modal Intercepting route modal
Own URL No Yes
Shareable / shows on refresh No Yes — renders as a full page
Needs client JS to render content at all Yes No, on direct navigation
Setup cost One useState A slot, an intercepting folder, a fallback route, default.tsx

For a dialog that's genuinely disposable UI — a confirmation prompt, a settings panel — the boolean is still the right tool. Reach for the routed version specifically when the content behind the modal deserves its own URL.

FAQ

Q: Do parallel route slot folders like @modal show up in the URL?
A: No. The @ prefix marks a folder as a slot rather than a route segment, so it's invisible to the URL and only affects which prop of the layout receives its content.

Q: What happens if someone navigates straight to /photo/123 instead of clicking a thumbnail in the feed?
A: Next.js renders app/photo/[id]/page.tsx as a normal full page. The interception only happens on a client-side transition from a matching route, by design.

Q: Can a parallel route slot have its own loading.tsx?
A: Yes. Each slot is an independent subtree and can define its own loading.tsx, error.tsx, and not-found.tsx, so a modal can suspend on its own data fetch without blocking the page behind it.

Q: Why doesn't router.back() close the modal for someone who opened the link directly?
A: There's no history entry to go back to when a route is the first thing loaded in a tab. Pair the close handler with an explicit link to a real fallback route instead of relying on history alone.

Q: Can I combine (..)(..) with a parallel route slot defined several levels up the tree?
A: Yes — the dot count is independent of where the @slot folder lives; it only describes how many folder levels above the intercepting file's own location the target route sits.


Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.

Top comments (0)