Next.js Parallel Routes and Intercepting Routes: The Complete Guide to Modals, Dashboards, and Advanced Layouts

Learn how to build URL-driven modals, multi-panel dashboards, and Instagram-style photo galleries using Next.js parallel routes and intercepting routes with production-ready code examples.

Introduction

The Next.js App Router changed how we think about routing — and honestly, it goes way beyond just navigating from one page to another. It gives you the tools to build things like dashboards with independent panels, Instagram-style photo modals, role-based layouts, and shareable modal URLs, all through file-system conventions.

Two of the most powerful (and let's be honest, underutilized) features behind these patterns are Parallel Routes and Intercepting Routes. Each one is useful on its own, but when you combine them? That's where the real magic happens — unlocking UX patterns that used to require tangled client-side state management and fragile history manipulation hacks.

So, let's dive in. In this guide, we'll cover how both features work from the ground up, build real-world examples including a multi-panel dashboard and an Instagram-style photo gallery modal, walk through common pitfalls, and leave you with production-ready patterns you can use right away.

Understanding Parallel Routes

Parallel Routes let you render one or more pages simultaneously — or conditionally — within the same layout. Think of them as named "slots" in your layout that can each display different content, load independently, and handle their own errors without stepping on each other's toes.

Defining Slots with the @ Convention

You create a parallel route by adding a folder prefixed with @ to your app directory. These folders are called slots, and each one gets passed as a prop to the parent layout component.

app/
├── layout.tsx
├── page.tsx
├── @analytics/
│   ├── page.tsx
│   └── default.tsx
└── @notifications/
    ├── page.tsx
    └── default.tsx

In the parent layout, you receive each slot as a named prop alongside the implicit children prop:

// app/layout.tsx
export default function Layout({
  children,
  analytics,
  notifications,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  notifications: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <main>{children}</main>
        <aside>
          {analytics}
          {notifications}
        </aside>
      </body>
    </html>
  );
}

Here's a key insight that tripped me up at first: the children prop is actually an implicit slot. Writing app/page.tsx is the same as writing app/@children/page.tsx. So every layout is already using at least one parallel route — you just might not realize it.

The Critical Role of default.tsx

The default.tsx file is a fallback. It renders when Next.js can't determine the active state of a slot. Why does this matter? Because of how Next.js handles soft navigation versus hard navigation.

During soft navigation (client-side Link clicks), Next.js does a partial render. It keeps the active state of all slots, even if a slot doesn't match the current URL. Everything feels seamless.

During hard navigation (a full page reload or typing the URL directly), Next.js can't recover the previous in-memory state. For any slot that doesn't match the current URL, it renders default.tsx. And if there's no default.tsx? You get a 404.

// app/@analytics/default.tsx
export default function AnalyticsDefault() {
  // Return null to render nothing, or a skeleton/placeholder
  return null;
}

Rule of thumb: Always create a default.tsx for every single slot. Forgetting this file is, without question, the most common source of bugs when working with parallel routes. I've lost more debugging time to this than I care to admit.

Building a Multi-Panel Dashboard

Dashboards are the canonical use case for parallel routes. Picture an admin panel where you need to show user metrics, recent activity, and revenue charts — each pulling its own data independently, each with its own loading and error states.

Project Structure

app/dashboard/
├── layout.tsx
├── page.tsx              # Main overview (children slot)
├── @metrics/
│   ├── page.tsx          # Metrics panel
│   ├── loading.tsx       # Metrics skeleton
│   ├── error.tsx         # Metrics error boundary
│   └── default.tsx       # Fallback
├── @activity/
│   ├── page.tsx          # Activity feed
│   ├── loading.tsx       # Activity skeleton
│   ├── error.tsx         # Activity error boundary
│   └── default.tsx       # Fallback
└── @revenue/
    ├── page.tsx          # Revenue charts
    ├── loading.tsx       # Revenue skeleton
    ├── error.tsx         # Revenue error boundary
    └── default.tsx       # Fallback

The Dashboard Layout

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  metrics,
  activity,
  revenue,
}: {
  children: React.ReactNode;
  metrics: React.ReactNode;
  activity: React.ReactNode;
  revenue: React.ReactNode;
}) {
  return (
    <div className="min-h-screen bg-gray-50">
      <header className="bg-white border-b px-6 py-4">
        <h1 className="text-2xl font-bold">Admin Dashboard</h1>
      </header>
      <div className="p-6">
        {children}
        <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mt-6">
          {metrics}
          {activity}
          {revenue}
        </div>
      </div>
    </div>
  );
}

