
Mastering Advanced Next.js Routing in 2025: Parallel and Intercepted Routes Explained
If you've been building with Next.js and sticking to simple routes, you're missing out on one of the framework's most powerful features. Parallel routes and intercepted routes aren't just nice-to-haves—they're the foundation of modern, sophisticated user experiences that feel fluid and responsive.
Think about the last time you scrolled through Instagram or Twitter and a modal popped up for a photo or tweet. You could interact with it, close it, and the feed stayed exactly where you left it. That's intercepted routes at work. Or imagine a dashboard where you view different analytics panels simultaneously without page refreshes—that's parallel routes making it happen.
These advanced routing patterns sound complex, but once you understand the core concepts, they become incredibly practical tools for building applications that just feel right to users. This comprehensive guide walks you through everything you need to know to implement them in production applications.
Why Advanced Routing Matters in 2025
We're in an era where users expect web applications to behave like native apps. They don't want to wait for page reloads. They expect modals to open instantly, data to load in the background, and navigation to feel seamless. Basic routing—where clicking a link reloads the page—simply doesn't cut it anymore.
Next.js parallel and intercepted routes are built specifically to solve these modern UX challenges. They enable you to:
- Display multiple views simultaneously without nested layouts
- Intercept navigation and replace routes dynamically
- Keep UI state while users navigate
- Create sophisticated patterns like modal overlays, sidebars, and multi-panel dashboards
- Improve performance by rendering multiple segments in parallel
The best part? These features are baked into Next.js App Router, so there's no external library or complex setup required. You just need to understand how they work.
Understanding Routing Fundamentals in Next.js App Router
Before diving into parallel and intercepted routes, let's establish solid foundations. Next.js App Router uses a file-system-based routing approach where your folder structure directly maps to URLs.
app/
├── page.js → /
├── about/
│ └── page.js → /about
├── blog/
│ └── [slug]/
│ └── page.js → /blog/:slug
└── dashboard/
├── layout.js
└── page.js → /dashboard
This structure is intuitive, but basic routing has limitations. When a user navigates from /blog/post-1 to /blog/post-2, the entire page re-renders. Any UI state in the parent component resets. This is where advanced routing comes in.
For a deeper dive into Next.js routing challenges and solutions, check out our detailed guide on fixing pagination and infinite scroll in App Router, which covers related navigation patterns and data management challenges.
What Are Parallel Routes?
Parallel routes let you render multiple views in the same layout simultaneously. Instead of having a single page.js file that determines what displays, you can have multiple route segments that load and render independently.
Think of it like this: on a typical website, you have one content area. Parallel routes give you multiple content areas that can load different content at different speeds, all on the same page.
Visual Example
Dashboard (Layout)
├── Analytics Panel (Slot 1)
├── User Stats Panel (Slot 2)
└── Recent Activity Panel (Slot 3)
All three panels load simultaneously, and if one takes longer, the others display immediately. The user sees content appearing progressively rather than waiting for everything to load.
File Structure for Parallel Routes
Parallel routes use a special syntax with @ symbols:
app/
├── layout.js
├── page.js
├── @analytics/
│ ├── page.js
│ └── loading.js
├── @userStats/
│ ├── page.js
│ └── loading.js
└── @recentActivity/
├── page.js
└── loading.js
Each @slotName folder represents a different parallel route segment. They're called "slots" because they define separate regions in your layout.
Implementing Your First Parallel Route
Here's how to build a dashboard with parallel routes:
// app/dashboard/layout.js
import React from 'react';
export default function DashboardLayout({ children, analytics, userStats, recentActivity }) {
return (
<div className="dashboard-container">
<aside className="sidebar">
<h1>Dashboard</h1>
<nav>
<a href="/dashboard">Overview</a>
<a href="/dashboard/settings">Settings</a>
</nav>
</aside>
<main className="dashboard-main">
{/* Default page content */}
{children}
</main>
<div className="dashboard-grid">
<section className="analytics-panel">
{analytics}
</section>
<section className="stats-panel">
{userStats}
</section>
<section className="activity-panel">
{recentActivity}
</section>
</div>
</div>
);
}The layout receives props for each slot. These props automatically contain the rendered components from the corresponding slot files.
Now create the slot components:
// app/dashboard/@analytics/page.js
import { AnalyticsChart } from '@/components/AnalyticsChart';
export default function AnalyticsSlot() {
return (
<div className="analytics-slot">
<h2>Analytics</h2>
<AnalyticsChart />
</div>
);
}
// app/dashboard/@userStats/page.js
import { UserStatsWidget } from '@/components/UserStatsWidget';
export default function UserStatsSlot() {
return (
<div className="stats-slot">
<h2>User Statistics</h2>
<UserStatsWidget />
</div>
);
}
// app/dashboard/@recentActivity/page.js
import { ActivityFeed } from '@/components/ActivityFeed';
export default function RecentActivitySlot() {
return (
<div className="activity-slot">
<h2>Recent Activity</h2>
<ActivityFeed />
</div>
);
}Add loading states for each slot:
// app/dashboard/@analytics/loading.js
export default function AnalyticsLoading() {
return (
<div className="analytics-slot">
<h2>Analytics</h2>
<div className="skeleton">
<div className="skeleton-bar" />
<div className="skeleton-bar" />
<div className="skeleton-bar" />
</div>
</div>
);
}
// app/dashboard/@userStats/loading.js
export default function UserStatsLoading() {
return (
<div className="stats-slot">
<h2>User Statistics</h2>
<div className="skeleton">Loading...</div>
</div>
);
}
// app/dashboard/@recentActivity/loading.js
export default function ActivityLoading() {
return (
<div className="activity-slot">
<h2>Recent Activity</h2>
<div className="skeleton">Loading...</div>
</div>
);
}With this setup, when a user navigates to /dashboard, all three slots load in parallel. The analytics panel might load first, the user stats second, and recent activity third—but they all load at the same time, giving the appearance of a snappy, responsive interface.
/* app/globals.css */
.dashboard-container {
display: grid;
grid-template-columns: 200px 1fr;
min-height: 100vh;
gap: 20px;
padding: 20px;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
.analytics-panel,
.stats-panel,
.activity-panel {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.skeleton {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}Intercepted Routes: Powerful Route Interception
Intercepted routes let you intercept navigation to a route and display different content based on where the navigation came from. This is the magic behind Instagram's modal photos or Twitter's expanded tweets.
The Problem Intercepted Routes Solve
Imagine a photo gallery. When you click a photo on the gallery page, you want to see it in a modal overlay. But you also want users to be able to navigate directly to /photo/123 and see a full-page view. Without intercepted routes, you'd need complex conditional rendering logic. With them, it's automatic.
File Structure for Intercepted Routes
Intercepted routes use parentheses with special modifiers:
app/
├── photos/
│ └── page.js → /photos (gallery view)
├── (.)photo/
│ └── [id]/
│ └── page.js → /photos/[id] (intercepted as modal)
└── photo/
└── [id]/
└── page.js → /photo/[id] (full page)
The (.) prefix means "intercept one level up." There are other modifiers:
(.)- intercept one level up(..)- intercept two levels up(...)- intercept from root(..)(..)- intercept two levels up from root
Building an Image Gallery with Intercepted Routes
Let's build a photo gallery with modal interception:
// app/photos/page.js
'use client';
import Link from 'next/link';
import { useState, useEffect } from 'react';
export default function PhotosGallery() {
const [photos, setPhotos] = useState([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Fetch photos from API
fetch('/api/photos')
.then(res => res.json())
.then(data => {
setPhotos(data);
setIsLoading(false);
});
}, []);
if (isLoading) {
return (
<div className="gallery-container">
<div className="gallery-skeleton">
{[...Array(12)].map((_, i) => (
<div key={i} className="photo-skeleton" />
))}
</div>
</div>
);
}
return (
<div className="gallery-container">
<h1>Photo Gallery</h1>
<div className="gallery-grid">
{photos.map(photo => (
<Link
key={photo.id}
href={`/photos/${photo.id}`}
className="photo-link"
>
<img
src={photo.thumbnail}
alt={photo.title}
className="photo-thumbnail"
/>
<p className="photo-title">{photo.title}</p>
</Link>
))}
</div>
</div>
);
}Now create the intercepted modal component:
// app/(.)photo/[id]/page.js
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Modal from '@/components/Modal';
export default function PhotoModal({ params }) {
const [photo, setPhoto] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
useEffect(() => {
// Fetch photo details
fetch(`/api/photos/${params.id}`)
.then(res => res.json())
.then(data => {
setPhoto(data);
setIsLoading(false);
});
}, [params.id]);
const handleClose = () => {
// Navigate back to gallery
router.back();
};
if (isLoading) {
return (
<Modal isOpen onClose={handleClose}>
<div className="modal-loading">Loading photo...</div>
</Modal>
);
}
return (
<Modal isOpen onClose={handleClose}>
<div className="modal-content">
<img
src={photo.fullImage}
alt={photo.title}
className="modal-image"
/>
<h2>{photo.title}</h2>
<p>{photo.description}</p>
<div className="photo-meta">
<span>By {photo.photographer}</span>
<span>{new Date(photo.date).toLocaleDateString()}</span>
</div>
</div>
</Modal>
);
}Create a reusable Modal component:
// components/Modal.js
'use client';
import { useEffect } from 'react';
export default function Modal({ isOpen, onClose, children }) {
useEffect(() => {
const handleEscape = (e) => {
if (e.key === 'Escape') {
onClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = 'unset';
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div
className="modal-box"
onClick={(e) => e.stopPropagation()}
>
<button
className="modal-close"
onClick={onClose}
aria-label="Close modal"
>
✕
</button>
{children}
</div>
</div>
);
}Create the full-page view for direct navigation:
// app/photo/[id]/page.js
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
export default function PhotoPage({ params }) {
const [photo, setPhoto] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetch(`/api/photos/${params.id}`)
.then(res => res.json())
.then(data => {
setPhoto(data);
setIsLoading(false);
});
}, [params.id]);
if (isLoading) {
return <div className="page-loading">Loading photo...</div>;
}
return (
<div className="photo-page">
<Link href="/photos" className="back-link">
← Back to Gallery
</Link>
<article className="photo-article">
<img
src={photo.fullImage}
alt={photo.title}
className="page-image"
/>
<h1>{photo.title}</h1>
<p className="photo-description">{photo.description}</p>
<div className="photo-details">
<div className="detail-item">
<span className="label">Photographer:</span>
<span className="value">{photo.photographer}</span>
</div>
<div className="detail-item">
<span className="label">Date:</span>
<span className="value">{new Date(photo.date).toLocaleDateString()}</span>
</div>
<div className="detail-item">
<span className="label">Location:</span>
<span className="value">{photo.location}</span>
</div>
</div>
</article>
</div>
);
}Add styles for the modal interaction:
/* app/globals.css */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal-box {
background: white;
border-radius: 12px;
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
position: relative;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.modal-close {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
z-index: 1001;
color: #666;
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
margin-top: 30px;
}
.photo-thumbnail {
width: 100%;
height: 200px;
object-fit: cover;
border-radius: 8px;
cursor: pointer;
transition: transform 0.3s ease;
}
.photo-thumbnail:hover {
transform: scale(1.05);
}Now here's the magic: when users click a photo on the gallery page, the intercepted route intercepts the navigation and shows the modal overlay. But if they navigate directly to /photo/123 or refresh the page, they see the full-page view. Same component logic, two different user experiences.
Real-World Advanced Patterns: Beyond the Basics
Now that you understand the fundamentals, let's explore sophisticated patterns that solve common real-world problems.
Pattern 1: Stacked Modals with Intercepted Routes
// app/(.)product/[id]/page.js - First modal
'use client';
export default function ProductModal({ params }) {
return (
<Modal>
<ProductDetails id={params.id} />
<Link href={`/product/${params.id}/reviews`}>View Reviews</Link>
</Modal>
);
}
// app/(.)product/[id]/(.)reviews/page.js - Stacked on top
'use client';
export default function ReviewsModal({ params }) {
return (
<Modal>
<ProductReviews id={params.id} />
</Modal>
);
}This creates a breadcrumb-like modal stacking where /product/123/reviews opens the reviews modal, but /product/123 still shows the product modal. Users can navigate backward through the stack naturally.
Pattern 2: Conditional Parallel Routes
// app/dashboard/layout.js
export default function DashboardLayout({
children,
sidePanel,
notifications
}) {
const [sidebarOpen, setSidebarOpen] = useState(true);
return (
<div className="dashboard">
<main>{children}</main>
{sidebarOpen && (
<aside className="side-panel">
{sidePanel}
</aside>
)}
<div className="notification-area">
{notifications}
</div>
</div>
);
}Different UI states can show/hide parallel routes dynamically.
Pattern 3: Data Sharing Between Parallel Routes
// app/dashboard/layout.js
'use client';
import { createContext, useState } from 'react';
export const DashboardContext = createContext();
export default function DashboardLayout({ children, analytics, stats }) {
const [selectedTimeframe, setSelectedTimeframe] = useState('month');
return (
<DashboardContext.Provider value={{ selectedTimeframe, setSelectedTimeframe }}>
<div className="dashboard">
<Timeframe />
<main>{children}</main>
<aside>{analytics}</aside>
<aside>{stats}</aside>
</div>
</DashboardContext.Provider>
);
}
// app/dashboard/@analytics/page.js
'use client';
import { useContext } from 'react';
import { DashboardContext } from '../layout';
export default function Analytics() {
const { selectedTimeframe } = useContext(DashboardContext);
// Fetch analytics based on context
return <AnalyticsChart timeframe={selectedTimeframe} />;
}Combining Parallel and Intercepted Routes for Maximum Power
The real magic happens when you combine both patterns:
app/
├── layout.js
├── page.js
├── (.)modal/ # Intercepted modal slots
│ ├── [type]/
│ │ └── @content/ # Parallel route inside intercepted
│ │ └── page.js
│ └── @overlay/
│ └── page.js
├── @sidebar/ # Parallel route
│ └── page.js
└── dashboard/
├── layout.js
├── page.js
└── @notifications/ # Nested parallel route
└── page.js
This creates incredibly sophisticated routing architectures where modals can contain parallel content, parallel routes can contain intercepted routes, and the combinations are endless.
Performance Optimization with Advanced Routing
When using parallel and intercepted routes, performance becomes critical. Here are optimization strategies:
1. Lazy Load Parallel Routes
// app/dashboard/layout.js
import dynamic from 'next/dynamic';
const Analytics = dynamic(() => import('./analytics'), { loading: () => <p>Loading...</p> });
const Stats = dynamic(() => import('./stats'), { loading: () => <p>Loading...</p> });
export default function DashboardLayout({ children, analytics, stats }) {
return (
<div className="dashboard">
{children}
<Suspense fallback={<p>Loading analytics...</p>}>
{analytics}
</Suspense>
</div>
);
}2. Cache Strategies for Intercepted Routes
// app/(.)photo/[id]/page.js
import { cache } from 'react';
const fetchPhoto = cache(async (id) => {
const res = await fetch(`https://api.example.com/photos/${id}`, {
next: { revalidate: 3600 } // Cache for 1 hour
});
return res.json();
});
export default async function PhotoModal({ params }) {
const photo = await fetchPhoto(params.id);
return <Modal>{/* ... */}</Modal>;
}3. Implement Request Deduplication
// lib/cache.js
const requestCache = new Map();
export async function cachedFetch(url, options = {}) {
if (requestCache.has(url)) {
return requestCache.get(url);
}
const promise = fetch(url, options).then(r => r.json());
requestCache.set(url, promise);
return promise;
}Debugging Common Issues with Advanced Routing
Issue 1: Intercepted Route Not Triggering
Problem: You click a link, but the intercepted route doesn't show.
Solution: Ensure your file structure matches the interception pattern exactly. If you're intercepting from /photos, the folder must be (.)photo, not (.)/photo.
// ❌ Wrong
app/(.)photo/[id]/page.js // This won't work
// ✅ Correct
app/\(.\)photo/[id]/page.js // In filesystem: (.)photo/[id]/page.jsIssue 2: Parallel Routes Not Loading Independently
Problem: One slow parallel route blocks all others from displaying.
Solution: Use Suspense boundaries and loading states for each slot:
// app/dashboard/layout.js
import { Suspense } from 'react';
export default function DashboardLayout({ analytics, stats }) {
return (
<div className="dashboard">
<Suspense fallback={<AnalyticsLoading />}>
{analytics}
</Suspense>
<Suspense fallback={<StatsLoading />}>
{stats}
</Suspense>
</div>
);
}Issue 3: Hydration Mismatch with Intercepted Routes
Problem: "Hydration failed" error when using intercepted routes with dynamic content.
Solution: For detailed solutions to hydration issues in Next.js, check our comprehensive guide on handling client-side storage in App Router, which covers related hydration challenges and their resolutions.
SEO Considerations for Advanced Routing
Advanced routing shouldn't hurt your SEO. Here's how to maintain search visibility:
1. Ensure Full-Page Views Exist
Always provide a full-page route alongside intercepted routes:
/photos → Gallery
/photos/1 → Intercepted as modal (if coming from gallery)
/photo/1 → Full page view (always accessible)
This ensures search engines can crawl and index full-page versions.
2. Set Proper Meta Tags
// app/photo/[id]/page.js
export async function generateMetadata({ params }) {
const photo = await fetch(`https://api.example.com/photos/${params.id}`);
const data = await photo.json();
return {
title: data.title,
description: data.description,
openGraph: {
images: [data.fullImage],
title: data.title,
description: data.description,
}
};
}3. Use Canonical URLs
// components/Head.js
export default function Head() {
return (
<>
<link rel="canonical" href="https://example.com/photo/1" />
</>
);
}Connecting Advanced Routing to Your Full Application
Advanced routing becomes powerful when integrated with your entire application architecture. Consider how pagination with advanced routing works:
For advanced pagination implementations, see our detailed guide on handling pagination and infinite scroll in App Router, which shows how to combine these patterns.
Additionally, mobile responsiveness is critical for applications using advanced routing. Users on mobile devices need smooth modal interactions and proper layout adaptation. Learn more in our article on building mobile-responsive applications.
If you're implementing search functionality alongside advanced routing, check out our guide on implementing search in MDX blogs using Next.js App Router.
For building more sophisticated features, you might benefit from modern development tools discussed in our article on AI in web development, which covers emerging tools that can help automate complex routing implementations.
Best Practices Summary
Do's ✅
- Do use parallel routes for independent content sections that load at different speeds
- Do use intercepted routes for modal overlays and alternative views
- Do provide loading states for each parallel route or intercepted segment
- Do test on mobile to ensure responsive modal and layout behavior
- Do create full-page fallbacks for intercepted routes to maintain SEO
- Do combine with Suspense for granular loading control
- Do document your routing architecture with clear comments explaining the pattern
Don'ts ❌
- Don't nest parallel routes more than 2-3 levels deep (complexity increases exponentially)
- Don't make intercepted routes dependent on complex state management without testing
- Don't forget error boundaries around parallel routes (one crash shouldn't break everything)
- Don't ignore accessibility when implementing modals and overlays
- Don't assume mobile works if you only tested on desktop
- Don't over-cache intercepted routes (users expect fresh data)
- Don't create more than 5-6 parallel routes in a single layout (performance degrades)
Advanced Configuration: Taking It Further
Custom Route Interceptors
// lib/routeInterceptor.js
export function useRouteInterceptor(pattern, handler) {
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (pathname.match(pattern)) {
handler(pathname);
}
}, [pathname, pattern, handler]);
}
// Usage:
useRouteInterceptor(/\/photos\/\d+/, (path) => {
console.log('Photo route detected:', path);
});Dynamic Slot Resolution
// app/dashboard/layout.js
export default function DashboardLayout({
children,
...slots
}) {
// Dynamically render all slots
return (
<div className="dashboard">
{children}
{Object.entries(slots).map(([key, component]) => (
<div key={key} data-slot={key}>
{component}
</div>
))}
</div>
);
}Troubleshooting Checklist
When advanced routing isn't working:
- Check folder naming - Parentheses and brackets must be exact
- Verify file exports - All page.js files must have default exports
- Test with console logs - Add logs to see which component renders
- Clear Next.js cache - Delete
.nextfolder and rebuild - Check route precedence - More specific routes should be before general ones
- Verify API calls - Ensure API endpoints return expected data
- Test hydration - Use client-side checks to prevent mismatches
- Review dependency arrays - Ensure useEffect hooks have correct dependencies
Real-World Application: E-Commerce Product Explorer
Let's build a complete e-commerce example combining everything:
// app/products/layout.js
import { Suspense } from 'react';
export default function ProductsLayout({
children,
filters,
recommendations
}) {
return (
<div className="products-container">
<aside className="products-sidebar">
<h2>Filters</h2>
<Suspense fallback={<div>Loading filters...</div>}>
{filters}
</Suspense>
</aside>
<main className="products-main">
<Suspense fallback={<div>Loading products...</div>}>
{children}
</Suspense>
</main>
<aside className="products-recommendations">
<h2>Recommended For You</h2>
<Suspense fallback={<div>Loading recommendations...</div>}>
{recommendations}
</Suspense>
</aside>
</div>
);
}
// app/products/@filters/page.js
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback } from 'react';
export default function FiltersSlot() {
const router = useRouter();
const searchParams = useSearchParams();
const updateFilter = useCallback((filterName, value) => {
const params = new URLSearchParams(searchParams);
params.set(filterName, value);
router.push(`?${params.toString()}`);
}, [router, searchParams]);
return (
<div className="filters">
<div className="filter-group">
<h3>Price Range</h3>
<input
type="range"
min="0"
max="1000"
onChange={(e) => updateFilter('maxPrice', e.target.value)}
/>
</div>
<div className="filter-group">
<h3>Category</h3>
{['Electronics', 'Clothing', 'Books', 'Home'].map(cat => (
<label key={cat}>
<input
type="checkbox"
onChange={(e) => updateFilter('category', cat)}
/>
{cat}
</label>
))}
</div>
</div>
);
}
// app/products/@recommendations/page.js
import { getRecommendations } from '@/lib/products';
export default async function RecommendationsSlot() {
const recommendations = await getRecommendations();
return (
<div className="recommendations">
{recommendations.map(product => (
<div key={product.id} className="recommendation-card">
<img src={product.image} alt={product.name} />
<h4>{product.name}</h4>
<p className="price">${product.price}</p>
</div>
))}
</div>
);
}
// app/products/page.js
'use client';
import { useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import Link from 'next/link';
export default function ProductsPage() {
const searchParams = useSearchParams();
const [products, setProducts] = useState([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Build query string from filters
const queryString = searchParams.toString();
fetch(`/api/products?${queryString}`)
.then(res => res.json())
.then(data => {
setProducts(data);
setIsLoading(false);
});
}, [searchParams]);
if (isLoading) {
return <div className="products-grid-skeleton">Loading...</div>;
}
return (
<div className="products-grid">
{products.map(product => (
<Link
key={product.id}
href={`/products/${product.id}`}
>
<div className="product-card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>{product.description}</p>
<div className="product-footer">
<span className="price">${product.price}</span>
<span className="rating">⭐ {product.rating}</span>
</div>
</div>
</Link>
))}
</div>
);
}
// app/(.)products/[id]/page.js
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Modal from '@/components/Modal';
export default function ProductModal({ params }) {
const [product, setProduct] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
useEffect(() => {
fetch(`/api/products/${params.id}`)
.then(res => res.json())
.then(data => {
setProduct(data);
setIsLoading(false);
});
}, [params.id]);
return (
<Modal
isOpen
onClose={() => router.back()}
>
{isLoading ? (
<div>Loading product details...</div>
) : (
<div className="product-modal">
<img src={product.fullImage} alt={product.name} />
<h2>{product.name}</h2>
<p className="description">{product.description}</p>
<p className="price">${product.price}</p>
<button className="add-to-cart">Add to Cart</button>
</div>
)}
</Modal>
);
}Conclusion: Mastering Modern Next.js Routing
Parallel routes and intercepted routes represent the evolution of web application architecture. They solve real problems that developers face every day: displaying multiple independent content sections, creating sophisticated modal interactions, and building applications that feel responsive and modern.
The patterns you've learned in this guide—from basic parallel routes to complex stacked modals—form the foundation for building world-class applications with Next.js. These aren't experimental features or nice-to-haves. They're essential tools for modern web development.
The key takeaways:
Parallel routes solve the problem of rendering multiple independent segments simultaneously. Use them when you have UI sections that load independently and don't depend on each other.
Intercepted routes solve the problem of conditional rendering based on navigation origin. Use them for modals, overlays, and alternative views where the same content should display differently depending on context.
Combined together, they enable sophisticated patterns like stacked modals within dashboards, dynamic filtering with parallel recommendations, and applications that feel native.
Start implementing these patterns in your projects. Begin with simpler use cases—a product gallery with modal, a dashboard with multiple panels—and gradually build toward more complex architectures. Test thoroughly on mobile devices, monitor performance, and maintain clear documentation of your routing structure.
Your users will immediately notice the difference. They'll experience faster page loads, smoother interactions, and an application that feels intelligent and responsive. That's the power of mastering advanced Next.js routing.
The future of web applications is built on these patterns. Master them now, and you'll be ahead of the curve in 2025 and beyond.

Malik Saqib
I craft short, practical AI & web dev articles. Follow me on LinkedIn.