Перейти к содержимому

18. Parallel и Intercepting Routes

🪟 Parallel Routes и Intercepting Routes: продвинутые паттерны

Заголовок раздела «🪟 Parallel Routes и Intercepting Routes: продвинутые паттерны»

Это одни из самых мощных (и поначалу запутанных!) фич App Router. Parallel Routes позволяют рендерить несколько страниц одновременно в одном layout. Intercepting Routes — “перехватывать” навигацию и показывать контент в модальном окне без ухода со страницы. Вместе они создают магию, которую ты видишь в Instagram, Twitter и Pinterest! 🎯


Parallel Routes определяются папками с символом @ — это называется именованные слоты:

app/
├── layout.tsx ← получает все слоты как props
├── page.tsx ← дефолтная страница
├── @team/ ← слот "team"
│ ├── page.tsx
│ └── loading.tsx
├── @analytics/ ← слот "analytics"
│ ├── page.tsx
│ └── error.tsx
└── @notifications/ ← слот "notifications"
└── page.tsx
// app/layout.tsx — получает слоты как children и именованные пропсы
export default function RootLayout({
children,
team,
analytics,
notifications,
}: {
children: React.ReactNode;
team: React.ReactNode;
analytics: React.ReactNode;
notifications: React.ReactNode;
}) {
return (
<html>
<body>
<div className="grid grid-cols-3 gap-4">
{/* Каждый слот рендерится независимо! */}
<div className="col-span-2">{children}</div>
<div>
{team}
{analytics}
{notifications}
</div>
</div>
</body>
</html>
);
}

Когда один слот navigates, но другие нет — нужен default.tsx как запасной вариант:

app/
├── layout.tsx
├── page.tsx
├── @team/
│ ├── page.tsx ← /
│ ├── settings/
│ │ └── page.tsx ← /settings
│ └── default.tsx ← показывается когда нет совпадения
└── @analytics/
├── page.tsx
└── default.tsx ← ОБЯЗАТЕЛЬНО если есть sub-routes в других слотах!
// app/@team/default.tsx
export default function TeamDefault() {
// Пустой компонент или что-то нейтральное
return null;
}
// Или показываем что-то полезное
export default function AnalyticsDefault() {
return (
<div className="p-4 text-gray-500">
Выберите раздел для просмотра аналитики
</div>
);
}

Главное преимущество Parallel Routes — каждый слот имеет независимый loading state:

// app/@team/loading.tsx — показывается пока загружается команда
export default function TeamLoading() {
return (
<div className="animate-pulse space-y-3 p-4">
<div className="h-8 bg-gray-200 rounded w-3/4"></div>
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
</div>
);
}
// app/@analytics/loading.tsx — независимо от team!
export default function AnalyticsLoading() {
return (
<div className="animate-pulse p-4">
<div className="h-32 bg-gray-200 rounded"></div>
</div>
);
}
// app/@team/page.tsx — медленный запрос не блокирует @analytics!
export default async function TeamPage() {
await new Promise(r => setTimeout(r, 2000)); // медленно!
const team = await getTeam();
return <TeamList team={team} />;
}

Intercepting Routes позволяют показывать маршрут в контексте текущей страницы (модальное окно), а при прямом открытии URL — показывать полную страницу.

Синтаксис папок для перехвата:

(.) — перехватывает маршрут на том же уровне
(..) — перехватывает на один уровень выше
(..)(..) — на два уровня выше
(...) — перехватывает из корня app/
app/
├── layout.tsx
├── page.tsx
├── photos/
│ ├── page.tsx ← /photos — галерея
│ └── [id]/
│ └── page.tsx ← /photos/42 — полная страница фото
└── @modal/
├── default.tsx ← null (по умолчанию нет модалки)
└── (.)photos/ ← перехватывает /photos/[id]
└── [id]/
└── page.tsx ← показывает фото В МОДАЛКЕ

app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html>
<body>
{children}
{modal} {/* Модальное окно рендерится поверх */}
</body>
</html>
);
}
// app/@modal/default.tsx
export default function ModalDefault() {
return null; // нет модалки по умолчанию
}
// app/photos/page.tsx — галерея
import Link from 'next/link';
import { getPhotos } from '@/lib/photos';
export default async function PhotosPage() {
const photos = await getPhotos();
return (
<div className="grid grid-cols-3 gap-4 p-4">
{photos.map(photo => (
<Link
key={photo.id}
href={`/photos/${photo.id}`}
// При клике: перехватывается → показывается модалка
// При прямом URL: показывается полная страница
>
<img
src={photo.thumbnail}
alt={photo.alt}
className="w-full aspect-square object-cover rounded-lg hover:opacity-90 transition"
/>
</Link>
))}
</div>
);
}
// app/photos/[id]/page.tsx — ПОЛНАЯ страница фото (при прямом URL)
export default async function PhotoPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const photo = await getPhoto(id);
return (
<div className="max-w-4xl mx-auto p-8">
<img src={photo.url} alt={photo.alt} className="w-full rounded-xl" />
<h1 className="text-2xl font-bold mt-4">{photo.title}</h1>
<p className="text-gray-600 mt-2">{photo.description}</p>
</div>
);
}

