Parallel Routes и Intercepting Routes в Next.js 15: Модални прозорци с deep linking

Ръководство за Parallel Routes и Intercepting Routes в Next.js 15. Модални прозорци с deep linking, default.tsx, чести грешки и продукционен чеклист.

Parallel Routes Next.js 15: Модали 2026

Обновено: 16 юли 2026 г.

Parallel Routes и Intercepting Routes в Next.js 15 са file-system конвенции на App Router, които позволяват едновременно рендиране на няколко страници в едно и също layout и „прихващане" на навигация за отваряне на модални прозорци с deep linking. Parallel Routes дефинират именувани слотове с префикс @ (например @modal), а Intercepting Routes използват (.), (..) или (...) префикси, за да заменят целева страница с локален вариант при soft navigation. Комбинацията решава класическия проблем с модалите, споделяеми през URL.

  • Parallel Routes се дефинират като папки с префикс @ и се приемат от родителския layout.tsx като допълнителни пропсове до children.
  • Всеки слот изисква default.tsx, за да не се чупи при hard reload или директно посещение на URL, който не съответства на слота.
  • Intercepting Routes използват (.) за същото ниво, (..) за едно ниво нагоре и (...) за корена на app (важи спрямо route segments, не спрямо файловата система).
  • Модалният pattern комбинира @modal слот с (.)items/[id] интерцептор, за да отвори overlay при клик, но да покаже пълната страница при директен URL достъп или refresh.
  • Софт навигацията запазва Server Component състояние в неактивните слотове, което е ключово за производителност и UX при табове и dashboards.
  • За затваряне на модал използвайте router.back(), а не програмна навигация към предишния URL, така браузър историята остава коректна.

Какво представляват Parallel Routes в Next.js 15

Parallel Routes са конвенция на App Router, при която една или повече именувани папки с префикс @ се третират като независими подрутове в рамките на общ layout.tsx. Аз работя с Next.js откакто беше pages-only и, честно казано, това е една от малкото възможности, които в pages router просто нямаше начин да реализирам чисто без hacks. В App Router слотовете се предават на layout-а като обикновени React пропсове до стандартния children, и всеки от тях има свой пълен render tree с loading, error и not-found състояния.

Използвайте Parallel Routes, когато една страница трябва да покаже няколко независими секции, които могат да се зареждат, ревалидират и грешат отделно. Типични примери? Dashboard с феед и странична колона, split view за имейл клиент, или таб панели, при които всеки таб е отделен route segment със собствен URL.

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  team,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  team: React.ReactNode;
}) {
  return (
    <section className="grid grid-cols-3 gap-6">
      <div className="col-span-2">{children}</div>
      <aside>{analytics}</aside>
      <aside>{team}</aside>
    </section>
  );
}

Структурата, която захранва това layout, е app/dashboard/@analytics/page.tsx и app/dashboard/@team/page.tsx. Забележете, че URL пътят не се променя от @ префикса. Той служи само като маркер за bundler-а. Потребителят все още посещава /dashboard, но получава три независимо рендирани дървета в един и същи layout, и всяко от тях може да има собствен loading.tsx, който показва skeleton, докато чака данни.

Как работят Intercepting Routes и трите нива на префикса

Intercepting Routes позволяват на един route segment да „прихване" навигацията към друг, но само при soft navigation вътре в приложението. При директно посещение на URL или hard reload оригиналната страница се рендира нормално. Префиксите изразяват относителна позиция в йерархията на route segments (не спрямо файловата система): (.) означава същото ниво, (..) е едно ниво нагоре, (..)(..) е две нива нагоре, а (...) сочи корена на app.

Класическа грешка, която правя постоянно, е да броя нивата от папки, а не от route segments. Route groups с (name) и Parallel Routes с @name НЕ се броят като нива. Ако структурата е app/(marketing)/feed/@modal/(.)photo, точката се отнася за feed, а не за @modal. Официалната Next.js документация за Intercepting Routes има диаграма, която препоръчвам да отворите настрани, докато конфигурирате структурата за първи път.

// Йерархията, интерпретирана от Next.js:
app/
├── feed/
│   ├── page.tsx              // /feed
│   └── @modal/
│       └── (.)photo/
│           └── [id]/
│               └── page.tsx  // прихваща /photo/[id]
└── photo/
    └── [id]/
        └── page.tsx          // /photo/[id]

Когато потребителят е на /feed и кликне <Link href="/photo/42">, Next.js вижда, че в активния layout има @modal слот с интерцептор за същото ниво, и рендира @modal/(.)photo/[id]/page.tsx, вместо да навигира към пълната photo страница. URL-ът се обновява до /photo/42, но layout остава същият. Ако потребителят refresh-не или сподели URL-а, интерцепторът не работи и се показва пълната photo/[id]/page.tsx.

