Hvis du har bygget bare et par sider med Next.js App Router, har du højst sandsynligt stødt på de mere kryptiske mappekonventioner: @folder, (.)folder og lignende parenteser, der ærligt talt ligner små besværgelser, første gang man ser dem. Det er Parallel Routes og Intercepting Routes – to mønstre, der løser problemer, vi førhen brugte tunge state-management-biblioteker eller en bunke manuel ruterhåndtering på.
I denne guide gennemgår jeg begge mønstre fra grunden, viser hvornår du bør bruge dem (og lige så vigtigt: hvornår du absolut ikke skal), og giver dig færdige kodeeksempler til de to mest typiske use cases: et dashboard med flere uafhængige sektioner, og en modal-rute, der bevarer URL-state ved deling og opdatering.
Lad os dykke ned i det.
Hvad er Parallel Routes?
Parallel Routes lader dig rendere flere uafhængige sider samtidigt i samme layout. Hver "side" har sin egen loading-state, sin egen error-boundary, sin egen navigation – og hver kan navigeres uafhængigt af de andre. Ret elegant, faktisk.
Tænk på et team-dashboard, der viser tre paneler: analytics, notifikationer og holdmedlemmer. Med klassisk routing skal du enten lægge alt i ét component-træ (svært at code-splitte) eller hente data parallelt på server-niveau (og dermed miste granulariteten over loading-states). Parallel Routes giver dig begge dele uden kompromiser.
Slot-konventionen: @folder
En parallel rute defineres ved at præfikse en mappe med @. Disse mapper kaldes slots, og et layout, der ligger ved siden af, modtager hvert slot som en prop:
app/
├── layout.tsx
├── page.tsx
├── @analytics/
│ ├── page.tsx
│ └── loading.tsx
├── @notifications/
│ ├── page.tsx
│ └── default.tsx
└── @team/
├── page.tsx
└── error.tsx
Layoutet modtager nu de tre slots som benævnte props ved siden af det velkendte children:
// app/layout.tsx
export default function DashboardLayout({
children,
analytics,
notifications,
team,
}: {
children: React.ReactNode
analytics: React.ReactNode
notifications: React.ReactNode
team: React.ReactNode
}) {
return (
<div className="grid grid-cols-3 gap-6 p-6">
<main className="col-span-3">{children}</main>
<section>{analytics}</section>
<section>{notifications}</section>
<section>{team}</section>
</div>
)
}
Hvert slot er reelt set en mini-app med sit eget rute-tree. Det betyder, at app/@analytics/loading.tsx kun viser et loading-skelet for det panel – mens @team sagtens kan rendere færdigt og være interaktivt på samme tid. Det er den slags ting, jeg førhen byggede med en blanding af React Query og custom Suspense-grænser. Nu er det indbygget.
Hvorfor default.tsx er kritisk
Den absolut mest almindelige fejl, jeg ser udviklere lave med Parallel Routes, er at glemme default.tsx. Når en bruger navigerer til en URL, der ikke har en matchende rute i et af dine slots, og siden bliver hard-refreshed (i modsætning til en client-side overgang), så kaster Next.js en 404, medmindre slottet har en default.tsx.
// app/@notifications/default.tsx
export default function NotificationsDefault() {
return null // eller en tom placeholder-UI
}
Reglen er simpel: hver parallel rute skal have en default.tsx. Hvis et slot kun bruges nogle steder i applikationen, returnerer du bare null som fallback. Husk det – det sparer dig for en fejlsøgning kl. 23 fredag aften.
Hvad er Intercepting Routes?
Intercepting Routes lader dig "fange" en rute fra ét sted i din app og rendere den et andet sted, mens URL'en stadig opdateres som forventet. Det klassiske eksempel? En bruger klikker på et fotomini i et galleri, og du vil vise fotoet i en modal – men hvis brugeren deler URL'en eller opdaterer siden, skal de stadig se den fulde fotoside.
Konventionen bruger parenteser i mappenavne:
(.)folder– fang en rute på samme niveau(..)folder– fang en rute ét niveau op(..)(..)folder– to niveauer op(...)folder– fang fra app-roden
En vigtig pointe: disse parenteser er ikke det samme som route groups ((group)), selvom de visuelt ligner hinanden. Route groups er rent organisatoriske og påvirker ikke URL'en. Intercepting routes er adfærdsmæssige og ændrer hvor en rute renderes. Bland dem ikke sammen.
Modal-mønsteret: Parallel + Intercepting
Den mest udbredte brug af Intercepting Routes er i kombination med Parallel Routes for at lave URL-baserede modaler. Her er et komplet billed-galleri-eksempel:
app/
├── layout.tsx
├── page.tsx // galleri-grid
├── @modal/
│ ├── default.tsx // returnerer null
│ └── (.)photos/
│ └── [id]/
│ └── page.tsx // modal-version
└── photos/
└── [id]/
└── page.tsx // fuld foto-side
Layout-filen renderer modal-slottet ved siden af children:
// app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode
modal: React.ReactNode
}) {
return (
<html lang="da">
<body>
{children}
{modal}
<div id="modal-root" />
</body>
</html>
)
}
Galleriet linker til /photos/123 som normalt:
// app/page.tsx
import Link from 'next/link'
export default function Gallery() {
return (
<div className="grid grid-cols-4 gap-2">
{photos.map((photo) => (
<Link key={photo.id} href={`/photos/${photo.id}`}>
<img src={photo.thumb} alt={photo.title} />
</Link>
))}
</div>
)
}
Når brugeren klikker fra galleriet, fanger (.)photos/[id] ruten og viser en modal. Hvis brugeren derimod kopierer URL'en eller opdaterer siden, gælder interception ikke længere, og den fulde side i app/photos/[id]/page.tsx renderes i stedet. Magisk – men ikke magic. Det er bare konventioner.
Modal-komponenten med dismiss-håndtering
// app/@modal/(.)photos/[id]/page.tsx
import { Modal } from '@/components/modal'
import { getPhoto } from '@/lib/photos'
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>
<p>{photo.description}</p>
</Modal>
)
}
// 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(() => {
dialogRef.current?.showModal()
}, [])
function dismiss() {
router.back()
}
return createPortal(
<dialog
ref={dialogRef}
onClose={dismiss}
className="rounded-lg p-6 backdrop:bg-black/50"
>
{children}
<button onClick={dismiss}>Luk</button>
</dialog>,
document.getElementById('modal-root')!
)
}
En lille ærlig anbefaling: brug det native <dialog>-element. Du får gratis Escape-tast-håndtering, fokus-trapping og ::backdrop-styling – uden et eneste tredjepartsbibliotek. Jeg har skiftet alle mine projekter væk fra react-modal og co., og jeg savner det ikke et sekund.
Avancerede Parallel Route-mønstre
Conditional rendering baseret på auth
Du kan rendere forskellige slots baseret på server-side state. Det er et stærkt mønster for autentificerings-flows:
// app/layout.tsx
import { auth } from '@/lib/auth'
export default async function Layout({
children,
login,
dashboard,
}: {
children: React.ReactNode
login: React.ReactNode
dashboard: React.ReactNode
}) {
const session = await auth()
return session ? dashboard : login
}
Begge slots forbliver code-split, og kun det relevante træ bliver hentet og hydreret. Det er den slags ting, der virker oversimpelt, indtil du regner ud, hvor meget logik det erstatter.
Tab-baserede UIs uden client state
Med useSelectedLayoutSegment(slotName) kan du læse, hvilken sub-rute der i øjeblikket er aktiv i et slot, og bruge det til at fremhæve en tab-knap:
'use client'
import { useSelectedLayoutSegment } from 'next/navigation'
import Link from 'next/link'
export function AnalyticsTabs() {
const segment = useSelectedLayoutSegment('analytics')
return (
<nav>
<Link
href="/dashboard/visits"
aria-current={segment === 'visits' ? 'page' : undefined}
>
Besøg
</Link>
<Link
href="/dashboard/conversions"
aria-current={segment === 'conversions' ? 'page' : undefined}
>
Konverteringer
</Link>
</nav>
)
}
Almindelige faldgruber – og hvordan du undgår dem
1. Manglende default.tsx fører til mystiske 404'ere
Symptomet: alt fungerer perfekt i development, men en hard refresh på en specifik URL giver pludselig 404. Løsningen er altid den samme – tilføj default.tsx til hvert @-slot. Returnér null, hvis slottet ikke skal vise noget.
2. Modaler forsvinder ved refresh
Det er by design. Intercepting Routes virker kun under client-side navigation. Når brugeren refresher, får de den fulde side – sådan at din applikation fungerer korrekt for delte links og browser-historik. Sørg derfor for, at den fulde version af ruten (app/photos/[id]/page.tsx) er en komplet, selvstændig side, og ikke bare en stub.
3. Forveksling af route groups med intercepting routes
(marketing) er en route group: den organiserer dine filer uden at påvirke URL'en. (.)photos er en intercepting route: den ændrer hvor en rute renderes. Brug ikke en enkelt prik (.) som mappenavn for organisering – det giver hurtigt uventede interception-effekter, og fejlsøgningen er ikke sjov.
4. SEO og parallel routes
Søgemaskiner crawler den fulde URL og får derfor altid den ikke-interceptede version af siden. Det betyder, at en modal-implementering er SEO-venlig ud af kassen: Googlebot ser den fulde fotoside, mens brugere får en flot modal-oplevelse. Husk dog at definere generateMetadata på den fulde rute.
5. Performance: undgå at over-bruge parallel routes
Hvert slot betyder ekstra rute-tree, ekstra layouts og potentielt ekstra round-trips. Hvis to "slots" altid renderes sammen og deler data, så er almindelige React-komponenter med Suspense-grænser et bedre valg. Det her er nok min vigtigste anbefaling: bare fordi du kan, betyder det ikke, at du skal.
Hvornår skal du faktisk bruge Parallel Routes?
Et hurtigt beslutningstræ til de uafklarede stunder:
- Brug Parallel Routes når dele af UI'et skal navigeres uafhængigt (separat URL-state per panel) eller har radikalt forskellige loading- og error-states.
- Brug Intercepting Routes når du har en URL, der både skal kunne åbnes som en fuld side og som en overlay (modal, side-panel, drawer).
- Brug almindelige komponenter med Suspense når du blot vil have parallel data-fetching uden separat URL-state.
Migration fra Pages Router-modaler
Hvis du migrerer fra Pages Router og har brugt biblioteker som react-modal eller global state til at vise modaler, så er overgangen til Intercepting Routes en kæmpe forenkling. Du fjerner:
- Modal-state fra Redux/Zustand
- Manuel synkronisering med browser-historik
- Custom logik til at håndtere browser-back-knappen
Alt det erstattes af native browser-navigation og to simple route-konventioner. På et af mine egne projekter slettede jeg cirka 200 linjer modal-state-kode på en eftermiddag. Det føltes godt.
Test af Parallel og Intercepting Routes
End-to-end-tests med Playwright håndterer disse mønstre rigtig godt. Det vigtige er at teste begge stier – både modal-flowet og den fulde side:
import { test, expect } from '@playwright/test'
test('foto åbner som modal fra galleri', async ({ page }) => {
await page.goto('/')
await page.getByRole('link', { name: /foto 1/i }).click()
await expect(page.getByRole('dialog')).toBeVisible()
await expect(page).toHaveURL('/photos/1')
})
test('foto vises som fuld side ved direkte URL', async ({ page }) => {
await page.goto('/photos/1')
await expect(page.getByRole('dialog')).not.toBeVisible()
await expect(page.getByRole('heading', { name: /foto 1/i })).toBeVisible()
})
FAQ
Hvad er forskellen mellem parallel routes og route groups?
Route groups (folder) er rent organisatoriske – de hjælper med at strukturere filer uden at påvirke URL'en eller renderingen. Parallel routes @folder opretter slots, der renderes samtidigt i et fælles layout og kan navigeres uafhængigt af hinanden.
Kan intercepting routes bruges uden modaler?
Ja, absolut. Intercepting Routes handler om hvor en rute renderes, ikke hvordan. Du kan bruge dem til side-paneler, drawers, lightboxes, inline-redigering – eller hvilken som helst UI, der har en alternativ visning afhængig af, hvor brugeren kommer fra.
Hvorfor forsvinder min modal når jeg opdaterer siden?
Det er forventet adfærd. Intercepting Routes virker kun under client-side overgange. Ved en hard refresh ser brugeren den fulde rute (uden interception), så delte URL'er fungerer korrekt. Sørg for, at den fulde side ved app/.../page.tsx er en komplet og brugbar side i sig selv.
Påvirker parallel routes SEO negativt?
Nej, hvis det er sat ordentligt op. Søgemaskiner crawler den canonical URL og får den fulde, ikke-interceptede version. Definér generateMetadata på den fulde rute (ikke på interception-versionen) for at sikre korrekte titler og beskrivelser.
Kan jeg neste parallel routes inde i andre parallel routes?
Ja. Parallel routes komponerer rekursivt. Et @dashboard-slot kan selv have @charts og @filters som indre slots, hver med deres egne layouts og default.tsx-filer. Hold dog kompleksiteten i tjek – dyb nesting bliver meget hurtigt svært at vedligeholde.
Hvor mange slots kan et layout have?
Der er ingen hård begrænsning, men hvert slot tilføjer kompleksitet til dit rute-tree. I praksis er 2-4 slots i samme layout en sund grænse. Har du brug for flere parallelle UI-områder, så overvej, om de virkelig har brug for separat URL-state, eller om Suspense-grænser ville være bedre.
Konklusion
Parallel Routes og Intercepting Routes er to af App Routerens mest kraftfulde – og samtidig mest misforståede – features. Med @folder-slots opnår du dashboards med uafhængig navigation og granulære loading-states uden global state. Med (.)folder-konventionen får du modaler, der bevarer URL-state og fungerer korrekt ved deling.
Hovedpointerne at huske: tilføj altid default.tsx til hvert slot, accepter at Intercepting Routes ikke virker ved hard refresh (det er en feature, ikke en bug), og brug ikke parallel routes når almindelige komponenter med Suspense rækker. Følg disse mønstre, og du vil opdage, at App Router gør komplekse UI-flows markant simplere, end de nogensinde var i Pages Router.