Skip to content
advanced Phase 4 · File-Based and Advanced Routing

Parallel and Intercepting Routes

Render multiple zones and modal-style route previews.

50m
0 problems
Topic Progress 0%

@folders and props.analytics: Rendering Two Independent Zones from One Route

A dashboard shows a main column of charts and a right-hand activity feed, and you want both to be route-driven — the URL decides the chart, a nested URL decides the feed, and navigating in one zone must not reset the other. Normal nesting can't do that: children are a single slot. Parallel routes add named slots — folders prefixed with @ that the layout receives as separate props.

app/dashboard/
  layout.tsx              receives { analytics, activity, children }
  @analytics/page.tsx     -> the charts zone
  @activity/page.tsx      -> the feed zone
  page.tsx                -> the main column
  settings/page.tsx       -> main column changes, zones persist

The contract is mechanical and easy to get backwards: a slot folder must be rendered by the layout that owns it, through the prop named after the folder minus the @:

// app/dashboard/layout.tsx
export default function Layout(props: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  activity: React.ReactNode;
}) {
  return (
    <div className="grid grid-cols-[1fr_320px]">
      <section>{props.children}</section>
      <aside>{props.activity}</aside>
      <section className="col-span-2">{props.analytics}</section>
    </div>
  );
}

Forget {props.activity} and the feed silently disappears — no error, because a slot that is declared and never rendered is not a violation, just unused output. That is the first bug everyone hits.

Each slot is matched independently against the URL, which is what makes the zones independent: /dashboard/settings matches the default page.tsx in children, while @analytics and @activity keep rendering their own current match. You can drive them explicitly with query params or nested paths (/dashboard?feed=42 in one design, /dashboard/activity/42 in another) — the router doesn't care which, as long as a file exists for the slot's matched path.

Now the failure case the docs make you handle. Navigate somewhere the slot has no matching file — say @activity has page.tsx but nothing for a deep link you just added. During a soft navigation, Next needs something to render in that prop, so you provide a default.tsx per slot:

// app/dashboard/@activity/default.tsx
export default function Default() {
  return <FeedPlaceholder />;   // what the zone shows when nothing matches
}

default.tsx is the slot's fallback for unmatched routes and for the initial render of a navigation that doesn't align across every slot. Omit it and you get the runtime error candidates love to be asked about: "Missing required default.tsx" / the slot prop rendering nothing on first paint. Treat a slot like a mini-app: it needs a page, a loading boundary, and a default.

Slots do not cascade. A slot declared at app/layout.tsx (app/@modal/) is rendered by that layout and applies to every route beneath it — which is why the photo modal works from any page. But a dashboard slot exists only while the dashboard layout is mounted: navigating out of /dashboard unmounts it along with its props. Two layouts that both declare @activity each get their own version of the zone, with no relationship between them, so pick one owner per zone and pass data down through layout props if a child needs it. The folder-level conventions these slot files rely on — which folders emit URLs and which never do — come from Route Groups and Private Folders.

The payoff is state isolation. Because zones are separate subtrees, client state inside @activity (an open menu, a scroll offset) survives navigation in children — the router re-renders the matched leaf of each slot rather than tearing down the layout. Interview framing: parallel routes are not "multiple columns"; they are independent navigation targets sharing one URL bar, and default.tsx is the answer to "what renders when a slot has no route?" Next, the trick that lets one of those zones show a different page's content: interception.

The Dot-Dot Trick: Intercepting a Sibling Navigation Before It Happens

Interception changes what a navigation renders without changing where it goes. You click /photos/42; the URL bar updates to /photos/42; but what appears is a different file — a modal in the current view — because a slot contains a folder written with parenthesis-dots that claims that path from a distance.

app/
  photos/
    page.tsx                    -> the feed
    [id]/page.tsx               -> the real photo page
  @modal/
    (.)photos/[id]/page.tsx     -> intercepts /photos/<id> from here
    default.tsx

@modal is a slot on the root layout, and (.)photos means: intercept a navigation to photos, one level above this folder's own position. The notation is relative and there are exactly three forms — memorize them, this is a favorite quiz item:

Folder written as Intercepts routes matching
(.)segment one level above the current folder
(..)(..)segment two levels above
(...)segment the root segment of app/

Counting is from the folder containing the (.), not from the URL: app/@modal/(.)photos sits one level above app/photos in the tree, so one dot is enough. Get the count wrong and nothing errors — the route simply never intercepts, and you are left wondering why a modal renders a full page instead.

