Parallel Routes en Intercepting Routes in Next.js App Router: Modals, Dashboards en @-Slot Patronen

Leer hoe je Parallel Routes en Intercepting Routes inzet in Next.js 15 voor dashboards, deelbare modals en role-based UI's, met werkende code, valkuilen en performance-tips voor 2026.

Parallel Routes Next.js 15: Modals & @-Slots 2026

Parallel Routes en Intercepting Routes behoren tot de krachtigste — en eerlijk gezegd ook de minst begrepen — features van de Next.js App Router. Ze laten je meerdere pagina's tegelijk renderen in dezelfde layout, modale dialogen bouwen die deelbaar zijn via URL, en complexe dashboards opzetten waarin elke sectie z'n eigen leven leidt qua laden en falen. Klinkt abstract? Dat dacht ik ook, totdat ik er een keer een avond goed in dook. In deze gids loop ik stap voor stap door beide patronen, met werkende code voor Next.js 15 die je morgen al in productie kunt zetten.

Wat zijn Parallel Routes?

Parallel Routes laten je meerdere pagina's gelijktijdig renderen binnen één layout. Elke parallel route gedraagt zich als een onafhankelijke "slot" in die layout — met z'n eigen loading states, error boundaries en data-fetching. Dat is fundamenteel anders dan geneste routes, waar er per keer maar één segment actief is.

De definitie gebeurt via mappen die beginnen met een @-teken (denk aan @analytics of @team). Die "named slots" worden dan automatisch als props doorgegeven aan de bovenliggende layout. Simpel idee, krachtige uitwerking.

Wanneer gebruik je Parallel Routes?

  • Dashboards met meerdere onafhankelijke widgets die parallel mogen laden en falen
  • Conditionele rendering op basis van rollen of feature flags
  • Modale dialogen die deelbaar moeten zijn via URL en de browser-history moeten respecteren
  • Split views zoals chat-applicaties of e-mailclients met lijst- en detailweergave

Een eerste Parallel Route opzetten

Laten we meteen een dashboard bouwen met twee onafhankelijke secties: analytics en teamleden. Begin met deze mapstructuur:

app/
  dashboard/
    layout.tsx
    page.tsx
    @analytics/
      page.tsx
      loading.tsx
      error.tsx
    @team/
      page.tsx
      loading.tsx

De layout krijgt elke slot automatisch als prop binnen — geen handmatige bedrading nodig:

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

Elke slot mag z'n eigen async data ophalen, en omdat ze parallel renderen, blokkeert een trage @team-fetch nooit de @analytics-render. Dat alleen al rechtvaardigt vaak de extra mapstructuur:

// app/dashboard/@analytics/page.tsx
export default async function AnalyticsSlot() {
  const stats = await fetch('https://api.example.com/stats', {
    next: { revalidate: 60 },
  }).then((r) => r.json());

  return (
    <div>
      <h2>Pageviews</h2>
      <p className="text-3xl font-bold">{stats.pageviews.toLocaleString('nl-NL')}</p>
    </div>
  );
}

Conditionele rendering met Parallel Routes

Een veelvoorkomende toepassing is het tonen van verschillende UI's op basis van de gebruikersrol. In plaats van alle conditionele logica in één component te proppen (we kennen die spaghetti allemaal wel), kun je elke variant als eigen slot definiëren:

app/
  layout.tsx
  @admin/
    page.tsx
    default.tsx
  @user/
    page.tsx
    default.tsx
// app/layout.tsx
import { getSession } from '@/lib/auth';

export default async function RootLayout({
  children,
  admin,
  user,
}: {
  children: React.ReactNode;
  admin: React.ReactNode;
  user: React.ReactNode;
}) {
  const session = await getSession();

  return (
    <html lang="nl">
      <body>
        {session?.role === 'admin' ? admin : user}
        {children}
      </body>
    </html>
  );
}

Het belang van default.tsx

Parallel Routes hebben één eigenaardigheid die echt heel veel ontwikkelaars laat struikelen — mezelf inbegrepen, eerlijk is eerlijk. Bij een volledige page-reload verliest Next.js de actieve state van slots die niet in de URL voorkomen. Geen default.tsx? Dan krijg je gewoon een 404 om de oren.

De oplossing is gelukkig triviaal: voeg een default.tsx toe aan elke slot. Dat is de fallback die rendert wanneer de URL geen actieve match biedt:

