Антон Ларичев
Перехват маршрутов (Intercepting Routes) в Next.js
Что такое Intercepting Routes
Intercepting Routes (перехват маршрутов) — механизм Next.js App Router, позволяющий загрузить содержимое одного маршрута внутри текущего layout, не выполняя полный переход. При этом URL в адресной строке меняется, но пользователь визуально остаётся в контексте предыдущей страницы.
Классический пример — галерея изображений: клик по фотографии открывает её в модальном окне поверх ленты, а URL обновляется до /photos/42. При прямом переходе на /photos/42 или обновлении страницы пользователь видит полноценную страницу фотографии без модального окна.
Это решает давнюю проблему: либо URL не отражает состояние UI, либо пользователь полностью покидает текущую страницу.
Синтаксис именования папок
Пеrehват задаётся специальным префиксом в имени папки. Префикс определяет, относительно какого сегмента пути происходит перехват:
| Префикс | Перехватывает сегменты |
|---|---|
(.) | того же уровня |
(..) | на один уровень выше |
(..)(..) | на два уровня выше |
(...) | от корня app/ |
Префикс относится к файловой системе маршрутизатора, а не к URL-сегментам. Это важно: группы маршрутов (folder), route groups без сегмента URL, не учитываются при подсчёте уровней.
Пример структуры с (..)
app/
├── feed/
│ ├── page.tsx # /feed
│ └── (..)photos/
│ └── [id]/
│ └── page.tsx # перехватывает /photos/[id] из /feed
├── photos/
│ └── [id]/
│ └── page.tsx # /photos/[id] — обычный маршрут
Папка (..)photos находится внутри feed/, то есть на один уровень ниже app/. Префикс (..) поднимает её на уровень выше — до app/ — и перехватывает сегмент photos.
Практический пример: фотогалерея с модальным окном
Структура файлов
app/
├── @modal/
│ ├── (.)photos/
│ │ └── [id]/
│ │ └── page.tsx
│ └── default.tsx
├── photos/
│ └── [id]/
│ └── page.tsx
├── layout.tsx
└── page.tsx
Здесь задействованы Parallel Routes (@modal) совместно с Intercepting Routes. Рассмотрим каждый файл.
app/layout.tsx — корневой layout с параллельным слотом
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html lang="ru">
<body>
{children}
{modal}
</body>
</html>
);
}
Слот modal будет рендериться рядом с основным контентом.
app/page.tsx — лента с фотографиями
import Link from 'next/link';
const photos = [
{ id: '1', src: '/img/photo-1.jpg', alt: 'Горы' },
{ id: '2', src: '/img/photo-2.jpg', alt: 'Море' },
{ id: '3', src: '/img/photo-3.jpg', alt: 'Лес' },
];
export default function GalleryPage() {
return (
<main>
<h1>Галерея</h1>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8 }}>
{photos.map((photo) => (
<Link key={photo.id} href={`/photos/${photo.id}`}>
<img src={photo.src} alt={photo.alt} style={{ width: '100%' }} />
</Link>
))}
</div>
</main>
);
}
app/photos/[id]/page.tsx — полная страница фотографии
Эта страница показывается при прямом переходе или обновлении:
interface Props {
params: { id: string };
}
export default function PhotoPage({ params }: Props) {
return (
<div>
<h1>Фотография #{params.id}</h1>
<img
src={`/img/photo-${params.id}.jpg`}
alt={`Фото ${params.id}`}
style={{ maxWidth: '100%' }}
/>
</div>
);
}
app/@modal/(.)photos/[id]/page.tsx — перехваченный маршрут (модальное окно)
Папка (.)photos перехватывает сегмент photos на том же уровне относительно @modal:
'use client';
import { useRouter } from 'next/navigation';
interface Props {
params: { id: string };
}
export default function PhotoModal({ params }: Props) {
const router = useRouter();
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 100,
}}
onClick={() => router.back()}
>
<div
style={{ background: '#fff', padding: 24, borderRadius: 8 }}
onClick={(e) => e.stopPropagation()}
>
<img
src={`/img/photo-${params.id}.jpg`}
alt={`Фото ${params.id}`}
style={{ maxWidth: 600 }}
/>
<p>Фотография #{params.id}</p>
</div>
</div>
);
}
Клик по затемнённому фону вызывает router.back() — это закрывает модальное окно и возвращает пользователя в галерею.
app/@modal/default.tsx — заглушка для слота
Когда модальное окно не активно, слот должен рендерить null:
export default function DefaultModal() {
return null;
}
Без этого файла Next.js выдаст ошибку при навигации, когда параллельный слот не имеет активного маршрута.
Как это работает изнутри
Навигация через <Link>
Когда пользователь кликает по ссылке /photos/1 со страницы галереи:
- Next.js находит
app/@modal/(.)photos/[id]/page.tsxи определяет его как перехватчик. - В слот
@modalзагружается компонент модального окна. - В слот
childrenостаётся страница галереи. - URL меняется на
/photos/1.
Прямой переход или обновление страницы
Когда пользователь открывает /photos/1 напрямую или нажимает F5:
- Перехват не срабатывает — нет предыдущего контекста навигации.
- Next.js загружает
app/photos/[id]/page.tsxкак обычный маршрут. - Слот
@modalрендеритdefault.tsx(возвращаетnull).
Возврат назад (router.back())
- URL возвращается к
/. - Слот
@modalснова рендеритdefault.tsx. - Галерея остаётся в
childrenбез перезагрузки.
Уровни перехвата: разбор (..) и (...)
Перехват через два сегмента (..)(..)
app/
├── dashboard/
│ └── settings/
│ ├── page.tsx
│ └── (..)(..)photos/
│ └── [id]/
│ └── page.tsx # перехватывает /photos/[id]
├── photos/
│ └── [id]/
│ └── page.tsx
Папка (..)(..)photos находится на уровне dashboard/settings/. Каждый (..) поднимает на уровень: сначала до dashboard/, затем до app/. После подъёма идёт перехват сегмента photos.
Перехват от корня (...)
app/
├── (shop)/
│ └── cart/
│ └── (...)photos/
│ └── [id]/
│ └── page.tsx # перехватывает /photos/[id] от корня app/
├── photos/
│ └── [id]/
│ └── page.tsx
Три точки (...) означают перехват относительно корня app/ независимо от текущей вложенности. Route group (shop) не учитывается в уровнях — он не добавляет сегмент URL.
Типичные сценарии применения
Модальное окно для создания записи
Пользователь находится на /dashboard/tasks. Клик на кнопку «Создать задачу» открывает форму в модальном окне, URL становится /dashboard/tasks/new. При обновлении страницы открывается полноценная страница создания задачи.
app/
├── dashboard/
│ ├── @modal/
│ │ ├── (.)tasks/
│ │ │ └── new/
│ │ │ └── page.tsx # модальная форма
│ │ └── default.tsx
│ ├── tasks/
│ │ ├── page.tsx # список задач
│ │ └── new/
│ │ └── page.tsx # полная страница создания
│ └── layout.tsx
Предпросмотр товара в каталоге
Клик по карточке товара в каталоге /shop показывает быстрый просмотр с ценой и кнопкой покупки, не покидая каталог. URL обновляется до /shop/products/123.
Логин-форма поверх текущей страницы
Пользователь на любой странице кликает «Войти». Открывается модальное окно авторизации, URL становится /login. При обновлении страницы — полноценная страница входа.
Частые ошибки и как их избежать
Ошибка: неверный уровень перехвата
Если перехват не срабатывает и показывается обычная страница вместо модального окна, проверьте уровень. Нарисуйте дерево файловой системы и посчитайте, сколько уровней нужно подняться от папки с перехватчиком до папки с целевым сегментом.
# Неверно: папка (.)photos находится на два уровня выше photos/
app/
├── @modal/
│ └── nested/
│ └── (.)photos/ # (.) перехватывает только на том же уровне
# Верно:
app/
├── @modal/
│ └── nested/
│ └── (..)photos/ # (..) поднимается на уровень выше
Ошибка: отсутствует default.tsx
Без файла default.tsx в параллельном слоте Next.js не знает, что рендерить когда слот не активен. Это приводит к ошибке No default export found.
// app/@modal/default.tsx — обязателен
export default function Default() {
return null;
}
Ошибка: использование router.push вместо router.back
Для закрытия модального окна нужно именно router.back(), а не router.push('/'). Push создаёт новую запись в истории браузера, back — возвращает к предыдущему состоянию и корректно убирает модальное окно.
Ошибка: отсутствует stopPropagation на контенте модального окна
Если клик по контенту внутри модального окна закрывает его, добавьте e.stopPropagation() на обёртку контента, чтобы клик не всплывал до оверлея.
Сочетание с другими возможностями App Router
Загрузка данных через Server Components
Компонент перехваченного маршрута может быть Server Component и получать данные напрямую:
// app/@modal/(.)photos/[id]/page.tsx
async function getPhoto(id: string) {
const res = await fetch(`https://api.example.com/photos/${id}`, {
next: { revalidate: 60 },
});
return res.json();
}
export default async function PhotoModal({ params }: { params: { id: string } }) {
const photo = await getPhoto(params.id);
return (
<ModalWrapper>
<img src={photo.url} alt={photo.title} />
<h2>{photo.title}</h2>
</ModalWrapper>
);
}
Loading UI для модального окна
Добавьте loading.tsx рядом с перехватчиком для Suspense-границы:
// app/@modal/(.)photos/[id]/loading.tsx
export default function ModalLoading() {
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)' }}>
<p style={{ color: '#fff', textAlign: 'center', marginTop: '40vh' }}>
Загрузка...
</p>
</div>
);
}
Метаданные
Добавьте generateMetadata в перехваченный маршрут — браузер обновит заголовок вкладки, что улучшает доступность и SEO для прямых переходов:
export async function generateMetadata({ params }: Props) {
const photo = await getPhoto(params.id);
return { title: photo.title };
}
Отличия от обычных модальных окон через состояние
| Критерий | useState/context | Intercepting Routes |
|---|---|---|
| URL отражает состояние | Нет | Да |
| Работает при обновлении | Нет | Да |
| Можно поделиться ссылкой | Нет | Да |
| Кнопка «Назад» работает | Нет | Да |
| SEO для полной страницы | Нет | Да |
| Сложность настройки | Низкая | Средняя |
Intercepting Routes — правильный выбор, когда контент в модальном окне имеет собственный URL и должен быть доступен напрямую.
Итог
Intercepting Routes решают задачу, с которой сталкиваются все SPA-приложения: отобразить вложенный контент поверх текущей страницы с корректным URL. Механизм основан на соглашении об именовании папок (.), (..), (..)(..) и (...) и наиболее мощен в сочетании с Parallel Routes.
Основные моменты для запоминания:
- Префикс задаётся относительно файловой системы, не URL-сегментов.
- Route Groups не учитываются при подсчёте уровней.
- Всегда нужен
default.tsxв параллельном слоте. - Для закрытия модального окна используйте
router.back().
Подробнее о маршрутизации и других возможностях Next.js App Router — на курсе Next.js на PurpleSchool.