What actually happens on click: <Link href="/photos/42"> performs a soft navigation. The router resolves the path, notices the slot's interception rule matches, and renders @modal/(.)photos/[id]/page.tsx in the modal slot while photos/page.tsx stays mounted underneath. The address bar still shows /photos/42, so sharing the URL, reloading it, or arriving from a search engine performs a hard navigation — no interception applies, and the browser renders photos/[id]/page.tsx as the real page. This asymmetry is the whole design:

soft navigation (Link click)  -> intercepted -> modal over the feed
hard navigation (reload/paste)-> normal     -> full photo page

That is why the modal pattern feels right rather than gimmicky: the deep link is a genuine page, and the click is a preview of it. In implementation, @modal/(.)photos/[id]/page.tsx usually reuses the same component as photos/[id]/page.tsx — the interception point decides the chrome, not the content, so you keep one PhotoView and wrap it in a modal shell in one place.

Interception composes with the normal special files. A loading.tsx inside the interception folder gives the modal its own skeleton, so the feed is not replaced by a global spinner. And since the intercepted route is still a route, notFound(), metadata, and error boundaries all behave as they would on the real page.

A useful test: temporarily comment out the interception folder's page.tsx and click the feed link. The navigation still updates the URL, but the modal slot falls back to default.tsx — nothing crashes, you just see the feed with a new URL. Restoring the file brings the modal back with no other change. That separation is what makes interception debuggable: the link, the interception point, and the slot are three independent pieces, and each fails in a different way.

One design caveat worth stating in a review: interception adds a route that exists only for soft navigations, which means the two render paths can drift if you give them different components. Keep a single source of truth for content (one PhotoView, one data-fetching function) and vary only the wrapper; otherwise the modal and the page will disagree about which fields are shown, and nobody notices until a user compares them side by side.

Common mistakes, stated plainly: putting the interception folder where its relative count doesn't reach (silent non-match); rendering the slot only on one route and then wondering why interception works on the feed but not on /settings; and intercepting a path you also want to open as a page without a fallback — remember the hard-navigation path is load-bearing, not an edge case. Next: assembling both mechanisms into the modal feed pattern end to end.

The Photo Feed Modal: Hard Refresh, Back Button, and Where the URL Lies

Here is the full pattern assembled, because interviewers ask you to build this, not to recite the notation. Requirement: an infinite photo feed where clicking a photo opens a modal with the photo, its comments, and a share link — but /photos/42 pasted into a browser must render a standalone page with its own metadata.

app/
  layout.tsx                      renders { children, @modal }
  photos/
    loading.tsx                   feed skeleton
    page.tsx                      <Feed /> with <Link href={`/photos/${id}`}> 
    [id]/
      page.tsx                    standalone: <PhotoView standalone />
      loading.tsx
  @modal/
    default.tsx                   nothing shown by default
    (.)photos/[id]/
      page.tsx                    <PhotoView /> inside a modal shell
      loading.tsx                 modal-specific spinner

Walk the three scenarios and say what each does — this is the answer that separates someone who built it from someone who read about it.

Click from the feed. Soft navigation, interception fires, the modal slot renders @modal/(.)photos/[id], the feed stays mounted behind it (scroll position and loaded images preserved), and the URL becomes /photos/42. Browser back pops the history entry, which unmounts the modal — no custom onClose needed to keep history honest.

Hard refresh on /photos/42. No interception: the router renders photos/[id]/page.tsx in children and @modal/default.tsx in the slot, so the user sees the standalone page with the correct title and OG tags. The modal shell must therefore be optional — if you only built the intercepted version, a shared link would 404 or render an empty layout.