// app/dashboard/@analytics/default.tsx
export default function Default() {
  return null; // of een placeholder
}

Vergeet trouwens ook geen default.tsx toe te voegen voor de hoofd-children-slot wanneer je intercepting routes combineert met parallel routes. Dit is veruit de meest voorkomende oorzaak van die mysterieuze 404's waar niemand iets van begrijpt.

Wat zijn Intercepting Routes?

Intercepting Routes laten je een route "onderscheppen" en in plaats daarvan een andere UI tonen, terwijl de originele URL gewoon intact blijft. Dat is precies de basis voor moderne, deelbare modal-patronen.

De conventies gebruiken speciale prefixen op mapnamen:

  • (.) — onderschept een route op hetzelfde niveau
  • (..) — onderschept een route één niveau hoger
  • (..)(..) — onderschept twee niveaus hoger
  • (...) — onderschept vanaf de app-root

Belangrijk om te onthouden: deze haakjes verwijzen naar de routesegmenten, niet naar de bestandsstructuur. Een (..)(..)-prefix in een diep geneste map kan dus voor verrassende resultaten zorgen — vraag me niet hoe ik dat weet.

Het Modal-patroon: Parallel + Intercepting Routes

Dit is wat mij betreft de killer-app voor deze features: het deelbare modal-patroon. Denk aan Instagram of Dribbble, waar je op een afbeelding klikt en hij als modal opent, maar bij directe URL-toegang gewoon de volledige pagina te zien krijgt. Hier is de complete structuur:

app/
  layout.tsx
  page.tsx
  @modal/
    default.tsx
    (.)foto/
      [id]/
        page.tsx
  foto/
    [id]/
      page.tsx

De root-layout neemt de modal-slot op:

// app/layout.tsx
export default function RootLayout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <html lang="nl">
      <body>
        {children}
        {modal}
        <div id="modal-root" />
      </body>
    </html>
  );
}

De default voor de modal blijft lekker leeg:

// app/@modal/default.tsx
export default function Default() {
  return null;
}

De normale fotopagina (de volledige weergave bij directe URL):

// app/foto/[id]/page.tsx
import { getPhoto } from '@/lib/photos';

export default async function PhotoPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const photo = await getPhoto(id);

  return (
    <article className="mx-auto max-w-4xl p-8">
      <h1>{photo.title}</h1>
      <img src={photo.url} alt={photo.title} />
      <p>{photo.description}</p>
    </article>
  );
}

En de onderschepte versie — die wordt getoond als modal zodra een gebruiker via een <Link> navigeert:

// app/@modal/(.)foto/[id]/page.tsx
import { getPhoto } from '@/lib/photos';
import { Modal } from '@/components/modal';

export default async function PhotoModal({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const photo = await getPhoto(id);

  return (
    <Modal>
      <img src={photo.url} alt={photo.title} />
      <h2>{photo.title}</h2>
    </Modal>
  );
}

De Modal-component met router.back()

De modal sluit door simpelweg terug te navigeren in de browser-history, en dat maakt de onderschepping ongedaan:

// components/modal.tsx
'use client';

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

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

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

  function onDismiss() {
    router.back();
  }

  return createPortal(
    <dialog
      ref={dialogRef}
      className="rounded-lg p-6 backdrop:bg-black/60"
      onClose={onDismiss}
      onClick={(e) => {
        if (e.target === dialogRef.current) onDismiss();
      }}
    >
      {children}
      <button onClick={onDismiss}>Sluiten</button>
    </dialog>,
    document.getElementById('modal-root')!,
  );
}

Loading en Error States per Slot

Een van de leukste voordelen van Parallel Routes is dat elke slot z'n eigen loading- en error-boundaries krijgt. Een trage analytics-API blokkeert de teamlijst niet, en een fout in één widget brengt het hele dashboard niet onderuit. Heerlijk:

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

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div className="rounded border border-red-300 p-4">
      <p>Analytics konden niet worden geladen.</p>
      <button onClick={reset}>Opnieuw proberen</button>
    </div>
  );
}

Veelvoorkomende valkuilen

1. Vergeten default.tsx leidt tot 404

Bij een hard reload of directe URL-toegang vereist Next.js dat alle slots een matchende state hebben. Ontbreekt een default.tsx? Dan krijg je een 404. Voeg er altijd een toe — zelfs als het ding alleen maar null retourneert.

