DevLift
Back to Blog

Next.js: Parallel Routes and Intercepting Routes — Build URL-Driven Modals and Complex Layouts

Parallel routes let a single layout render multiple independent pages at once. Combine them with intercepting routes and you get URL-aware modals with zero hacks.

Admin
August 3, 20267 min read4 views

Next.js: Parallel Routes and Intercepting Routes — Build URL-Driven Modals and Complex Layouts

You open a photo on Instagram. The URL changes to /p/abc123. You hit refresh — still on the photo. You copy the link and send it to a friend — they open the full photo page. But you saw a modal over the feed. Same URL, different UI based on how you got there.

That's the pattern these two Next.js App Router features make possible. Together they solve one of the oldest frontend problems: modals that need to be both ephemeral overlays and linkable pages.

Let's break down how each feature works, then combine them.

Parallel Routes — Multiple Pages, One Layout

Normally a layout renders one children slot. Parallel routes let you render multiple independent route segments side by side. Each segment has its own loading state, error boundary, and can be navigated independently.

The @slot convention

You define a parallel route by prefixing a folder name with @. That folder is called a slot.

app/
  dashboard/
    layout.tsx          ← receives { children, analytics, team } props
    page.tsx            ← fills the implicit @children slot
    @analytics/
      page.tsx
    @team/
      page.tsx

The layout automatically gets each named slot as a prop:

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  team,
}: {
  children: React.ReactNode
  analytics: React.ReactNode
  team: React.ReactNode
}) {
  return (
    <div className="grid grid-cols-3 gap-4 p-6">
      <main className="col-span-2">{children}</main>
      <aside className="space-y-4">
        {analytics}
        {team}
      </aside>
    </div>
  )
}

Each slot is a full route segment — it gets its own loading.tsx, error.tsx, and can house dynamic routes. That means your analytics widget can show a loading spinner independently while the main content has already rendered. No waterfall, no shared suspense boundary catching everything.