// app/@modal/(.)photos/[id]/page.tsx — МОДАЛЬНОЕ ОКНО
import { PhotoModal } from '@/components/PhotoModal';
import { getPhoto } from '@/lib/photos';
export default async function PhotoModalPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const photo = await getPhoto(id);
return <PhotoModal photo={photo} />;
}
// components/PhotoModal.tsx
'use client';
import { useRouter } from 'next/navigation';
export function PhotoModal({ photo }: { photo: Photo }) {
const router = useRouter();
return (
<>
{/* Затемнение фона — клик закрывает модалку */}
<div
className="fixed inset-0 bg-black/70 z-40"
onClick={() => router.back()}
/>
{/* Модальное окно */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl max-w-4xl w-full max-h-[90vh] overflow-auto">
<div className="relative">
<button
onClick={() => router.back()}
className="absolute top-4 right-4 w-8 h-8 bg-black/50 rounded-full flex items-center justify-center text-white"
>
</button>
<img
src={photo.url}
alt={photo.alt}
className="w-full rounded-t-2xl"
/>
</div>
<div className="p-6">
<h2 className="text-2xl font-bold">{photo.title}</h2>
<p className="text-gray-600 mt-2">{photo.description}</p>
</div>
</div>
</div>
</>
);
}

Мягкая навигация (soft navigation):
• Клик по <Link href="/photos/42">
• Intercepting Route АКТИВЕН
• Показывается МОДАЛКА поверх галереи
• URL меняется на /photos/42
• Кнопка "Назад" возвращает на галерею
Жёсткая навигация (hard navigation):
• Прямой ввод URL /photos/42 в браузере
• Обновление страницы (F5)
• Intercepting Route НЕ активен
• Показывается ПОЛНАЯ страница фото

app/
├── dashboard/
│ ├── layout.tsx
│ ├── page.tsx ← список задач
│ └── tasks/
│ └── [id]/
│ └── page.tsx ← полная страница задачи
└── @modal/
└── (.)dashboard/tasks/ ← перехватывает /dashboard/tasks/[id]
└── [id]/
└── page.tsx ← модальное окно задачи
app/dashboard/page.tsx
import Link from 'next/link';
export default async function DashboardPage() {
const tasks = await getTasks();
return (
<div>
<h1>Задачи</h1>
<ul>
{tasks.map(task => (
<li key={task.id}>
{/* Клик открывает модалку */}
<Link href={`/dashboard/tasks/${task.id}`}>
{task.title}
</Link>
</li>
))}
</ul>
</div>
);
}

app/
└── dashboard/
├── layout.tsx ← грид из 3 панелей
├── page.tsx
├── @users/
│ ├── page.tsx ← /dashboard
│ └── loading.tsx
├── @revenue/
│ ├── page.tsx
│ └── loading.tsx
└── @activity/
├── page.tsx
└── loading.tsx
app/dashboard/layout.tsx
export default function DashboardLayout({
children,
users,
revenue,
activity,
}: {
children: React.ReactNode;
users: React.ReactNode;
revenue: React.ReactNode;
activity: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-gray-100 p-6">
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
<div className="grid grid-cols-3 gap-6 mb-6">
{/* Каждый виджет загружается независимо */}
<div className="bg-white rounded-xl p-4 shadow">{users}</div>
<div className="bg-white rounded-xl p-4 shadow">{revenue}</div>
<div className="bg-white rounded-xl p-4 shadow">{activity}</div>
</div>
<div className="bg-white rounded-xl p-4 shadow">{children}</div>
</div>
);
}

// Можно показывать разные слоты в зависимости от состояния
export default function Layout({
children,
authModal,
dashboard,
}: {
children: React.ReactNode;
authModal: React.ReactNode;
dashboard: React.ReactNode;
}) {
const session = await auth();
return (
<html>
<body>
{/* Авторизованным — dashboard, остальным — authModal */}
{session ? dashboard : authModal}
{children}
</body>
</html>
);
}