Independent Loading and Error States

Each slot streams on its own. If the revenue chart takes three seconds to load while metrics load instantly, the user sees metrics right away with a loading skeleton where the revenue chart will go. If the activity feed throws an error, only that panel shows the error boundary — everything else stays fully interactive.

This is honestly one of my favorite things about parallel routes.

// app/dashboard/@metrics/loading.tsx
export default function MetricsLoading() {
  return (
    <div className="bg-white rounded-lg p-6 shadow animate-pulse">
      <div className="h-4 bg-gray-200 rounded w-1/3 mb-4" />
      <div className="h-8 bg-gray-200 rounded w-1/2" />
    </div>
  );
}

// app/dashboard/@metrics/error.tsx
'use client';

export default function MetricsError({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div className="bg-red-50 rounded-lg p-6 border border-red-200">
      <h3 className="text-red-800 font-medium">Failed to load metrics</h3>
      <p className="text-red-600 text-sm mt-1">{error.message}</p>
      <button
        onClick={reset}
        className="mt-3 text-sm bg-red-100 px-3 py-1 rounded hover:bg-red-200"
      >
        Try again
      </button>
    </div>
  );
}

This isolation is a huge upgrade over the traditional approach of cramming everything into a single page component. With parallel routes, React's Suspense and Error Boundary mechanisms work at the slot level, giving you granular control without any manual wiring.

Conditional Rendering Based on User Role

Parallel routes also let you do conditional rendering. You can check the user's role right in the layout and decide which slot to show:

// app/dashboard/layout.tsx
import { getSession } from "@/lib/auth";

export default async function DashboardLayout({
  children,
  admin,
  user,
}: {
  children: React.ReactNode;
  admin: React.ReactNode;
  user: React.ReactNode;
}) {
  const session = await getSession();
  const isAdmin = session?.user?.role === "admin";

  return (
    <div>
      {isAdmin ? admin : user}
    </div>
  );
}

With @admin and @user slots, you can serve entirely different experiences from the same URL based on authentication state, feature flags, A/B test groups, or really any server-side condition you can think of.

Understanding Intercepting Routes

Intercepting Routes let you load a route from another part of your app within the current layout — without the user actually navigating away. The URL updates in the browser, but the page context stays intact.

Think of it this way: when a user clicks a link, instead of doing a full navigation to the new page, Next.js "intercepts" that navigation and renders the target route's content right inside the current layout. But if someone visits that URL directly (hard navigation, page refresh, or a shared link), the full page renders normally.

It's a surprisingly elegant system once it clicks.

The (..) Convention

Intercepting routes use a special naming convention based on route segments (not file-system directories):

  • (.) — matches segments on the same level
  • (..) — matches segments one level above
  • (..)(..) — matches segments two levels above
  • (...) — matches segments from the root app directory

Now here's the thing that trips everyone up: the (..) convention counts route segments, not file-system directories. Slot folders (@modal) and route groups ((group)) are not counted as segments. This single distinction causes more confusion than probably anything else in the App Router.

For example, if your file-system path is app/@modal/(.)photos/[id]/page.tsx, the (.) convention targets a route segment at the same level as the layout containing the @modal slot — even though in the file system it looks like it's nested deeper.

When to Use Intercepting Routes

Intercepting routes are a great fit when you want to:

  • Show content in a modal while updating the URL
  • Preserve the user's scroll position and page context
  • Make modal content shareable via URL
  • Support direct navigation to a full-page version of the same content
  • Let browser back/forward navigation open and close the modal naturally

The Modal Pattern: Parallel + Intercepting Routes Combined

This is where things get really interesting. The most powerful pattern in the App Router emerges when you combine parallel routes with intercepting routes to build URL-driven modals. This technique addresses every classic modal headache: shareability, deep linking, refresh persistence, and natural browser history behavior.

