Files
agent-skills/skills/next-best-practices/parallel-routes.md
Jason Woltje f6bcc86881 feat: Add 5 curated skills for Mosaic Stack
New skills:
- next-best-practices: Next.js 15+ RSC, async patterns, self-hosting (vercel-labs)
- better-auth-best-practices: Official Better-Auth with Drizzle adapter (better-auth)
- verification-before-completion: Evidence-based completion claims (obra/superpowers)
- shadcn-ui: Component patterns with Tailwind v4 adaptation note (developer-kit)
- writing-skills: TDD methodology for skill authoring (obra/superpowers)

README reorganized by category with Mosaic Stack alignment section.
Total: 9 skills (4 existing + 5 new).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 16:17:40 -06:00

7.0 KiB

Parallel & Intercepting Routes

Parallel routes render multiple pages in the same layout. Intercepting routes show a different UI when navigating from within your app vs direct URL access. Together they enable modal patterns.

File Structure

app/
├── @modal/                    # Parallel route slot
│   ├── default.tsx            # Required! Returns null
│   ├── (.)photos/             # Intercepts /photos/*
│   │   └── [id]/
│   │       └── page.tsx       # Modal content
│   └── [...]catchall/         # Optional: catch unmatched
│       └── page.tsx
├── photos/
│   └── [id]/
│       └── page.tsx           # Full page (direct access)
├── layout.tsx                 # Renders both children and @modal
└── page.tsx

Step 1: Root Layout with Slot

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

Step 2: Default File (Critical!)

Every parallel route slot MUST have a default.tsx to prevent 404s on hard navigation.

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

Without this file, refreshing any page will 404 because Next.js can't determine what to render in the @modal slot.

Step 3: Intercepting Route (Modal)

The (.) prefix intercepts routes at the same level.

// app/@modal/(.)photos/[id]/page.tsx
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>
      <img src={photo.url} alt={photo.title} />
    </Modal>
  );
}

Step 4: Full Page (Direct Access)

// app/photos/[id]/page.tsx
export default async function PhotoPage({
  params
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params;
  const photo = await getPhoto(id);

  return (
    <div className="full-page">
      <img src={photo.url} alt={photo.title} />
      <h1>{photo.title}</h1>
    </div>
  );
}

Step 5: Modal Component with Correct Closing

Critical: Use router.back() to close modals, NOT router.push() or <Link>.

// 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);

  // Close on escape key
  useEffect(() => {
    function onKeyDown(e: KeyboardEvent) {
      if (e.key === 'Escape') {
        router.back(); // Correct
      }
    }
    document.addEventListener('keydown', onKeyDown);
    return () => document.removeEventListener('keydown', onKeyDown);
  }, [router]);

  // Close on overlay click
  const handleOverlayClick = useCallback((e: React.MouseEvent) => {
    if (e.target === overlayRef.current) {
      router.back(); // Correct
    }
  }, [router]);

  return (
    <div
      ref={overlayRef}
      onClick={handleOverlayClick}
      className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
    >
      <div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4">
        <button
          onClick={() => router.back()} // Correct!
          className="absolute top-4 right-4"
        >
          Close
        </button>
        {children}
      </div>
    </div>
  );
}

Using push or Link to "close" a modal:

  1. Adds a new history entry (back button shows modal again)
  2. Doesn't properly clear the intercepted route
  3. Can cause the modal to flash or persist unexpectedly

router.back() correctly:

  1. Removes the intercepted route from history
  2. Returns to the previous page
  3. Properly unmounts the modal

Route Matcher Reference

Matchers match route segments, not filesystem paths:

Matcher Matches Example
(.) Same level @modal/(.)photos intercepts /photos
(..) One level up @modal/(..)settings from /dashboard/@modal intercepts /settings
(..)(..) Two levels up Rarely used
(...) From root @modal/(...)photos intercepts /photos from anywhere

Common mistake: Thinking (..) means "parent folder" - it means "parent route segment".

Handling Hard Navigation

When users directly visit /photos/123 (bookmark, refresh, shared link):

  • The intercepting route is bypassed
  • The full photos/[id]/page.tsx renders
  • Modal doesn't appear (expected behavior)

If you want the modal to appear on direct access too, you need additional logic:

// app/photos/[id]/page.tsx
import { Modal } from '@/components/modal';

export default async function PhotoPage({ params }) {
  const { id } = await params;
  const photo = await getPhoto(id);

  // Option: Render as modal on direct access too
  return (
    <Modal>
      <img src={photo.url} alt={photo.title} />
    </Modal>
  );
}

Common Gotchas

1. Missing default.tsx → 404 on Refresh

Every @slot folder needs a default.tsx that returns null (or appropriate content).

2. Modal Persists After Navigation

You're using router.push() instead of router.back().

3. Nested Parallel Routes Need Defaults Too

If you have @modal inside a route group, each level needs its own default.tsx:

app/
├── (marketing)/
│   ├── @modal/
│   │   └── default.tsx     # Needed!
│   └── layout.tsx
└── layout.tsx

4. Intercepted Route Shows Wrong Content

Check your matcher:

  • (.)photos intercepts /photos from the same route level
  • If your @modal is in app/dashboard/@modal, use (.)photos to intercept /dashboard/photos, not /photos

5. TypeScript Errors with params

In Next.js 15+, params is a Promise:

// Correct
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
}
app/
├── @modal/
│   ├── default.tsx
│   └── (.)photos/
│       └── [id]/
│           └── page.tsx
├── photos/
│   ├── page.tsx           # Gallery grid
│   └── [id]/
│       └── page.tsx       # Full photo page
├── layout.tsx
└── page.tsx

Links in the gallery:

// app/photos/page.tsx
import Link from 'next/link';

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

  return (
    <div className="grid grid-cols-3 gap-4">
      {photos.map(photo => (
        <Link key={photo.id} href={`/photos/${photo.id}`}>
          <img src={photo.thumbnail} alt={photo.title} />
        </Link>
      ))}
    </div>
  );
}

Clicking a photo → Modal opens (intercepted) Direct URL → Full page renders Refresh while modal open → Full page renders