Back/Module A-7 Advanced Routing Internals — Parallel, Intercepting, Templates, and Segments
Module A-7·25 min read

Parallel routes @slot rendering, default.js and the unmatched slot 404 trap, template.js vs layout.js state semantics, intercepting route modal patterns, soft vs hard navigation mechanics, and route group layout inheritance.

A-7 — Advanced Routing Internals: Parallel, Intercepting, Templates

Who this is for: Architects who hit the wall with basic App Router layouts and need to understand the features designed for complex UI patterns — the ones that make Twitter-style photo modals, side-by-side dashboards, and multi-step checkout flows possible without reaching for a client-side routing library.


Why the App Router's Layout Model Isn't Enough on Its Own

The default App Router layout model is a single slot: each route renders one page component inside its nearest layout. This is correct for most applications. But a category of UI patterns requires more:

  • Photo modal on scroll: User clicks a photo in a grid. A modal opens with the photo fullscreen. The URL changes to /photos/123. If the user refreshes, they get the standalone photo page, not the modal. The underlying grid remains visible and scrolled to the right position behind the modal.
  • Dashboard with independent panels: An analytics dashboard where the left sidebar shows a team list and the right panel shows the selected team's metrics. Each panel can be navigated independently without re-rendering the other.
  • Wizard with back navigation: A checkout flow where the browser back button navigates between steps in the wizard, and the URL reflects the current step.

Parallel routes and intercepting routes are the App Router's answer to these patterns. They're not workarounds — they're first-class routing primitives.


Parallel Routes — Multiple Slots in One Layout

Parallel routes let a single layout render multiple independent pages simultaneously. Each slot is a named directory prefixed with @:

app/
  dashboard/
    layout.tsx          ← receives both slots as props
    page.tsx            ← default slot content
    @analytics/
      page.tsx          ← analytics slot
      default.tsx       ← shown when no sub-route matches
    @team/
      page.tsx          ← team slot
      default.tsx
tsx
// app/dashboard/layout.tsx export default function DashboardLayout({ children, analytics, team, }: { children: React.ReactNode; analytics: React.ReactNode; team: React.ReactNode; }) { return ( <div className="dashboard-grid"> <main>{children}</main> <aside className="analytics-panel">{analytics}</aside> <aside className="team-panel">{team}</aside> </div> ); }

Each slot receives its own independent Server Component subtree. When the user navigates within one slot (e.g., /dashboard/analytics/weekly), only that slot re-renders. The other slots are unaffected — their Server Component trees don't re-execute.

The default.tsx requirement: When a slot has sub-routes (e.g., @analytics/weekly/page.tsx), it needs a default.tsx at the slot root. This is shown when the URL doesn't match any sub-route of that slot. Without it, navigating to a URL that matches the main page.tsx but has no corresponding sub-route for a slot causes a 404.


Intercepting Routes — The Modal Pattern

Intercepting routes let you "intercept" a navigation that would normally go to a full page, and instead render the destination inside a modal on top of the current page.

The convention uses (.) to indicate the interception level:

app/
  photos/
    page.tsx           ← grid view (all photos)
    [id]/
      page.tsx         ← standalone photo page (direct URL access)
  @modal/
    (.)photos/
      [id]/
        page.tsx       ← intercepted modal version of the photo
    default.tsx        ← null (no modal by default)
tsx
// app/layout.tsx export default function RootLayout({ children, modal, }: { children: React.ReactNode; modal: React.ReactNode; }) { return ( <html> <body> {children} {modal} {/* modal slot — null by default, photo modal when intercepted */} </body> </html> ); }

The (.) in (.)photos/[id] means "intercept the route at the same level." The interception levels:

  • (.) — same level
  • (..) — one level up
  • (..)(..) — two levels up
  • (...) — root level

How the interception decision is made: When the user clicks a link from /photos to /photos/123, React Router detects the navigation and checks for an intercepting route. It finds @modal/(.)photos/[id]/page.tsx and renders it as a modal. When the user refreshes the page on /photos/123, there's no prior navigation to intercept — Next.js serves the standalone page at photos/[id]/page.tsx.

This is the key insight: interception is a client-side navigation concept only. Direct URL access always goes to the real route.


Building the Photo Modal End-to-End