How It Works Conceptually

  1. A parallel route slot (e.g., @modal) is defined in the layout to hold modal content
  2. An intercepting route inside that slot captures specific navigations and renders them as a modal overlay
  3. A full page exists at the actual route for direct access and hard navigation
  4. The default.tsx in the modal slot returns null when no modal is active

Simple enough in theory. Let's build it.

Building an Instagram-Style Photo Gallery Modal

Let's build a complete photo gallery where clicking a photo opens it in a modal overlay (with the URL changing to /photos/[id]), while directly navigating to that URL shows the full photo page. This is the same pattern Instagram, Pinterest, and X use.

File Structure

app/
├── layout.tsx
├── page.tsx                          # Gallery grid
├── photos/
│   └── [id]/
│       └── page.tsx                  # Full photo page (hard navigation)
└── @modal/
    ├── default.tsx                   # Returns null (no modal active)
    └── (.)photos/
        └── [id]/
            └── page.tsx             # Photo modal (intercepted navigation)

Step 1: Root Layout with Modal Slot

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

Step 2: The Gallery Page

// app/page.tsx
import Link from "next/link";
import { getPhotos } from "@/lib/data";

export default async function GalleryPage() {
  const photos = await getPhotos();

  return (
    <div className="max-w-6xl mx-auto p-8">
      <h1 className="text-3xl font-bold mb-8">Photo Gallery</h1>
      <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
        {photos.map((photo) => (
          <Link
            key={photo.id}
            href={`/photos/${photo.id}`}
            className="group relative aspect-square overflow-hidden rounded-lg"
          >
            <img
              src={photo.thumbnailUrl}
              alt={photo.title}
              className="object-cover w-full h-full group-hover:scale-105 transition-transform"
            />
          </Link>
        ))}
      </div>
    </div>
  );
}

Notice we're using the standard Next.js Link component here — never a plain <a> tag. The Link component enables client-side navigation, which is what makes route interception work in the first place.

Step 3: The Modal Default (No Modal Active)

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

When no interception is active, this slot renders nothing. The gallery page just displays on its own.

Step 4: The Intercepted Modal Route

// app/@modal/(.)photos/[id]/page.tsx
import { getPhoto } from "@/lib/data";
import { Modal } from "@/components/modal";

export default async function PhotoModal({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const photo = await getPhoto(id);

  return (
    <Modal>
      <div className="max-w-4xl w-full bg-white rounded-xl overflow-hidden shadow-2xl">
        <div className="aspect-video relative">
          <img
            src={photo.url}
            alt={photo.title}
            className="object-contain w-full h-full"
          />
        </div>
        <div className="p-6">
          <h2 className="text-2xl font-bold">{photo.title}</h2>
          <p className="text-gray-600 mt-2">{photo.description}</p>
          <p className="text-sm text-gray-400 mt-4">
            By {photo.photographer} &middot; {photo.date}
          </p>
        </div>
      </div>
    </Modal>
  );
}

This route only kicks in during soft navigation — when the user clicks a Link in the gallery. It renders inside the @modal slot, overlaying the gallery page underneath.

Step 5: The Modal Component

// components/modal.tsx
"use client";

import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef } from "react";

export function Modal({ children }: { children: React.ReactNode }) {
  const router = useRouter();
  const overlayRef = useRef<HTMLDivElement>(null);

  const onDismiss = useCallback(() => {
    router.back();
  }, [router]);

  useEffect(() => {
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key === "Escape") onDismiss();
    };
    document.addEventListener("keydown", onKeyDown);
    return () => document.removeEventListener("keydown", onKeyDown);
  }, [onDismiss]);

  const handleOverlayClick = (e: React.MouseEvent) => {
    if (e.target === overlayRef.current) onDismiss();
  };

  return (
    <div
      ref={overlayRef}
      onClick={handleOverlayClick}
      className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
    >
      <div className="relative">
        <button
          onClick={onDismiss}
          className="absolute -top-4 -right-4 z-10 bg-white rounded-full p-2 shadow-lg hover:bg-gray-100"
          aria-label="Close modal"
        >
          &times;
        </button>
        {children}
      </div>
    </div>
  );
}