The default.tsx problem (don't skip this)

Here's where people get burned, and here's where most of what's written about it is out of date.

During soft navigation Next.js tracks which subpage each slot is currently showing. On a hard navigation — typing the URL, hitting refresh, clicking a link from another site — it can't recover that state, so it has to render every slot from the URL alone. /dashboard is fine: @analytics/page.tsx matches it. The problem is a sibling route. Navigate straight to /dashboard/revenue and the main segment has a page but @analytics and @team have nothing that matches.

Older articles (and older Next.js) will tell you this returns a 404. On 16.1.6 it doesn't — it fails the build:

$ next build
Missing required default.js file for parallel route at /dashboard/@analytics
The parallel route slot "@analytics" is missing a default.js file. When using
parallel routes, each slot must have a default.js file to serve as a fallback.
Create a default.js file at: /dashboard/@analytics/default.js
https://nextjs.org/docs/messages/slot-missing-default

That's an improvement — you find out at build time instead of from a bug report — but it means the symptom you're looking for is a red build, not a mysterious 404. In next dev it's a 500 on every route in the app until you add the file, which is disorienting the first time.

// app/dashboard/@analytics/default.tsx
// Required — renders when no subpage matches during hard navigation
export default function AnalyticsDefault() {
  return null  // or a skeleton, or a placeholder
}

If you actually want the old 404 behaviour for a slot, you have to ask for it:

// app/dashboard/@analytics/default.tsx
import { notFound } from 'next/navigation'
 
export default function AnalyticsDefault() {
  notFound()
}
⚠️
Every named @slot folder needs a default.tsx, and so does the implicit children slot if the parent's active state can't be recovered — without one, children falls back to the 404 page. Adding default.tsx returning null to every slot is the cheap, boring, correct move.

Slots don't affect URLs

This trips people up. The folder @analytics does not appear in the URL. /dashboard renders @analytics/page.tsx at the same time as page.tsx. Navigating to /dashboard/revenue would render @analytics/revenue/page.tsx alongside page.tsx (if that page exists in the main segment too).

Slot folders are invisible to the URL. Route groups (folders wrapped in parentheses) are also URL-invisible. Keep that in mind when constructing intercepting routes.

Intercepting Routes — Context-Aware Navigation

Intercepting routes let you show a different UI for the same URL depending on how the user got there. Navigate client-side (soft navigation via <Link>) and you see one thing. Navigate directly (hard navigation, refresh) and you see another.

The (..) notation

You intercept a route by wrapping a folder name in parentheses with dots indicating how many segments to traverse up:

ConventionMeaning
(.)folderIntercept a sibling route (same level)
(..)folderIntercept a route one level up
(..)(..)folderIntercept a route two levels up
(...)folderIntercept from the app root

Important: the dots count URL segments, not file-system directories. @slot folders and (group) folders are transparent to this counting.

So if your route is app/photos/[id]/page.tsx and you want to intercept it from a sibling slot, you use (.)photos/[id].

The Combined Pattern: URL-Aware Modals

This is where things click. The classic Unsplash pattern:

  • User browses /feed — sees a photo grid
  • Clicks a photo — URL becomes /photos/42, but they see a modal over the feed
  • Refresh — the modal is gone, they see /photos/42 as a standalone full page
  • Copy + share the URL — recipient sees the full photo page, not the modal
app/
  layout.tsx           ← root layout with @modal slot
  page.tsx             ← home feed
  @modal/
    default.tsx        ← returns null (modal closed state)
    (.)photos/
      [id]/
        page.tsx       ← modal content (intercepted)
  photos/
    [id]/
      page.tsx         ← full standalone page (direct access)

The root layout receives the modal slot:

// app/layout.tsx
export default function RootLayout({
  children,
  modal,
}: {
  children: React.ReactNode
  modal: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        {children}
        {modal}  {/* renders null by default, modal content when intercepted */}
      </body>
    </html>
  )
}

The default slot returns null so no modal renders normally:

// app/@modal/default.tsx
export default function ModalDefault() {
  return null
}

The intercepted route renders the modal UI:

// app/@modal/(.)photos/[id]/page.tsx
import { Modal } from '@/components/modal'
import { PhotoDetail } from '@/components/photo-detail'
 
export default async function PhotoModal({
  params,
}: {
  params: Promise<{ id: string }>  // a Promise since Next.js 15
}) {
  const { id } = await params
  return (
    <Modal>
      <PhotoDetail id={id} />
    </Modal>
  )
}

The standalone page for direct access:

// app/photos/[id]/page.tsx
import { PhotoDetail } from '@/components/photo-detail'
 
export default async function PhotoPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  return (
    <div className="max-w-4xl mx-auto p-8">
      <PhotoDetail id={id} />
    </div>
  )
}

The modal component itself:

// components/modal.tsx
'use client'
 
import { useRouter } from 'next/navigation'
 
export function Modal({ children }: { children: React.ReactNode }) {
  const router = useRouter()
 
  return (
    <div
      className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center"
      onClick={() => router.back()}
    >
      <div
        className="bg-white rounded-xl max-w-2xl w-full p-6"
        onClick={(e) => e.stopPropagation()}
      >
        {children}
      </div>
    </div>
  )
}
Use router.back() to close the modal — not router.push('/'). back() pops the history entry that opened the modal, returning the user to whatever page they were actually on, with scroll position intact. push('/') sends everyone to the same hardcoded route and adds an entry, so the browser Back button re-opens the modal they just dismissed.

How the flow actually works

Rendering diagram...

During soft navigation, Next.js detects the (.)photos interceptor inside @modal and renders that instead of the real photos/[id]/page.tsx. The URL updates to /photos/42 but the @modal slot is now active, and the children slot (your feed) stays exactly where it was.

During hard navigation — refresh, direct URL, external link — the interceptor doesn't fire. Next.js renders photos/[id]/page.tsx as a normal page, and @modal/default.tsx (returning null) fills the modal slot.

You can watch the decision happen without opening a browser. A soft navigation is just a request carrying the RSC headers, so building the tree above on 16.1.6 and hitting the same URL two ways:

# Hard navigation
curl localhost:3000/photos/42
# → <div data-kind="FULLPAGE">FULL PAGE for photo 42</div>
 
# Soft navigation from /feed (what <Link> sends)
curl -H 'RSC: 1' -H 'Next-Url: /feed' localhost:3000/photos/42
# → MODAL for photo 42

Same route, same server, two different components — selected by Next-Url, the header the client router sends to say where the user currently is. That header is the entire mechanism.

