Parallel Routes & Intercepting Routes
Building the Instagram Modal Pattern in Next.js
LinkedIn Hook
"I asked a senior developer to build a photo modal that opens over the feed — like Instagram. He built a custom modal system with state management, URL syncing, and back-button handling. It took 3 days."
The Next.js developer on the same team did it in 45 minutes. Zero state management. Zero custom history handling. The URL updates, the back button works, and sharing the link opens the photo in its own full page.
The secret? Two routing patterns that 90% of Next.js developers have never used: Parallel Routes and Intercepting Routes.
In Lesson 5.3, you'll learn how
@folderslots render multiple pages simultaneously, how(.)conventions intercept navigation to show modals over feeds, and how combining both creates the Instagram pattern — the single most impressive routing demo you can show in an interview.Read the full lesson -> [link]
#NextJS #ParallelRoutes #InterceptingRoutes #AppRouter #WebDevelopment #FrontendInterview #React #Routing
What You'll Learn
- How
@folderparallel routes let you render multiple independent page segments simultaneously - What
default.jsdoes and why your parallel routes break without it - How intercepting routes with
(.),(..),(...), and(..)(..)conventions hijack navigation to show modals - How to combine parallel routes + intercepting routes to build the Instagram modal pattern
- When these advanced patterns are the right tool vs. over-engineering
The Television Analogy — Picture-in-Picture and Channel Interception
Think of your Next.js layout as a television screen.
Normal routing is like watching one channel at a time. You navigate from the news to a movie — the entire screen changes. One URL, one page, one piece of content.
Parallel routes are like Picture-in-Picture (PiP). You split your TV screen into multiple independent panels. The main screen shows a sports game, a small panel in the corner shows a news ticker, and another panel shows a stock chart. Each panel has its own channel, its own content, and can change independently. If you switch the sports game to a movie, the news ticker and stock chart stay untouched. That is what @folder slots do — they let one URL render multiple independent page segments side by side.
Intercepting routes are like your TV's smart notification system. You're watching a movie, and a breaking news alert appears as an overlay. You didn't change the channel — the TV intercepted the news channel and showed it as a popup over your current content. If you press "Read Full Story," now the entire screen switches to the news channel. That is exactly what intercepting routes do — they catch a navigation that would normally take you to a full page, and instead show it as a modal or overlay on top of your current page.
Combine both? You get a TV with multiple PiP panels where each panel can have its own smart overlays. That is the Instagram pattern — a feed in the main area, and clicking a photo intercepts the navigation to show it as a modal, while the feed stays visible underneath.
Parallel Routes — The @folder Convention
Parallel routes allow you to simultaneously render one or more pages in the same layout. Each parallel route is defined by a named slot using the @folder convention.
The Mental Model
A normal layout renders one children slot — the page.tsx matching the current URL. Parallel routes let you define additional named slots that render alongside children, each with their own loading and error states.
Folder Structure
app/
dashboard/
layout.tsx // Receives @analytics, @team, and children as props
page.tsx // The default children slot (/dashboard)
@analytics/
page.tsx // Analytics panel (/dashboard)
loading.tsx // Independent loading state for analytics
@team/
page.tsx // Team panel (/dashboard)
error.tsx // Independent error boundary for team
Code Example — Dashboard with Multiple Panels
// app/dashboard/layout.tsx
// Each @folder becomes a prop in the layout
// "children" is the implicit slot (the page.tsx in the same directory)
export default function DashboardLayout({
children,
analytics,
team,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
team: React.ReactNode;
}) {
return (
<div>
<h1>Dashboard</h1>
{/* Main content area — this is the regular page.tsx */}
<div className="main-panel">
{children}
</div>
{/* These two panels render IN PARALLEL with the main content */}
<div className="side-panels">
<div className="analytics-panel">
{analytics}
</div>
<div className="team-panel">
{team}
</div>
</div>
</div>
);
}
// app/dashboard/page.tsx
// This is the "children" slot — the main dashboard content
export default function DashboardPage() {
return (
<div>
<h2>Overview</h2>
<p>Welcome to your dashboard. Here is your summary for today.</p>
</div>
);
}
// app/dashboard/@analytics/page.tsx
// This renders inside the "analytics" slot
// It has its own data fetching, loading, and error states
async function getAnalytics() {
const res = await fetch('https://api.example.com/analytics', {
cache: 'no-store',
});
return res.json();
}
export default async function AnalyticsPanel() {
const data = await getAnalytics();
return (
<div>
<h3>Analytics</h3>
<p>Page views today: {data.pageViews}</p>
<p>Bounce rate: {data.bounceRate}%</p>
<p>Avg session: {data.avgSession}s</p>
</div>
);
}
// app/dashboard/@team/page.tsx
// This renders inside the "team" slot — completely independent
async function getTeamActivity() {
const res = await fetch('https://api.example.com/team', {
cache: 'no-store',
});
return res.json();
}
export default async function TeamPanel() {
const activity = await getTeamActivity();
return (
<div>
<h3>Team Activity</h3>
<ul>
{activity.members.map((m: { id: string; name: string; status: string }) => (
<li key={m.id}>{m.name} — {m.status}</li>
))}
</ul>
</div>
);
}
The key insight: when you visit /dashboard, Next.js renders all three slots simultaneously. The analytics panel fetching slowly does not block the team panel or the main content. Each slot streams independently thanks to React Suspense under the hood.
Independent Loading and Error States
Each slot gets its own loading.tsx and error.tsx:
// app/dashboard/@analytics/loading.tsx
// Only the analytics panel shows a skeleton while loading
// The team panel and main content are already visible
export default function AnalyticsLoading() {
return (
<div className="animate-pulse">
<h3>Analytics</h3>
<div className="h-4 bg-gray-700 rounded w-3/4 mb-2" />
<div className="h-4 bg-gray-700 rounded w-1/2 mb-2" />
<div className="h-4 bg-gray-700 rounded w-2/3" />
</div>
);
}
// app/dashboard/@team/error.tsx
'use client'; // Error boundaries must be Client Components
export default function TeamError({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div>
<h3>Team Activity</h3>
<p>Failed to load team data: {error.message}</p>
{/* Retry only the team panel — analytics and main content are unaffected */}
<button onClick={() => reset()}>Retry</button>
</div>
);
}
The default.js File — Why Your Parallel Routes Break
This is the most common source of confusion with parallel routes, and interviewers test it specifically.
When you navigate to a sub-route that exists for children but not for a parallel slot, Next.js needs to know what to render in that slot. Without default.js, Next.js throws a 404.
The Problem
app/
dashboard/
layout.tsx // Uses children + @analytics + @team
page.tsx // children for /dashboard
settings/
page.tsx // children for /dashboard/settings
@analytics/
page.tsx // analytics for /dashboard
// NO settings/page.tsx — what renders for /dashboard/settings?
@team/
page.tsx // team for /dashboard
// NO settings/page.tsx — what renders for /dashboard/settings?
When the user navigates to /dashboard/settings:
childrenslot rendersdashboard/settings/page.tsx(exists)@analyticsslot has nosettings/page.tsx(problem!)@teamslot has nosettings/page.tsx(problem!)
On soft navigation (client-side via <Link>), Next.js keeps the previously rendered content for unmatched slots. The analytics and team panels stay as they were.
On hard navigation (full page reload, direct URL access), Next.js cannot recover the previous state. It looks for default.js in each unmatched slot. If it does not find one, it renders a 404.
The Solution
// app/dashboard/@analytics/default.tsx
// Fallback for the analytics slot when the current route
// does not have a matching page inside @analytics
export default function AnalyticsDefault() {
return null; // Render nothing — the slot is "empty" for this route
}
// You could also render a placeholder:
// export default function AnalyticsDefault() {
// return <p>Navigate to the dashboard overview to see analytics.</p>;
// }
// app/dashboard/@team/default.tsx
// Same pattern — provide a fallback for hard navigation
export default function TeamDefault() {
return null;
}
Rule of thumb: Always create a default.tsx in every @slot folder. Even if it just returns null, it prevents 404 errors on hard navigation.
Intercepting Routes — The (.) Convention
Intercepting routes allow you to load a route from another part of your application within the current layout. When the user clicks a link that would normally navigate to a new page, the intercepting route "catches" that navigation and displays the content in a different way — typically as a modal.
The Convention
| Pattern | Matches | Meaning |
|---|---|---|
(.) | Same level | Intercept a route at the same folder level |
(..) | One level up | Intercept a route one segment above |
(..)(..) | Two levels up | Intercept a route two segments above |
(...) | Root level | Intercept a route from the app root |
These match route segments, not filesystem directories. This distinction matters because @folder slots do not count as route segments.
How Interception Works
When a user clicks a link (soft navigation), the intercepting route takes over. The original page stays visible underneath, and the intercepted content appears as an overlay or modal.
When a user directly visits the URL (hard navigation — typing in the address bar, refreshing, or sharing the link), the interception does NOT happen. The full page at that route renders normally.
This is exactly how Instagram works:
- Click a photo in the feed -> modal opens over the feed (interception)
- Share the photo URL with a friend -> they see the full photo page (no interception)
- Refresh while the modal is open -> full photo page loads (no interception)
Simple Interception Example
app/
feed/
page.tsx // The photo feed (/feed)
(.)photo/
[id]/
page.tsx // Intercepted route — shows as modal
photo/
[id]/
page.tsx // Full page route — shows when directly accessed
// app/photo/[id]/page.tsx
// The FULL photo page — rendered on hard navigation (direct URL, refresh, shared link)
async function getPhoto(id: string) {
const res = await fetch(`https://api.example.com/photos/${id}`);
return res.json();
}
export default async function PhotoPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const photo = await getPhoto(id);
return (
<div className="photo-full-page">
<img src={photo.url} alt={photo.title} width={1200} height={800} />
<h1>{photo.title}</h1>
<p>{photo.description}</p>
<p>By {photo.author} | {photo.likes} likes</p>
<div className="comments">
{photo.comments.map((c: { id: string; text: string; user: string }) => (
<p key={c.id}><strong>{c.user}:</strong> {c.text}</p>
))}
</div>
</div>
);
}
// app/feed/(.)photo/[id]/page.tsx
// The INTERCEPTED route — rendered as a modal on soft navigation
// The (.) means "intercept the 'photo' route at the same level as 'feed'"
async function getPhoto(id: string) {
const res = await fetch(`https://api.example.com/photos/${id}`);
return res.json();
}
export default async function PhotoModal({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const photo = await getPhoto(id);
return (
<div className="modal-overlay">
<div className="modal-content">
<img src={photo.url} alt={photo.title} width={800} height={600} />
<h2>{photo.title}</h2>
<p>{photo.likes} likes</p>
</div>
</div>
);
}
// app/feed/page.tsx
import Link from 'next/link';
// The feed page — clicking a photo link triggers interception
export default function FeedPage() {
const photos = [
{ id: '1', thumbnail: '/thumb-1.jpg', title: 'Sunset' },
{ id: '2', thumbnail: '/thumb-2.jpg', title: 'Mountains' },
{ id: '3', thumbnail: '/thumb-3.jpg', title: 'City Lights' },
];
return (
<div className="photo-grid">
{photos.map((photo) => (
// This Link navigates to /photo/1, /photo/2, etc.
// But (.)photo/[id] intercepts it and shows a modal instead
<Link key={photo.id} href={`/photo/${photo.id}`}>
<img src={photo.thumbnail} alt={photo.title} />
</Link>
))}
</div>
);
}
// User clicks photo "Sunset":
// - URL changes to /photo/1
// - Feed stays visible underneath
// - Modal appears with the photo
// - Back button closes the modal and goes back to /feed
// User shares /photo/1 with a friend:
// - Friend opens the link
// - Full photo page renders (no modal, no feed underneath)
The Instagram Pattern — Combining Parallel Routes + Intercepting Routes
The real power emerges when you combine both patterns. Parallel routes give you a named slot to render the modal, and intercepting routes control when that slot shows a modal vs. nothing.
Full Folder Structure
app/
layout.tsx
@modal/
(.)photo/
[id]/
page.tsx // Modal content (intercepted route)
default.tsx // Returns null — no modal by default
feed/
page.tsx // Photo grid
layout.tsx
photo/
[id]/
page.tsx // Full photo page (direct access)
Code — The Complete Pattern
// app/layout.tsx
// The root layout receives the @modal slot
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{children}
{/* The modal slot renders on top of the current page */}
{/* When no interception is active, default.tsx returns null */}
{modal}
</body>
</html>
);
}
// app/@modal/default.tsx
// When no interception is happening, the modal slot is empty
export default function ModalDefault() {
return null;
}
// app/@modal/(.)photo/[id]/page.tsx
// This is the intercepted modal version of /photo/[id]
// Rendered in the @modal slot when the user clicks a photo link
import { Modal } from '@/components/Modal';
async function getPhoto(id: string) {
const res = await fetch(`https://api.example.com/photos/${id}`);
return res.json();
}
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} width={800} height={600} />
<div className="photo-details">
<h2>{photo.title}</h2>
<p>By {photo.author}</p>
<p>{photo.likes} likes</p>
</div>
</Modal>
);
}
// components/Modal.tsx
'use client';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useRef } from 'react';
// Reusable modal component with backdrop close and Escape key handling
export function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter();
const overlayRef = useRef<HTMLDivElement>(null);
// Close the modal by navigating back — restores the previous URL
const onDismiss = useCallback(() => {
router.back();
}, [router]);
// Close on Escape key
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onDismiss();
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [onDismiss]);
// Close when clicking the backdrop (outside the modal content)
const handleOverlayClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === overlayRef.current) onDismiss();
},
[onDismiss]
);
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}}
>
<div style={{
background: '#1a1a2e',
borderRadius: '12px',
padding: '24px',
maxWidth: '800px',
width: '90%',
maxHeight: '90vh',
overflow: 'auto',
}}>
<button
onClick={onDismiss}
style={{ float: 'right', cursor: 'pointer' }}
>
Close
</button>
{children}
</div>
</div>
);
}
// app/photo/[id]/page.tsx
// Full photo page — rendered on hard navigation (direct link, refresh)
// This is the NON-intercepted version
async function getPhoto(id: string) {
const res = await fetch(`https://api.example.com/photos/${id}`);
return res.json();
}
export default async function PhotoPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const photo = await getPhoto(id);
return (
<main className="full-photo-page">
<img src={photo.url} alt={photo.title} width={1200} height={800} />
<h1>{photo.title}</h1>
<p>By {photo.author}</p>
<p>{photo.description}</p>
<p>{photo.likes} likes | {photo.comments.length} comments</p>
<section>
<h2>Comments</h2>
{photo.comments.map((c: { id: string; text: string; user: string }) => (
<div key={c.id}>
<strong>{c.user}:</strong> {c.text}
</div>
))}
</section>
</main>
);
}
The Flow
User on /feed:
- children: FeedPage (photo grid)
- @modal: null (default.tsx)
User clicks a photo (soft navigation to /photo/3):
- URL changes to /photo/3
- children: FeedPage STAYS (feed is still visible underneath)
- @modal: PhotoModal renders (intercepted route shows modal)
User presses Back:
- URL changes to /feed
- children: FeedPage (unchanged)
- @modal: null (default.tsx again)
User directly visits /photo/3 (hard navigation):
- No interception happens
- Full PhotoPage renders as a standalone page
- No modal, no feed underneath
Conditional Parallel Routes — Beyond Dashboards
Parallel routes are not just for dashboards. You can conditionally render slots based on authentication or user roles:
// app/layout.tsx
import { auth } from '@/lib/auth';
export default async function RootLayout({
children,
admin,
user,
}: {
children: React.ReactNode;
admin: React.ReactNode;
user: React.ReactNode;
}) {
const session = await auth();
const isAdmin = session?.user?.role === 'admin';
return (
<html lang="en">
<body>
{children}
{/* Show different panels based on the user's role */}
{isAdmin ? admin : user}
</body>
</html>
);
}
This is a powerful pattern because both the @admin and @user slots are server-rendered. There is no client-side conditional rendering — the decision happens on the server, and only the relevant slot's HTML is sent to the browser.
Understanding the (.) Segment Matching
The intercepting route convention matches route segments, not filesystem paths. This is a critical distinction because @folder slots are invisible to the routing system.
Route segments for /photo/[id]: photo -> [id] (2 segments from root)
Route segments for /feed: feed (1 segment from root)
From inside @modal (which is at root level, slots don't count as segments):
(.)photo -> intercepts /photo (one segment from current position)
(..)photo -> would go up one segment, then match photo
(...)photo -> intercepts /photo from the root (useful when deeply nested)
When to use each:
| Convention | Use When |
|---|---|
(.) | Intercepting a sibling route at the same level |
(..) | Intercepting a route one level above your current segment |
(..)(..) | Intercepting a route two levels above |
(...) | Intercepting any route from the app root — the "catch anything" escape hatch |
Common Mistakes
-
Forgetting
default.tsxin parallel route slots. This is the number one cause of mysterious 404 errors with parallel routes. On hard navigation (refresh, direct URL), Next.js cannot recover the previous slot state. Withoutdefault.tsx, unmatched slots throw a 404. Always adddefault.tsxto every@slotfolder, even if it returnsnull. -
Confusing filesystem paths with route segments in intercepting routes. The
(.),(..)conventions match against route segments, not the folder hierarchy on disk. Since@folderslots are not route segments, they do not count when calculating the relative path. Developers often use(..)when they should use(.)because they count the@modalfolder as a segment. -
Expecting interception on hard navigation. Intercepting routes only work on soft navigation (client-side
<Link>clicks orrouter.push()). If the user refreshes the page, types the URL directly, or receives a shared link, the full page at that URL renders — no interception. This is by design and is actually a feature, not a bug. It means shared links always show the full content. -
Overusing parallel routes for simple conditional rendering. If you just need to show/hide a section based on a prop, a regular conditional in your component is simpler. Parallel routes shine when slots need independent loading states, error boundaries, or when each slot maps to a different sub-route. Do not use
@folderslots just because you have two<div>sections on a page.
Interview Questions
1. What are parallel routes in Next.js, and how do you define them?
(Covered in the @folder Convention section above — named slots using @folder directories that render simultaneously in a shared layout.)
2. What is default.js in the context of parallel routes, and why is it necessary?
(Covered in the default.js section above — it provides a fallback for slots that do not have a matching page for the current URL, preventing 404 errors on hard navigation.)
3. Explain the difference between (.), (..), (..)(..), and (...) in intercepting routes.
(Covered in the Segment Matching section above — they match route segments at the same level, one level up, two levels up, and from the root respectively.)
4. Why do intercepting routes only work on soft navigation? What happens on hard navigation, and why is that actually a good design?
Intercepting routes rely on the client-side router's ability to keep the current page rendered while overlaying the intercepted content. On hard navigation (page refresh, direct URL, shared link), there is no "current page" to overlay on — the browser starts fresh. So Next.js renders the full page at that URL instead. This is a good design because it means every URL has a meaningful standalone page. When someone shares
/photo/123, the recipient sees the full photo page with all details and comments — not a broken modal floating over nothing.
5. Walk me through the folder structure and data flow for building an Instagram-style modal pattern in Next.js.
(Covered in the Instagram Pattern section above — combine @modal parallel slot with (.)photo/[id] intercepting route, default.tsx returning null, and router.back() for dismissal.)
Quick Reference -- Cheat Sheet
| Concept | Key Point |
|---|---|
| Parallel routes | @folder slots render multiple pages simultaneously in one layout |
| Slot prop | Each @folder becomes a prop in the parent layout.tsx |
children slot | The implicit slot — maps to page.tsx in the same directory |
default.tsx | Fallback for unmatched slots on hard navigation — always add one |
(.) | Intercept route at same level |
(..) | Intercept route one segment up |
(...) | Intercept route from app root |
| Soft nav | Interception works — modal over current page |
| Hard nav | Interception skipped — full page renders |
| Instagram pattern | @modal parallel slot + (.) intercepting route |
router.back() | Dismiss modal by navigating back |
+------------------------------------------------------------------+
| Parallel + Intercepting Routes Mental Model |
+------------------------------------------------------------------+
| |
| PARALLEL ROUTES (@folder): |
| layout.tsx receives { children, @slotA, @slotB } |
| Each slot = independent loading / error / streaming |
| Always add default.tsx in every @slot |
| |
| INTERCEPTING ROUTES ((.) convention): |
| Soft nav -> intercepted route renders (modal) |
| Hard nav -> original route renders (full page) |
| Same URL -> two different experiences |
| |
| COMBINED (Instagram pattern): |
| |
| app/ |
| layout.tsx -> { children, modal } |
| @modal/ |
| default.tsx -> null (no modal) |
| (.)photo/[id]/ |
| page.tsx -> <Modal> with photo (intercepted) |
| photo/[id]/ |
| page.tsx -> full photo page (direct access) |
| feed/ |
| page.tsx -> photo grid with <Link> to /photo/[id] |
| |
| Click photo -> feed stays, modal appears, URL = /photo/3 |
| Share URL -> full photo page, no modal, no feed |
| Back button -> modal closes, URL = /feed |
| |
+------------------------------------------------------------------+
Previous: Lesson 5.2 -- Layouts & Templates Next: Lesson 5.4 -- Middleware
This is Lesson 5.3 of the Next.js Interview Prep Course -- 8 chapters, 33 lessons.