Това е причината, заради която повечето хора първо се сблъскват с Parallel и Intercepting Routes. Искат модален прозорец, който се отваря при клик, но същият URL да показва пълноекранна страница при директно посещение. Ето минималната работеща структура, която използвам в реални проекти.

// app/layout.tsx
export default function RootLayout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <html lang="bg">
      <body>
        {children}
        {modal}
      </body>
    </html>
  );
}
// app/@modal/default.tsx  (задължителен fallback)
export default function Default() {
  return null;
}

// app/@modal/(.)items/[id]/page.tsx
import { Modal } from '@/components/modal';
import { getItem } from '@/lib/data';

export default async function ItemModal({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const item = await getItem(id);

  return (
    <Modal>
      <h2>{item.title}</h2>
      <p>{item.description}</p>
    </Modal>
  );
}
// components/modal.tsx  (client component за overlay)
'use client';

import { useRouter } from 'next/navigation';
import { useEffect, useRef } from 'react';

export function Modal({ children }: { children: React.ReactNode }) {
  const router = useRouter();
  const dialogRef = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    dialogRef.current?.showModal();
  }, []);

  return (
    <dialog
      ref={dialogRef}
      onClose={() => router.back()}
      className="rounded-lg p-6 backdrop:bg-black/50"
    >
      {children}
      <button onClick={() => router.back()}>Затвори</button>
    </dialog>
  );
}

Ключовият детайл е router.back() в handler-а за затваряне. Ако напиша router.push('/items'), се появява дубликат в history stack и Back бутонът на браузъра се държи странно. С router.back() моделът работи точно както потребителят очаква: modal се затваря при Escape, при клик върху backdrop и при browser back. Хванах този бъг чак в стейджинг миналата година, така че сега винаги го проверявам първо. За по-подробна обработка на форми в модалите, вижте моето ръководство за Server Actions и оптимистични актуализации.

Ролята на default.tsx и защо липсата му чупи приложението

Всеки parallel slot изисква default.tsx файл на всяко ниво, където се очаква слотът да съществува. Причината е, че Next.js не може да предположи какво да рендира в слот, за който няма съответстваща route за текущия URL. При soft navigation React запазва предишното съдържание на неактивния слот, но при hard reload или директно посещение няма контекст за възстановяване. Точно тогава се използва default.tsx.

Практически, това означава, че за модалния pattern @modal/default.tsx просто връща null, защото при повечето URL-и не трябва да има отворен модал. Но за dashboard с постоянни секции default.tsx обикновено е копие на page.tsx или показва празно/скелетно състояние.

// app/dashboard/@analytics/default.tsx
export default function AnalyticsDefault() {
  return (
    <div className="rounded border border-dashed p-4 text-sm text-gray-500">
      Няма избран период за анализ
    </div>
  );
}

Пропускането на default.tsx е грешка №1, която виждам в чужд код и в собствените си PR-и. Симптомът е 404 – This page could not be found при директно посещение на URL, който би трябвало да работи. Проверката е тривиална: за всеки слот с префикс @, уверете се, че има default.tsx в същата папка и във всяка вложена сегментна папка, където слотът може да бъде активен.

Каква е разликата между Parallel Routes и Route Groups

Route Groups използват кръгли скоби (name) и служат единствено за организация на файлова структура. Те не създават нови URL сегменти и не рендират нищо самостоятелно. Parallel Routes използват @name и създават именуван слот, който се предава като React prop на layout-а. Дори имената да изглеждат подобно, поведението е коренно различно.

ХарактеристикаParallel Routes (@)Route Groups (())
Създава ли URL сегментНеНе
Предава ли се като propДа, на родителския layoutНе
Изисква ли default.tsxДа, задължителноНе
Независими loading/errorДа, по един на слотНе, споделя с parent
Използва се заМодали, dashboards, tabs, split viewsГрупиране на layout-и без ефект върху URL
Може ли да се комбинира с Intercepting RoutesДа, за модални patternsНе директно

В практиката често ги комбинирам. Например, app/(shop)/@modal/(.)products/[id] използва (shop) route group, за да сподели shop-специфично layout, @modal parallel slot за overlay-a, и (.)products/[id] intercepting route за самия прихванат сегмент. И трите префиксни синтаксиса имат ясни, отделни отговорности.

Защо моите Parallel Routes не работят и как да ги оправя

Топ-5 симптоми, които виждам при code reviews и в GitHub issues на официалната Next.js discussions страница:

1. Слотът се появява като undefined в layout

Причина: сложили сте @slot папка в page.tsx вместо в layout.tsx. Слотовете се приемат само от layout компоненти. Преместете съответния layout на нивото, където искате да имате достъп до слота.

2. 404 при директно посещение на URL

Причина: липсва default.tsx в слота. Добавете @slot/default.tsx, който връща null или подходящо fallback съдържание. Проверете всички вложени нива, не само корена на слота.

3. Интерцепторът работи в dev, но не и в production