Close the modal. Either router.back() (restores the feed URL, history stays linear) or a Link to the feed with router.push (adds an entry — fine if the modal URL shouldn't be revisited, sloppy if it should). Do not hand-roll a pushState escape hatch; the router already owns the entry you created when the interception happened.

Two production details the toy example skips. First, fetching in the modal: the intercepted page is a server component, so it can await the photo and comments directly — the feed's data is not reused, and that is fine because the modal is a separate render. If the feed already has the bytes and you want zero refetch, render the modal's body from a client component fed by the feed's cache, accepting that the hard-refresh path then needs its own fetch anyway. Second, metadata: generateMetadata runs on the standalone page, giving /photos/42 a real title; intercepted renders don't update the document head (the client router doesn't rewrite <title> for a modal), so decide whether that matters for your product.

Where does this sit in a codebase? Photo feeds, issue lists with a detail preview, tables with a side drawer — any UI where the detail view is also a URL. The interview framing to rehearse: parallel slots give independent zones, interception gives soft-navigation-only rendering of a real route, and the hard-navigation path is what keeps links, refresh, and SEO honest. If a candidate can explain why the modal must have a default.tsx and a real page behind it, they have actually shipped the pattern.

Be ready for the follow-up: "Why not just useState(true) and render a modal?" Because then the URL is not the state — a refresh closes it, Back does nothing, the share link opens the feed, and analytics can't distinguish a view from a list visit. Interception buys URL-addressable UI with the modal experience; a state-only modal is simpler but leaks its state out of the URL. The middle option — a query param like ?photo=42 with a client-side modal — keeps refresh working but skips server rendering of the detail and pushes state through searchParams, which is a fine trade for a purely client-cacheable view and a poor one when the detail needs its own metadata.

Quiz

1. app/dashboard/layout.tsx receives props but the activity feed never appears, with no error logged. What is the most likely cause?

Question 1 options

2. A navigation to a path that has no matching file inside a slot needs what to render correctly?

Question 2 options

3. app/@modal/(.)photos/[id]/page.tsx — which navigation does it intercept?

Question 3 options

4. A user opens /photos/42 in a modal, then hits the browser Back button. What should happen, and why?

Question 4 options

Flashcards

Question

What is a named slot, how is it declared, and how does the layout use it?

Answer

A folder prefixed with @ (e.g. app/dashboard/@analytics) whose contents are passed to the nearest layout as a prop named after the folder (props.analytics). The layout must render that prop or the zone never appears — no error, just missing UI.

Question

When is default.tsx required, and what does it render?

Answer

In a slot folder (@name/default.tsx). It supplies the content for a navigation that has no matching file inside that slot (and for initial renders). Without it, the slot renders nothing or errors during navigation; loading.tsx and error.tsx do not replace it.

Question

Explain (.), (..)(..), and (...) in an interception route folder.

Answer

They set how far up the tree the interception point matches, counted from the folder that contains them: (.) = one level above, (..)(..) = two levels, (...) = the root segment of app/. A wrong count fails silently — the navigation just renders normally.

Question

Intercepted click vs hard refresh on the same URL — what renders?

Answer

Click (soft navigation): the interception route renders in its slot (modal over the current page) while the URL updates. Refresh/paste (hard navigation): interception does not apply; the real page renders with its own layout, metadata, and @slot default — so both files must exist and share components.

Question

How should the modal close so history stays correct?

Answer

router.back() to pop the entry the intercepted navigation pushed (feed URL restored, no custom history code), or push to the feed when the modal URL should not be revisited. Never hand-roll pushState — the router already recorded the entry.

Revision Notes

Key Takeaways

  • 1. Slots are @folders delivered to the layout as props — declaring one and not rendering it loses the zone silently
  • 2. default.tsx is the slot's fallback for navigations with no matching file; loading and error files are not substitutes
  • 3. (.) is one level above the folder it is written in, (..)(..) two, (...) the root — and a wrong count fails without erroring
  • 4. Interception applies to soft navigations only; a reload renders the real page, so both files must exist and share components
  • 5. Back button works for free because interception is still a real navigation that pushed a history entry

Interview Tips

  • • "What are parallel routes?" — slots that let one layout render several independently navigated regions; mention props rendering and default.tsx as the two things candidates forget
  • • "Explain interception" — a folder whose (.) notation claims a route from a distance: soft navigation renders it in place, hard navigation renders the real page; give the photo-modal example unprompted
  • • "Why does my modal work on click but 404 on refresh?" — you built only the interception file; the hard path must render photos/[id]/page.tsx with its own metadata
  • • "How do you close the modal without breaking history?" — router.back(), because the intercepted navigation pushed an entry; explain why a manual pushState would double-count

Cheat Sheet

Parallel and Intercepting Routes

Parallel (named slots):

app/dashboard/layout.tsx        gets { children, analytics, activity }
app/dashboard/@analytics/page.tsx
app/dashboard/@activity/default.tsx   <- required fallback for unmatched routes
export default function Layout({ children, analytics, activity }) {
  return <>{children}{activity}{analytics}</>;   // render every prop you declare
}

Each slot matches the URL independently -> independent zones, independent client state.

Interception notation (relative to the folder that contains it):

Written Matches
(.)photos one level above
(..)(..)photos two levels above
(...)photos the root segment

Canonical modal feed:

app/photos/[id]/page.tsx          real page (hard navigation, metadata)
app/@modal/(.)photos/[id]/page.tsx  modal (soft navigation only)
app/@modal/default.tsx           nothing shown otherwise

Behavior matrix:

Action Renders
<Link> click to /photos/42 intercepted: modal over the feed
refresh / paste URL real page + slot default
Back after modal opens pops the entry, modal unmounts

Close the modal: router.back() (linear history) or push to the feed (new entry). Both pages should share one <PhotoView />; interception chooses the chrome, not the content.

Gotchas: never-rendered slot prop = silent disappearance · missing default.tsx = broken unmatched navigation · wrong dot count = interception silently never fires.

Continue Learning