Карточка в каталоге обычно ведёт на detail page. Для прямого перехода нужен отдельный URL вроде /events/123. Для просмотра нескольких карточек подряд удобнее оставить список на экране и открыть detail поверх него. Если modal хранится только в useState, адрес страницы не меняется. Прямую ссылку на открытый объект получить нельзя. После reload остаётся список, а поисковый робот видит только исходную страницу.
App Router поддерживает другую схему. Один detail URL открывается как modal при client-side переходе из списка и как обычная страница при прямом запросе или reload. Для этого используются Parallel Routes и Intercepting Routes. Next.js описывает тот же паттерн для modal поверх gallery или другого списка. В учебном EventMap этот маршрут используется для событий.
Один URL и два входа
Пусть detail route выглядит так:
/en/events/london-music-night
Пользователь может попасть на него двумя способами.
Из списка:
/en/search/music ↓ click /en/events/london-music-night
Браузер меняет URL, но detail появляется поверх search page.
Прямой переход:
address bar / external link / reload ↓ /en/events/london-music-night
Рендерится полноценная detail page.
Intercepting Routes различают client-side и hard navigation. При переходе через Next.js router маршрут можно перехватить и показать в текущем layout. При прямом открытии или refresh interception не применяется, рендерится обычная страница по этому URL.
URL в обоих случаях относится к одному событию.
Обычная detail page
Сначала нужен самостоятельный route, который работает без списка и modal:
app/ [locale]/ events/ [id]/ page.tsx
В проекте detail page получает locale и id, загружает событие и возвращает 404, если запись не найдена:
export default async function EventDetailPage({ params, }: EventDetailPageProps) { const locale = await getLocaleFromParams(params); const { id } = await params; const event = await fetchEventById({ id, locale, }); if (!event) { notFound(); } return ( <section className="container demo-page"> <EventDetails event={event} locale={locale} mode="page" /> </section> ); }
Этот route не зависит от того, открывал ли пользователь перед ним search page.
Для controlled IDs проект также возвращает static params:
export const dynamicParams = true; export function generateStaticParams() { return getStaticEventParams(); }
Известные IDs могут быть построены заранее, остальные остаются dynamic routes.
Metadata detail page
Detail route владеет metadata события.
export async function generateMetadata({ params, }: EventDetailPageProps): Promise<Metadata> { const locale = await getLocaleFromParams(params); const { id } = await params; const event = await fetchEventById({ id, locale, }); const seoPaths = buildEventSeoPaths({ id, locale, }); if (!event) { return getEventNotFoundMetadata(locale); } return toNextMetadata({ title: event.title, description: `${event.description} ${event.venue}, ${event.city}.`, imageUrl: event.imageUrl, ...seoPaths, }); }
buildEventSeoPaths строит canonical из текущей locale и ID:
export function buildEventSeoPaths({ id, locale, }: { id: string; locale: Locale; }): SeoPaths { const getPath = (targetLocale: Locale) => routes.eventDetail( targetLocale, id, ); return { canonicalPath: getPath(locale), languageAlternates: buildLocalizedAlternates(getPath), }; }
Для английской версии canonical будет указывать на /en/events/..., для русской на /ru/events/.... Language alternates связывают обе версии.
Modal не заменяет этот route. Поисковый робот или пользователь с внешней ссылкой получает самостоятельную detail page.
Parallel Route для modal
Modal нужно отрисовать рядом с основной страницей. Для этого App Router использует named slot с префиксом @.
Структура проекта:
app/ [locale]/ layout.tsx events/ [id]/ page.tsx @modal/ default.tsx page.tsx [...catchAll]/ page.tsx (.)events/ [id]/ page.tsx
@modal не добавляет сегмент в URL. Named slots передаются родительскому layout как props и рендерятся параллельно с children.
Layout получает два React tree:
type LocaleLayoutProps = { children: React.ReactNode; modal: React.ReactNode; params: LocaleParams; }; export default async function LocaleLayout({ children, modal, params, }: LocaleLayoutProps) { const locale = await getLocaleFromParams(params); const messages = ( await import( `../../../messages/${locale}.json` ) ).default; return ( <html lang={locale}> <body> <NextIntlClientProvider locale={locale} messages={messages} > <PageShell locale={locale}> {children} </PageShell> {modal} </NextIntlClientProvider> </body> </html> ); }
children содержит search page или другую обычную страницу. modal может вернуть overlay либо null.
Intercepted route
Файл modal расположен здесь:
@modal/ (.)events/ [id]/ page.tsx
(.) означает interception сегмента на том же route level.
@modal при подсчёте уровней не считается URL segment. Поэтому (.)events перехватывает обычный /events/[id], хотя в файловой структуре папки находятся на разных уровнях. Это отдельно оговорено в документации Next.js.
Intercepted page снова получает обычные route params:
type EventPreviewModalPageProps = { params: Promise<{ id: string; locale: string; }>; searchParams: Promise< Record< string, string | string[] | undefined > >; };
Данные загружаются тем же server function:
const locale = await getLocaleFromParams(params); const { id } = await params; const event = await fetchEventById({ id, locale, });
Modal route не требует отдельного client-side fetch.
Modal поверх текущей страницы
Intercepted page возвращает fixed overlay:
return ( <div className="modal-backdrop"> <aside className="modal-panel event-modal-preview" aria-label="Event preview" > <EventDetails event={event} locale={locale} mode="modal" /> </aside> </div> );
CSS размещает backdrop поверх текущего content:
.modal-backdrop { position: fixed; z-index: 40; inset: 0; display: grid; align-items: start; justify-items: center; background: rgba(10, 10, 15, 0.74); padding: 72px 20px 28px; }
Основная страница остаётся в children, modal приходит через соседний slot.
Ссылка из списка
Карточке не нужен специальный modal URL. Она строит обычный detail address:
const detailHref = routes.eventDetail( locale, event.id, );
Дальше используется обычный Link:
<Link className="event-card-link" href={href} scroll={false} > {cardContent} </Link>
Link выполняет client-side navigation. При таком переходе (.)events/[id] перехватывает route, поэтому detail появляется в @modal.scroll={false} отключает автоматическую попытку Next.js прокрутить новую page к началу. Сам href остаётся detail URL.
Фильтры списка в returnTo
Search page может быть открыта не только как /search.
Например:
/en/search/music /en/search/music/london /en/search/music?q=jazz&page=2
Перед открытием карточки проект собирает текущий search URL:
const returnTo = buildSearchHref({ filters, locale, });
И передаёт его в карточку:
<EventCard event={event} key={event.id} locale={locale} returnTo={returnTo} />
Карточка добавляет значение к detail URL:
const href = returnTo ? `${detailHref}?${new URLSearchParams({ returnTo, }).toString()}` : detailHref;
Адрес может выглядеть так:
/en/events/123 ?returnTo=%2Fen%2Fsearch%2Fmusic%3Fpage%3D2
returnTo хранит состояние маршрута списка, а не состояние React-компонента.
Проверка returnTo
Query parameter нельзя использовать для redirect без проверки.
В проекте modal route передаёт searchParams в helper:
const returnTo = getSafeReturnTo({ fallback: routes.search(locale), locale, searchParams: query, });
Helper принимает только путь текущей locale:
if ( returnTo !== `/${locale}` && !returnTo.startsWith(`/${locale}/`) ) { return fallback; }
Потом URL дополнительно проверяется через временный local origin:
const parsed = new URL( returnTo, "https://eventmap.local", ); if ( parsed.origin !== "https://eventmap.local" ) { return fallback; }
На выходе остаются pathname, search и hash:
return ( `${parsed.pathname}` + `${parsed.search}` + `${parsed.hash}` );
Modal знает точный адрес, куда возвращаться после закрытия.
Закрытие modal
Next.js допускает закрытие intercepted modal через router.back() или обычный Link.
В проекте используется Link на сохранённый returnTo:
<Link className="button button--secondary" href={returnTo} scroll={false} > Close </Link>
Фильтры и номер search page уже находятся в URL. Отдельный объект с фильтрами для modal не создаётся.
default.tsx
Parallel Route должен иметь состояние для случая, когда modal не активен.
export default function DefaultModalSlot() { return null; }
На hard navigation Next.js не может восстановить active state unmatched slot. В таком случае он использует default.tsx; без него unmatched slot может привести к 404. Для обычной detail page @modal возвращает null.
page.tsx и catch-all
У slot есть ещё два пустых route:
// @modal/page.tsx export default function EmptyModalSlot() { return null; }
И:
// @modal/[...catchAll]/page.tsx export default function EmptyModalSlot() { return null; }
Parallel Routes сохраняют active subpage slot при soft navigation. Если пользователь уходит на route, который больше не должен показывать modal, slot нужно сопоставить с route, возвращающим null. Документация Next.js использует catch-all по той же причине.
Full page из открытого modal
В modal есть отдельное действие:
<a className="button" href={ routes.eventDetail( locale, event.id, ) } > Open full page </a>
Здесь используется обычный <a>, а не next/link. Браузер выполняет full-page navigation. Interception больше не применяется, и тот же /events/[id] открывается через обычный detail route.
Получается два перехода на один pathname:
<Link href="/events/123"> client navigation → intercepted modal
<a href="/events/123"> hard navigation → full detail page
Прямой ввод URL и reload относятся ко второй ветке.
Общий EventDetails
Modal и full page получают один event, но выводят разные объёмы информации.
Компонент принимает mode:
type EventDetailsProps = { actions?: ReactNode; event: EventCardData; labels?: EventDetailsLabels; locale: Locale; mode: "modal" | "page"; };
Full page вызывает:
<EventDetails event={event} locale={locale} mode="page" />
Intercepted route вызывает:
<EventDetails event={event} locale={locale} mode="modal" />
В page mode компонент выводит hero image, h1, полный description, дату, venue, category, price и основные actions. В modal mode остаются preview image, h2, несколько полей и сокращённый description.
Data model у двух режимов одна.
Fetch для двух маршрутов
Full page и intercepted route вызывают одну функцию:
fetchEventById({ id, locale, });
Modal не получает данные из уже отрисованной карточки и не хранит выбранное событие в глобальном client state. При прямом запросе detail page получает объект тем же способом. Это сохраняет одинаковую обработку controlled и live events в обоих режимах.
URL modal и canonical
При переходе из search URL может содержать служебный returnTo:
/en/events/123?returnTo=...
SEO helper его не использует:
const seoPaths = buildEventSeoPaths({ id, locale, });
Canonical строится только из locale и ID:
/en/events/123
Search filters не становятся частью canonical detail page.
Full route также строит language alternates для того же event ID:
/en/events/123 /ru/events/123
Modal navigation и возврат к списку не меняют адрес, который detail page публикует как canonical.
Ошибка загрузки в modal
Detail page при отсутствии события вызывает:
notFound();
Modal использует другое представление ошибки. Если preview не загрузился, slot остаётся overlay и показывает сообщение вместо карточки. Обычная full page при прямом запросе сохраняет стандартную семантику 404.
Маршруты используют один data source, но обработка отсутствующей записи соответствует режиму отображения.
Search page остаётся отдельным route
Modal не превращает search page в client SPA. Search route продолжает работать как Server Component:
/[locale]/search/[[...segments]]
Фильтры разбираются из params и searchParams, результаты загружаются на сервере. Карточки получают detail URL и returnTo. Modal появляется через соседний Parallel Route slot.
Нет отдельного client store, который должен одновременно хранить:
selectedEvent isModalOpen previousSearchFilters previousPage
Эти части уже представлены route state.
Поведение по шагам
Пользователь открывает:
/en/search/music?page=2
Search page формирует:
returnTo=/en/search/music?page=2
Карточка ведёт на:
/en/events/123?returnTo=...
Client-side Link активирует intercepted route:
@modal/(.)events/[id]
Layout одновременно показывает search page и @modal. Modal загружает событие через fetchEventById. Close ведёт на сохранённый returnTo.
Если пользователь копирует URL события, открывает его в новой вкладке или делает reload, Next.js выбирает обычный route:
events/[id]/page.tsx
Он возвращает full detail page, metadata, canonical, language alternates и 404 для отсутствующего ID.
Структура маршрутов
Полная часть дерева для этого паттерна выглядит так:
app/ └── [locale]/ ├── layout.tsx │ ├── search/ │ └── [[...segments]]/ │ └── page.tsx │ ├── events/ │ └── [id]/ │ └── page.tsx │ └── @modal/ ├── default.tsx ├── page.tsx │ ├── [...catchAll]/ │ └── page.tsx │ └── (.)events/ └── [id]/ └── page.tsx
Обычный events/[id] отвечает за прямой detail route и SEO metadata. (.)events/[id] отвечает за preview при soft navigation. @modal размещает preview рядом с текущей page. default.tsx и catch-all возвращают пустое состояние slot. Общий EventDetails рендерит данные в двух режимах.
AnnSerba
Есть два места, на которых эта схема обычно и ломается, и в статье их нет. Первое: notFound() из intercepted-слота. Если событие удалили, а карточку открыли кликом из списка, в ряде версий Next это поднимается до not-found границы всего маршрута — список вместе с модалкой исчезает, хотя прямой заход на тот же URL отдаёт честную 404-страницу. Обычно это лечат своим состоянием «недоступно» внутри модалки, а notFound() оставляют только отдельной странице. Второе: блокировка фона. Если ради iOS вешать position: fixed на body, после закрытия список прыгает к началу, потому что scrollY надо сохранять руками; scroll={false} не выручает — он про навигацию, а не про позицию. Как у вас сделаны эти два места?
lemon_m Автор
Да, эти два случая у меня разведены.
В intercepted route
notFound()не вызывается. ЕслиfetchEventById()не вернул событие,@modal/(.)events/[id]остаётся в modal slot и рендеритEvent preview unavailableс возможностью закрыть модалку.notFound()вызывается только в обычномevents/[id]/page.tsx, поэтому прямой заход на отсутствующий event получает 404.С блокировкой фона здесь другой вариант.
position: fixedстоит на.modal-backdrop, самbodyне переводится вfixedиscrollYвручную не сохраняется. Поэтому после закрытия восстанавливать позицию из-за body lock не требуется.scroll={false}используется только для навигации и само по себе проблему сbody { position: fixed }действительно не решало бы.Если добавлять отдельный iOS scroll lock через фиксацию
body, тогда да, там уже нужно сохранять текущийscrollYперед открытием и восстанавливать его после закрытия.