Причина: обикновено е проблем с cache-ването или несъответствие на префикса. Ако използвате (..), уверете се, че пътят се брои по route segments. Забравянето, че route groups и parallel slots не се броят, е класическа причина за това. Изчистете .next и рестартирайте.

4. Модалът не се затваря при browser Back

Причина: използвате router.push или state-based hiding вместо router.back(). Overlay-ът трябва да реагира на промяна в URL, а не на локален state. Така се синхронизира с browser history.

5. Двойно рендиране или flashes на съдържание

Причина: имате едновременно вложен loading.tsx и Suspense boundary в слота, което произвежда двойни fallbacks. Оставете само един. Аз предпочитам loading.tsx за page-level и ръчен <Suspense> само където имам конкретен подкомпонент с бавни данни.

Интеграция със Server Components, Suspense и loading състояния

Всеки parallel slot е независимо React Server Component дърво, което означава, че всеки слот се стриймва отделно към клиента и има собствени граници за грешки. Това е особено полезно за dashboards, където един бавен external API (например analytics) не трябва да блокира останалата част от страницата.

// app/dashboard/@analytics/loading.tsx
export default function Loading() {
  return <div className="animate-pulse bg-gray-200 h-32 rounded" />;
}

// app/dashboard/@analytics/error.tsx
'use client';
export default function Error({ reset }: { reset: () => void }) {
  return (
    <div>
      <p>Анализите са временно недостъпни.</p>
      <button onClick={reset}>Опитайте отново</button>
    </div>
  );
}

Слотовете са напълно съвместими с Partial Prerendering (PPR). Статичният shell на слота се пре-генерира, а динамичните части се стриймват при заявка. Комбинацията е особено силна за модалния pattern: overlay-ът се появява моментално, докато вътрешните данни се зареждат прогресивно.

За защита на модали и слотове, които съдържат чувствителна информация, използвайте същите механизми, описани в моето ръководство за middleware и автентикация. Не разчитайте на UI ниво за защита. Потребител може да посети /items/42 директно и да прескочи модалния overlay.

Продукционен чеклист преди деплой във Vercel

Преди да пусна нещо с Parallel/Intercepting routes в production, минавам през следния чеклист. Той се роди от няколко пъти burnt-ване с production-only bug-ове, които не се виждаха локално.

  1. default.tsx на всяко ниво. Проверете с find app -type d -name "@*" и се уверете, че всяка от тези папки има default.tsx.
  2. Тест на deep linking. Отворете модала през клик, копирайте URL-а, отворете го в incognito. Трябва да получите пълноекранна страница, не модал.
  3. Тест на browser back/forward. Навигация с клавиатура и history бутоните трябва да отваря и затваря модала предвидимо.
  4. Auth check в интерцептора. Ако страницата зад модала изисква сесия, повторете проверката в (.)path/page.tsx. Middleware проверява URL пътя, но интерцепторът рендира различен файл.
  5. Cache стратегия. При статични модални данни, използвайте cache: 'force-cache' или fetch с next: { revalidate: ... }. Вижте ръководството за кеширане в Next.js 15.
  6. Edge runtime съвместимост. Ако рендирате слотовете на edge, уверете се, че никой от dependency-ите не използва Node.js API.
  7. Bundle size проверка. Client component-и в слотове се добавят към JS bundle. next build ще ви покаже увеличението.

Често задавани въпроси

Могат ли Parallel Routes да се използват в page.tsx?

Не. Слотовете се предават като пропсове само на layout.tsx. Ако имате нужда от паралелна структура на page ниво, добавете междинен layout на същото ниво или преместете компонентите вътре в един page.tsx като обикновени React елементи.

Работят ли Intercepting Routes с Server Actions?

Да, напълно съвместими са. Server Action, изпратен от модал в intercepting route, се обработва нормално и може да върне redirect или да ревалидира пътя. Ако формата затвори модала след submit, извикайте router.back() в success handler-а или използвайте redirect() от next/navigation.

Как се тества модалният pattern с Playwright или Cypress?

Пишете два отделни теста: единият навигира от feed страница и очаква dialog елемент, другият посещава модалния URL директно и очаква пълноекранна страница. Ако и двата преминат, deep linking работи коректно.

Мога ли да имам вложени Parallel Routes?

Да, но всеки слот на всяко ниво изисква свой default.tsx. Вложените слотове са полезни за сложни dashboards, но добавят когнитивна тежест. Обмислете дали един слот с вътрешно tabs състояние не решава проблема по-просто.

Защо URL-ът се променя, но съдържанието не се обновява?

Обикновено е cache проблем, Next.js router cache връща предишен snapshot на слота. Опитайте router.refresh() след mutation, или добавете revalidatePath() в Server Action-а. При development рестартирайте dev server-а, за да изчистите .next/cache.

Ben Howard
За Автора Ben Howard

Full-stack Next.js developer who's been with the framework since pages-only days. Slowly warming up to App Router.