tsx
// app/@modal/(.)photos/[id]/page.tsx import { PhotoModal } from '@/components/PhotoModal'; export default async function PhotoModalPage({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; const photo = await getPhoto(id); return <PhotoModal photo={photo} />; }
tsx
// components/PhotoModal.tsx 'use client'; import { useRouter } from 'next/navigation'; export function PhotoModal({ photo }: { photo: Photo }) { const router = useRouter(); return ( <div className="fixed inset-0 bg-black/70 z-50" onClick={() => router.back()} // ← dismiss closes the modal, returns to grid > <div className="modal-content" onClick={e => e.stopPropagation()} > <img src={photo.url} alt={photo.alt} /> <h1>{photo.title}</h1> </div> </div> ); }

router.back() on dismiss navigates back to the grid page, which removes the modal from the @modal slot. The underlying grid page was never unmounted — the user returns to their scroll position.


Route Groups — Organisation Without URL Impact

Route groups use (parenthesis) syntax to group routes without affecting the URL:

app/
  (marketing)/
    page.tsx          ← /
    about/
      page.tsx        ← /about
    blog/
      page.tsx        ← /blog
  (app)/
    layout.tsx        ← authenticated layout (with sidebar, nav)
    dashboard/
      page.tsx        ← /dashboard
    settings/
      page.tsx        ← /settings

Route groups solve the problem of needing different layouts for different sections of the application without polluting the URL. (marketing) pages share one layout (no auth), (app) pages share another (with sidebar and auth checks). The (marketing) and (app) directory names don't appear in URLs.

Multiple root layouts: You can have a separate root layout per route group by placing layout.tsx at the group level. A page in (marketing) and a page in (app) can have completely different <html> and <body> structures — different fonts, different CSS variables, different analytics.


Templates vs Layouts

Templates (template.tsx) look like layouts but have a critical difference: they re-mount on every navigation.

Layouts persist across navigations — the layout component instance is preserved. A Client Component in a layout keeps its state between navigations.

Templates create a new instance on every navigation — state is reset, useEffect runs again.

tsx
// app/template.tsx — runs fresh on every navigation export default function Template({ children }: { children: React.ReactNode }) { return <div>{children}</div>; }

When to use a template over a layout:

  • You need useEffect to run on every navigation (page view tracking without onRouteChange)
  • You want animations that play on every page enter (exit animations are harder — use Framer Motion with layout)
  • You have forms that should reset when the user navigates away and back
  • You're implementing startTransition-based page transitions

Most of the time, layouts are correct. Templates are the exception for cases where re-mounting on navigation is the intended behaviour.


The forbidden() and unauthorized() Interrupt System

Introduced in Next.js 15, forbidden() and unauthorized() are to auth what notFound() is to missing resources — they throw a special error that renders a specific file instead of error.tsx.

ts
// In a Server Component or Server Action import { forbidden, unauthorized } from 'next/navigation'; export default async function AdminPage() { const session = await auth(); if (!session) { unauthorized(); // renders unauthorized.tsx (HTTP 401) } if (session.user.role !== 'admin') { forbidden(); // renders forbidden.tsx (HTTP 403) } return <AdminDashboard />; }
tsx
// app/forbidden.tsx export default function ForbiddenPage() { return ( <div> <h1>Access Denied</h1> <p>You don't have permission to access this page.</p> </div> ); }

The architectural benefit: auth checks are co-located with the data they protect (in the Server Component), but the auth error UI is centralised in forbidden.tsx and unauthorized.tsx. No repetition of error UI across every protected page.

Enable in next.config.ts:

ts
const config: NextConfig = { experimental: { authInterrupts: true, }, };

Route Segment Config — When to Use It

Route segment config exports (dynamic, revalidate, runtime, fetchCache) override the segment's rendering behaviour. They're escape hatches, not defaults.

ts
// app/admin/page.tsx // Force dynamic rendering even if the page could be static export const dynamic = 'force-dynamic'; // Override the default revalidation time for this segment export const revalidate = 3600; // 1 hour // Run this segment at the edge instead of Node.js export const runtime = 'edge'; // Control fetch caching behaviour for all fetches in this segment export const fetchCache = 'force-no-store';

When to use each:

  • dynamic = 'force-dynamic' — page reads cookies/headers but Next.js incorrectly static-renders it
  • revalidate = N — override the page-level ISR revalidation time without changing fetch calls
  • runtime = 'edge' — the handler is simple enough for edge and needs low latency
  • fetchCache — you want to override all fetch calls in a segment without touching each one

When not to use them: as a first resort. Start with the natural rendering behaviour and reach for segment config when it diverges from what you need.


Where We Go From Here

A-8 addresses one of the hardest architectural problems in the App Router — state that needs to exist both on the server and the client. URL state, server-side form state, optimistic updates, and React context across the RSC boundary. With A-7's routing internals, A-8 explains how state flows through the structures you've just learned.

Discussion