The modal closes via router.back(), which pops the intercepted route from the browser history. This means the back button works exactly as you'd expect — closing the modal returns the user to the gallery with their scroll position preserved. No custom state management needed.

Step 6: The Full Photo Page (Hard Navigation Fallback)

// app/photos/[id]/page.tsx
import { getPhoto } from "@/lib/data";
import Link from "next/link";
import { notFound } from "next/navigation";

export default async function PhotoPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const photo = await getPhoto(id);

  if (!photo) notFound();

  return (
    <div className="max-w-4xl mx-auto p-8">
      <Link
        href="/"
        className="text-blue-600 hover:underline mb-6 inline-block"
      >
        &larr; Back to gallery
      </Link>
      <div className="bg-white rounded-xl overflow-hidden shadow-lg">
        <img
          src={photo.url}
          alt={photo.title}
          className="w-full"
        />
        <div className="p-8">
          <h1 className="text-3xl font-bold">{photo.title}</h1>
          <p className="text-gray-600 mt-4 text-lg">{photo.description}</p>
          <div className="mt-6 text-gray-400">
            By {photo.photographer} &middot; {photo.date}
          </div>
        </div>
      </div>
    </div>
  );
}

When someone shares the URL /photos/42 and another person opens it directly, they see this full-page version. The intercepted modal route is only used during soft navigation. This dual-rendering strategy is really the core value of combining parallel and intercepting routes.

Building an Authentication Modal

Another really practical use case: an authentication modal. The user clicks "Sign In" in the navbar, a modal pops up with the login form (URL changes to /login), but the page behind stays visible. If someone visits /login directly, they get a full login page instead.

File Structure

app/
├── layout.tsx
├── page.tsx                          # Home page
├── login/
│   └── page.tsx                      # Full login page
└── @auth/
    ├── default.tsx                   # Returns null
    ├── (.)login/
    │   └── page.tsx                  # Login modal
    └── [...catchAll]/
        └── page.tsx                  # Catch-all returns null

The Catch-All Slot Pattern

See that [...catchAll] directory inside @auth? This is an important pattern. It makes sure that when the user navigates to any route other than /login, the auth slot matches and returns null (effectively closing the modal), rather than blowing up with a 404.

// app/@auth/[...catchAll]/page.tsx
export default function AuthCatchAll() {
  return null;
}

Without this catch-all, navigating from the login modal to, say, /about via a Link would fail because the @auth slot has no matching route for /about. It's a small thing, but it'll save you a lot of headaches.

The Login Modal

// app/@auth/(.)login/page.tsx
import { Modal } from "@/components/modal";
import { LoginForm } from "@/components/login-form";

export default function LoginModal() {
  return (
    <Modal>
      <div className="bg-white rounded-xl p-8 w-full max-w-md shadow-2xl">
        <h2 className="text-2xl font-bold mb-6">Sign In</h2>
        <LoginForm />
      </div>
    </Modal>
  );
}

A nice design advantage here: the LoginForm can be a Server Component containing your Server Action for form submission. You get to separate the interactive modal shell (client component) from the form content (server component), keeping your client bundle lean.

Advanced Patterns and Techniques

Tab-Based Navigation with Parallel Routes

Parallel routes can also power tab interfaces where each tab gets its own URL and loads independently:

app/settings/
├── layout.tsx
├── page.tsx                    # Redirects to /settings/profile
├── @tabs/
│   ├── default.tsx
│   ├── profile/
│   │   └── page.tsx            # Profile settings tab
│   ├── security/
│   │   └── page.tsx            # Security settings tab
│   └── billing/
│       └── page.tsx            # Billing settings tab
// app/settings/layout.tsx
import Link from "next/link";

export default function SettingsLayout({
  children,
  tabs,
}: {
  children: React.ReactNode;
  tabs: React.ReactNode;
}) {
  return (
    <div className="max-w-4xl mx-auto p-8">
      <h1 className="text-2xl font-bold mb-6">Settings</h1>
      <nav className="flex gap-4 border-b mb-6">
        <Link href="/settings/profile" className="pb-2 border-b-2">
          Profile
        </Link>
        <Link href="/settings/security" className="pb-2">
          Security
        </Link>
        <Link href="/settings/billing" className="pb-2">
          Billing
        </Link>
      </nav>
      {tabs}
    </div>
  );
}

