18. Parallel и Intercepting Routes
🪟 Parallel Routes и Intercepting Routes: продвинутые паттерны
Заголовок раздела «🪟 Parallel Routes и Intercepting Routes: продвинутые паттерны»Это одни из самых мощных (и поначалу запутанных!) фич App Router. Parallel Routes позволяют рендерить несколько страниц одновременно в одном layout. Intercepting Routes — “перехватывать” навигацию и показывать контент в модальном окне без ухода со страницы. Вместе они создают магию, которую ты видишь в Instagram, Twitter и Pinterest! 🎯
🔀 Parallel Routes: слоты (@slot)
Заголовок раздела «🔀 Parallel Routes: слоты (@slot)»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> );}💡 default.tsx: резервный контент
Заголовок раздела «💡 default.tsx: резервный контент»Когда один слот 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.tsxexport default function TeamDefault() { // Пустой компонент или что-то нейтральное return null;}
// Или показываем что-то полезноеexport default function AnalyticsDefault() { return ( <div className="p-4 text-gray-500"> Выберите раздел для просмотра аналитики </div> );}🎭 Независимые Loading States
Заголовок раздела «🎭 Независимые Loading States»Главное преимущество 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: перехват навигации
Заголовок раздела «✂️ Intercepting Routes: перехват навигации»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 ← показывает фото В МОДАЛКЕ🖼️ Photo Gallery Modal: полный пример
Заголовок раздела «🖼️ Photo Gallery Modal: полный пример»export default function RootLayout({ children, modal,}: { children: React.ReactNode; modal: React.ReactNode;}) { return ( <html> <body> {children} {modal} {/* Модальное окно рендерится поверх */} </body> </html> );}
// app/@modal/default.tsxexport 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> );}🔮 Intercepting Route Modal
Заголовок раздела «🔮 Intercepting Route Modal»// 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> </> );}🔄 Мягкая vs жёсткая навигация
Заголовок раздела «🔄 Мягкая vs жёсткая навигация»Мягкая навигация (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 ← модальное окно задачи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> );}🛠️ Parallel Routes для Dashboard
Заголовок раздела «🛠️ Parallel Routes для Dashboard»app/└── dashboard/ ├── layout.tsx ← грид из 3 панелей ├── page.tsx ├── @users/ │ ├── page.tsx ← /dashboard │ └── loading.tsx ├── @revenue/ │ ├── page.tsx │ └── loading.tsx └── @activity/ ├── page.tsx └── loading.tsxexport 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> );}🎨 Условный рендер Parallel Routes
Заголовок раздела «🎨 Условный рендер Parallel Routes»// Можно показывать разные слоты в зависимости от состояния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> );}