Concept
The beginner framing: parallel routes let you render more than one page at the same time within a shared layout, think a dashboard showing team and analytics panels side by side, each navigable independently. Intercepting routes let a URL "borrow" a different page's content, like a photo opening in a modal over a feed, instead of navigating away entirely.
The precise mental model, parallel routes: a folder named @slotName defines a named slot, not a real URL segment, passed as a prop to the shared parent layout.
// app/layout.tsx receives slots as props, alongside the implicit `children` slot
export default function Layout({
children, // app/page.js is equivalent to app/@children/page.js
team, // from app/@team/
analytics, // from app/@analytics/
}: {
children: React.ReactNode;
team: React.ReactNode;
analytics: React.ReactNode;
}) {
return (
<>
{children}
{team}
{analytics}
</>
);
}Slots never appear in the URL, app/@analytics/views/page.js still resolves to /views, not /analytics/views. Because of this, you cannot have one slot statically rendered and another dynamic at the same level, if any slot is dynamic, the whole segment level must be.
default.tsx: the fallback for unmatched slots
Next.js tracks each slot's own active subpage independently. What actually renders in a slot depends on the navigation type:
- Soft navigation (client-side): a partial render changes only the matching slot's subpage, other slots keep showing their current active page, even if it no longer matches the URL.
- Hard navigation (full reload/refresh): Next.js can't recover which subpage a non-matching slot should show, so it renders that slot's
default.tsx, or a 404 if nodefault.tsxexists.
Intercepting routes: (.), (..), (..)(..), (...)
(.) matches segments at the SAME level
(..) matches one level ABOVE
(..)(..) matches two levels above
(...) matches from the ROOT app directoryThese are based on route segments, not filesystem folder depth, a @slot folder doesn't count as a level, so a path that's two filesystem directories deep but only one route-segment level up still uses (..), not (..)(..).
<Link href="/blog">Blog</Link>// enters the viewport...
Next.js automatically prefetches routes linked with <Link> as they enter the viewport, for a static route, the full route; for a dynamic one, a partial prefetch if loading.tsx exists.
Putting them together: a shareable modal
app/
feed/page.tsx // the main feed
photo/[id]/page.tsx // the STANDALONE photo page (full reload / shared URL)
@modal/
default.tsx // returns null, nothing shown when not intercepting
(.)photo/[id]/page.tsx // INTERCEPTS /photo/[id] when navigated via <Link> from feed
layout.tsx // renders {children} AND {modal}Clicking a photo from the feed (soft navigation) shows it in a modal, masking the URL as /photo/123 while staying visually on the feed. Sharing that exact URL, or refreshing the page (hard navigation), instead renders the full, standalone photo/[id]/page.tsx, no interception, no modal. This solves every classic modal problem at once: shareable URLs, refresh-safe context, and correct back/forward behavior, all driven by the router rather than client-only state.
Try It
Predict what happens before checking the solution.
app/
@analytics/
page-views/page.tsx
@team/
page.tsx
layout.tsx (renders {analytics} and {team})A user is on /page-views (soft-navigated into the @analytics slot from elsewhere), then hits a full browser refresh. What renders in the @analytics slot?
Solution
Next.js renders @analytics's default.tsx, or a 404 if no default.tsx exists in that slot, not page-views/page.tsx again. On a hard navigation (a refresh), Next.js cannot recover which subpage each slot was previously showing, since that active-slot state only lives in the client-side router's memory, not the URL itself. Only a soft, client-side navigation preserves a slot's active subpage across renders.
Implement It Yourself
Model the intercepting-route level-matching logic:
function resolveInterceptPrefix(currentSegmentDepth, targetSegmentDepth) {
const levelsUp = currentSegmentDepth - targetSegmentDepth;
if (levelsUp === 0) return "(.)";
if (levelsUp === 1) return "(..)";
if (levelsUp === 2) return "(..)(..)";
return "(...)"; // from the root app directory
}
// @modal is a SLOT, not a route segment, it doesn't count toward depth
resolveInterceptPrefix(/* feed segment depth */ 1, /* photo segment depth */ 1);
// "(.)", same route-segment level, even though @modal adds a filesystem folderThis captures the key subtlety: the matcher counts route segments, and a @slot folder is deliberately excluded from that count, which is exactly why (.)photo (not (..)photo) correctly intercepts a sibling route from inside a slot folder.
Under the Hood
Parallel routes are a direct extension of App Router Overview's children-prop composition, a slot is just another named prop passed to a layout, exactly like children is, just sourced from a differently-named folder instead of the nested route tree. And a slot's independent "which subpage is currently active" tracking is conceptually the same state-per-instance idea from Reconciliation, each slot maintains its own identity across renders, only losing that memory when a hard navigation forces Next.js to rebuild the tree from the URL alone, with no prior client-side history to consult.
Common Mistakes
1. Forgetting default.tsx for a slot used in a modal pattern
app/@modal/(.)login/page.tsx // the intercepted modal content
// ❌ missing app/@modal/default.tsxWithout a default.tsx returning null, navigating anywhere that doesn't match the modal's intercepted route causes Next.js to render a 404 for that slot instead of correctly showing nothing.
2. Mixing static and dynamic rendering across slots at the same level
Since all slots at a given level are combined into one final page, they cannot diverge on static-vs-dynamic rendering, if any slot needs dynamic rendering, every slot at that level does too.
3. Using the wrong intercept-level prefix by counting filesystem folders instead of route segments
app/@modal/(..)photo/[id]/page.tsx // ❌ if @modal and its sibling are at the SAME route-segment level, this should be (.), not (..)Since @slot folders don't count as a route-segment level, it's easy to over-count when reasoning by filesystem depth instead of segment depth, always count in terms of actual URL segments, ignoring any @slot wrapper folders along the way.
Best Practices
- Always provide
default.tsxfor every slot involved in conditional or modal-style rendering, so hard navigations degrade gracefully instead of 404ing. - Use the parallel-routes-plus-interception combo specifically for shareable, deep-linkable modals, photo viewers, login modals, shopping carts, where the underlying content also deserves its own standalone, refresh-safe URL.
- Keep the modal's content Server-Component-friendly by separating the
<Modal>shell (a Client Component managing open/close) from its children (which can then remain Server Components, per Server/Client Component Composition). - Close modals via
router.back()or a<Link>to a route matched by anull-returning slot, both correctly restore the URL and the slot's state.
Performance Tips
- Parallel routes render independently and can be streamed independently, each slot can have its own
loading.tsx/error.tsx, so one slow slot doesn't block the others from appearing. - Since intercepted routes reuse the surrounding layout entirely (no full reload), a modal opened this way is typically far faster to display than one that would otherwise require a full page navigation to its standalone route.