Multi-Modal Patterns with Route Groups

When your app has multiple types of modals (login, photo preview, settings), you can organize them all under a single parallel route slot using separate intercepting routes:

app/
├── layout.tsx
├── @modal/
│   ├── default.tsx
│   ├── (.)login/
│   │   └── page.tsx             # Login modal
│   ├── (.)photos/
│   │   └── [id]/
│   │       └── page.tsx         # Photo modal
│   └── (.)settings/
│       └── page.tsx             # Settings modal

Consolidating all modals under one @modal slot keeps your layout clean — it only needs one extra prop — while each intercepting route handles its own content and data fetching independently. Much tidier than the alternative.

Nested Parallel Routes

Here's something that might surprise you: parallel routes can be nested. A dashboard layout can have parallel route slots, and each slot can contain its own sub-slots for further decomposition:

app/dashboard/
├── layout.tsx
├── @sidebar/
│   ├── layout.tsx
│   ├── page.tsx
│   ├── @quickActions/
│   │   └── page.tsx
│   └── @recentItems/
│       └── page.tsx
└── @main/
    └── page.tsx

This hierarchical approach works well for complex enterprise applications where different sections of the UI have their own independent data requirements and rendering lifecycles. That said, don't over-nest — keep it as simple as your use case allows.

Common Gotchas and How to Fix Them

1. Missing default.tsx Causes 404 on Refresh

This is the number one issue. You implement a modal with parallel routes, everything works beautifully during client-side navigation, and then you hit refresh. 404.

Fix: Add a default.tsx that returns null to every parallel route slot. Also add one to the root app/ directory if you have parallel routes in the root layout, since children is an implicit slot that needs a default too.

2. The (..) Convention Counts Segments, Not Directories

This one bites almost everyone at least once. Developers miscount because @slot folders and (group) folders aren't route segments.

# File system:
app/@modal/(.)photos/[id]/page.tsx
#   ^--- @modal is NOT a segment
#         ^--- (.) matches the SAME level as the layout

# Route segments:  / → photos → [id]
# The (.) targets "photos" at the same level as root layout

Fix: Count only actual route segments — the folders that show up in the URL. Ignore @ slots and () groups entirely.

3. Using <a> Tags Instead of Link

Intercepting routes only work with soft navigation. If you use a plain HTML anchor tag, the browser performs a full page load and the intercepting route gets bypassed entirely.

Fix: Always use the Link component from next/link or router.push() from next/navigation.

4. Route Groups and Intercepting Routes Compatibility

There are some known limitations when combining route groups (group) with intercepting routes. In certain cases, the interception just won't work as you'd expect.

Fix: Keep your intercepting routes outside of route groups when you can. If you must use route groups, test the interception behavior thoroughly and be prepared to restructure if things get weird.

5. Closing the Modal via Link Navigation

If you use a Link to navigate away while a modal is open, the modal slot might persist because the new route doesn't explicitly unmount it.

Fix: Use the replace prop on the Link component when navigating away from a modal context. Or better yet, use the catch-all pattern ([...catchAll]) in your modal slot to ensure all non-modal routes render null.

6. Dynamic Routes Inside Intercepting Routes

Some versions of Next.js 14 had issues with dynamic route segments inside intercepting routes — parameters would come through as undefined, which was pretty frustrating to debug.

Fix: Use Next.js 15 or later. The team fixed several bugs related to parallel and intercepting routes in version 15, and things are much more stable now.

Performance Considerations

Streaming and Parallel Data Fetching

Each parallel route slot streams independently, and this is a significant performance win. In a traditional single-page component, data fetches tend to be sequential — one has to finish before the next one starts. With parallel routes, each slot fetches its own data concurrently, each wrapped in its own Suspense boundary.