2. Intercepting werkt alleen via client-side navigatie

Onderscheppingen vinden uitsluitend plaats wanneer de gebruiker via <Link> of router.push() navigeert. Bij een hard reload of een geplakte URL valt Next.js gewoon terug op de "echte" route. Zorg dus dat beide varianten een fatsoenlijke UX bieden.

3. Verkeerd niveau-prefix gebruiken

De (.), (..) en (...)-prefixen verwijzen naar routesegmenten, niet naar mapdiepte. Een route-group als (marketing) telt bijvoorbeeld niet als segment. Test je intercepting altijd vanuit de echte navigatiepaden in je app — anders blijf je raden.

4. Modal-state delen met server

De Modal-component is een client component, maar krijgt children binnen die op de server gerenderd zijn. Houd je state-management lekker simpel: gebruik router.back() om te sluiten en laat de URL gewoon de waarheidsbron zijn.

Performance-overwegingen voor 2026

Met Next.js 15 en Partial Prerendering (PPR) krijgen Parallel Routes nog meer waarde. Elke slot kan onafhankelijk worden voorgerenderd, met dynamische delen achter een eigen <Suspense>-grens. In de praktijk leverde dat in mijn laatste project metingen op rond Time-to-First-Byte van <100ms voor statische slots, terwijl dynamische analytics-data parallel doorstreamde. Verschil tussen "snel genoeg" en "wow, dat is snel".

Combineer Parallel Routes met de use cache-directive voor maximale performance:

// app/dashboard/@analytics/page.tsx
'use cache';
import { unstable_cacheTag as cacheTag } from 'next/cache';

export default async function AnalyticsSlot() {
  cacheTag('analytics-stats');
  const stats = await fetchStats();
  return <StatsView stats={stats} />;
}

Veelgestelde vragen

Wat is het verschil tussen Parallel Routes en geneste layouts?

Geneste layouts renderen één route-hiërarchie waarbij elke laag de onderliggende inhoud omhult. Parallel Routes renderen daarentegen meerdere onafhankelijke routes naast elkaar in dezelfde layout, elk met eigen loading-, error- en data-states. Kort gezegd: geneste layouts gaan over hiërarchie, parallel routes over compositie.

Werken Parallel Routes met server components?

Ja, en het is zelfs het aanbevolen patroon. Elke slot kan een async server component zijn die zelfstandig data ophaalt. Omdat de slots parallel renderen, voeren meerdere fetches gelijktijdig uit in plaats van sequentieel — wat bij dashboards een wereld van verschil maakt.

Hoe sluit ik een modal die met intercepting routes is gebouwd?

Gebruik router.back() in een client component. Dat navigeert terug in de browser-history, waardoor de onderschepping wordt opgeheven en de oorspronkelijke pagina weer zichtbaar wordt. Vermijd router.push() naar de vorige URL — dat creëert een nieuwe history-entry en breekt de natuurlijke back-button-flow.

Waarom krijg ik een 404 na een page refresh op een parallel route?

Bijna altijd komt dit door een ontbrekend default.tsx-bestand in een van de slots. Bij een full reload moet Next.js voor elke slot een matchende state vinden; zonder default valt-ie terug op een 404. Voeg default.tsx toe aan elke slot, inclusief de impliciete children-slot wanneer dat nodig is.

Kan ik Parallel Routes combineren met dynamische segmenten?

Absoluut. Een slot zoals @modal/(.)product/[id]/page.tsx combineert intercepting, parallel routes en dynamische parameters in één klap. De params-prop wordt sinds Next.js 15 als een Promise geleverd — vergeet 'm dus niet te awaiten voordat je de waarden gebruikt.

Conclusie

Parallel Routes en Intercepting Routes openen een patroonruimte die in pre-App-Router-tijden alleen met derde-partij-libraries en pijnlijke routerhacks bereikbaar was. Voor dashboards, deelbare modals en role-based UI's leveren ze een natuurlijke, URL-gedreven oplossing met eersteklas server component-ondersteuning. Mijn advies? Begin klein — bouw eerst een eenvoudig dashboard met twee slots — en voeg intercepting pas toe wanneer je modal-patronen echt nodig hebt. Vergeet je default.tsx-bestanden niet, en gebruik router.back() consistent voor je sluitlogica. Succes!

Over de Auteur Editorial Team

Our team of expert writers and editors.