Variations

Split dashboard with independent loading

Parallel routes without any interception — useful when you want completely independent data fetching per panel:

app/
  dashboard/
    layout.tsx
    page.tsx             ← main content
    @activity/
      page.tsx           ← recent activity feed
      loading.tsx        ← its own loading UI
    @metrics/
      page.tsx           ← analytics widgets
      loading.tsx
      error.tsx          ← isolated error state

Each slot's loading.tsx fires independently. A slow metrics panel shows its own skeleton while the activity feed next to it has already rendered — whichever resolves first paints first. No Promise.all, no shared suspense boundary eating everything.

Auth modal with intercepting routes

A common pattern: /login is a real page, but clicking "Sign in" anywhere on the site opens it as a modal without losing your place:

app/
  layout.tsx            ← has @auth slot
  page.tsx
  login/
    page.tsx            ← full login page (direct access)
  @auth/
    default.tsx         ← null
    (.)login/
      page.tsx          ← login modal

Conditional tab content

You can use parallel routes to build tabs where each tab has its own URL, back/forward history, and loading state:

// Each tab is a real route: /dashboard/overview, /dashboard/settings, etc.
// But they all render within the same dashboard layout
 
// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <div>
      <nav>
        <Link href="/dashboard/overview">Overview</Link>
        <Link href="/dashboard/settings">Settings</Link>
      </nav>
      {children}
    </div>
  )
}

No @slots needed here — just normal nested routes. Parallel routes become worthwhile when you need simultaneous rendering of independent sections.

When NOT to use this

Simple modals with no URL requirement. If the modal content doesn't need a shareable URL, doesn't need to survive a refresh, and doesn't need to be accessible via direct link — just use a state-based modal. useState(false) + a Dialog component is 10 lines. The parallel + intercepting routes setup is 6+ files. Match the tool to the need.

Tabs that only need client state. If your tabs just swap visible content and don't need URL-driven navigation, a simple activeTab state variable is cleaner and faster to implement.

When you're on Pages Router. This entire pattern is App Router-only. There's no equivalent in the Pages Router.

Deeply nested modal hierarchies. Opening a modal from inside a modal with this pattern gets complicated fast. The intercepting routes convention gets hard to reason about when you're three levels deep. For nested modals, a state-management approach (zustand, context, or a modal manager library) is usually cleaner.

When the gotchas outweigh the benefits. The default.tsx requirement, the dot-counting in intercepting routes, the fact that params is a Promise since Next.js 15 — these add up. If your team isn't familiar with the App Router's mental model, the debugging cost when something breaks can be high.

💡
Next.js 15 changed route params to be async: params: Promise<{ id: string }>, and 16 keeps it that way. You must await params before destructuring. This affects all dynamic routes, including intercepted ones and default.tsx files, which receive params too.

The gotchas worth memorizing

Missing default.tsx — no longer silent, but still the first thing to check. On 16 it stops the build with slot-missing-default; on 14 and earlier it was a 404 on refresh that worked fine after client-side navigation, which is the version most existing write-ups describe. Either way: add default.tsx to every slot, even if it just returns null.

router.back() vs router.push() for closing modals. Always back(). Push adds a history entry, so Back re-opens what the user just closed.

Slot folders don't count as URL segments. @modal/(.)photos — the @modal part is invisible to the dot counting. (.) still means "same URL level as the current route segment."

Route groups don't count either. (app)/photos/(.)detail — the (app) wrapper is also transparent to the dots.

When the pattern is right for your use case, it genuinely eliminates the modal mess: no searchParams hacks, no hiding/showing based on history state, no copy-paste of modal content into two components. The URL is the state. The file system is the logic. That's the deal.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

Server Actions collapse form handling into a single server function—no API route, no manual fetch, no useState for loading. Here's the pattern that replaces 80% of your mutation boilerplate.
AdminAugust 3, 20267 min read
One file to guard every route — plus the Next.js 16 rename that moves it off the Edge runtime, the request-vs-response header trap that leaks user IDs to the browser, and the CVE that explains why this can never be your only auth layer.
AdminAugust 3, 20268 min read
The EventEmitter pattern lets components in the same process react to the same event without being directly coupled — no message broker needed.
AdminAugust 3, 20266 min read