// app/dashboard/@metrics/page.tsx
// This component fetches independently of other slots
export default async function MetricsPanel() {
  // This fetch runs in parallel with @activity and @revenue
  const metrics = await fetchMetrics();

  return (
    <div className="bg-white rounded-lg p-6 shadow">
      <h2 className="font-semibold text-lg mb-4">Key Metrics</h2>
      <div className="grid grid-cols-2 gap-4">
        <div>
          <p className="text-sm text-gray-500">Total Users</p>
          <p className="text-2xl font-bold">{metrics.totalUsers.toLocaleString()}</p>
        </div>
        <div>
          <p className="text-sm text-gray-500">Active Today</p>
          <p className="text-2xl font-bold">{metrics.activeToday.toLocaleString()}</p>
        </div>
      </div>
    </div>
  );
}

Bundle Size Impact

Good news: parallel and intercepting routes don't add any meaningful overhead to your client-side JavaScript bundle. The routing logic is handled by Next.js at the framework level. Your slot components follow the same Server Component / Client Component rules as any other page — keep them as Server Components by default and only add "use client" to the leaf components that actually need interactivity.

Prefetching Behavior

Next.js automatically prefetches visible Link targets. For intercepted routes, this means the modal content can be prefetched ahead of the user's click, making the modal appear pretty much instantly. This is actually one reason intercepting routes can feel faster than traditional JavaScript-driven modals — the content may already be loaded before the user even clicks.

Testing Parallel and Intercepting Routes

Testing these patterns requires you to think about both soft and hard navigation scenarios. Here's what a solid Playwright test suite looks like:

// e2e/photo-gallery.spec.ts (Playwright)
import { test, expect } from "@playwright/test";

test.describe("Photo Gallery Modal", () => {
  test("opens photo in modal on click", async ({ page }) => {
    await page.goto("/");
    await page.click('a[href="/photos/1"]');

    // Modal should be visible
    await expect(page.locator('[class*="fixed"]')).toBeVisible();
    // URL should update
    expect(page.url()).toContain("/photos/1");
    // Gallery should still be visible behind the modal
    await expect(page.locator("h1")).toHaveText("Photo Gallery");
  });

  test("shows full page on direct navigation", async ({ page }) => {
    await page.goto("/photos/1");

    // Should see full photo page, not modal
    await expect(page.locator('[class*="fixed"]')).not.toBeVisible();
    await expect(page.locator("h1")).not.toHaveText("Photo Gallery");
  });

  test("closes modal on back navigation", async ({ page }) => {
    await page.goto("/");
    await page.click('a[href="/photos/1"]');
    await page.goBack();

    // Modal should be gone
    await expect(page.locator('[class*="fixed"]')).not.toBeVisible();
    // Should be back on gallery
    expect(page.url()).not.toContain("/photos/");
  });
});

When to Use These Patterns (and When Not To)

Use Parallel Routes When:

  • You need independent loading and error states for different UI sections
  • You're building dashboard or multi-panel layouts with concurrent data fetching
  • You want conditional rendering based on server-side state (auth, roles, feature flags)
  • You need tab-based navigation where each tab has its own URL

Use Intercepting Routes When:

  • You need URL-driven modals with deep linking support
  • You want content previews that also work as standalone pages
  • You're building quick-view or peek patterns (product cards, user profiles)
  • Any overlay content that should be shareable via URL

Skip These Patterns When:

  • A simple client-side modal without URL requirements does the job
  • The "intercepted" content doesn't need a standalone page version
  • Your layout is simple enough that regular components work fine
  • You're on an older version of Next.js (pre-15) where bugs may affect reliability

Conclusion

Parallel routes and intercepting routes represent a real shift in how we build complex UIs with Next.js. Instead of managing modal state, scroll position, browser history, and URL synchronization through imperative JavaScript, the App Router lets you declare these behaviors through file-system conventions.

The patterns we've covered — multi-panel dashboards, Instagram-style photo modals, authentication modals, tab navigation, and conditional rendering — aren't just theoretical exercises. These are production-ready patterns that teams are shipping right now. The key is understanding the mental model: slots define where content can appear, intercepting routes define when content should be captured and displayed in-place, and default.tsx handles what happens when nothing matches.

My advice? Start with a simple modal pattern, get comfortable with the file-system conventions, and then gradually adopt the more advanced patterns as your app grows. The learning curve is front-loaded, but once these conventions click, building sophisticated routing experiences becomes surprisingly straightforward.

About the Author Editorial Team

Our team of expert writers and editors.