Guide to using NextJS Parallel and Intercepting Routes

Introduction

In the previous article I guided some ways to use NextJS App Router, now we will continue with 2 other features including

  • Parallel Routes: as the name implies you can understand that it allows to define many separate routes and put them together on 1 route for display, the very clear advantage is that it helps to separate UI, isolate errors (if any), easy to maintain and expand functionality
    • Note that if you use this feature and in the main route has define an additional 1 page that is not a Parallel Routes, then in the Parallel Routes should have an additional file to show default information to avoid 404 errors when accessing directly (we will go into more detail in the detail part below)
  • Intercepting Route: Allows "blocking" a route to display that content in another context (for example: a link will display a Modal, but when reloading that page, a separate page will open).

Prerequisites

  • This article is continued to be developed from previous articles, please review if you do not understand to have basic information about App Router before continuing
  • Note that in this article because I only focus on 2 features which are Parallel Routes and Intercepting Route so there will not be much explanation about the UIs, you can also replace with your corresponding source code to suit


Detail

Update file app/_type/common.ts

export interface Order {
id: string
name: string
}


Update file app/_data/common.ts to create fake data for order

import type {Order} from '../_type/common'

export const orders: Order[] = [
{id: '1', name: 'Order 1'},
{id: '2', name: 'Order 2'},
]


Create file app/dashboard/layout.tsx

interface DashboardLayoutProps {
children: React.ReactNode
analytics: React.ReactNode
orders: React.ReactNode
}

export default function DashboardLayout({
children,
analytics,
orders,
}: DashboardLayoutProps) {
return (
<div className="min-h-screen bg-[#f5f5f5] p-6 font-sans text-slate-900">
<div className="max-w-[1400px] mx-auto space-y-6">
<header className="py-4">
<h1 className="text-3xl font-bold tracking-tight text-slate-800">
System Administration
</h1>
<p className="text-slate-500 mt-1">
Real-time overview of system operations.
</p>
</header>

<main className="bg-white rounded-xl shadow-sm border border-slate-200 p-8 transition-all">
<div className="min-h-[100px]">{children}</div>
</main>

<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<section className="bg-white rounded-xl shadow-sm border-t-4 border-t-blue-500 border-x border-b border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100 bg-slate-50/50">
<h2 className="font-semibold text-lg text-slate-700">
Detailed Analytics
</h2>
</div>
<div className="p-6 min-h-[300px]">{analytics}</div>
</section>

<section className="bg-white rounded-xl shadow-sm border-t-4 border-t-green-500 border-x border-b border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100 bg-slate-50/50">
<h2 className="font-semibold text-lg text-slate-700">
Order Management
</h2>
</div>
<div className="p-6 min-h-[300px]">{orders}</div>
</section>
</div>
</div>
</div>
)
}

This file plays the role of the main Layout for Dashboard, using the Parallel Routes feature of NextJS. It receives "slots" like analytics and orders (corresponding to the @analytics and @orders folders) along with traditional children. This layout arranges the slots into a grid system, allowing to display multiple pages at once on a single interface while maintaining independence of logic and data.


Create file app/dashboard/page.tsx

import {SettingOutlined} from '@ant-design/icons'
import Link from 'next/link'

export default function DashboardPage() {
return (
<div className="flex flex-col gap-4">
<div>
<h3 className="text-xl font-bold text-slate-800">Welcome back</h3>
<p className="text-slate-500">This is the dashboard page.</p>
</div>
<div>
<Link
href="/dashboard/settings"
className="inline-flex items-center gap-2 bg-slate-800 hover:bg-slate-700 text-white px-4 py-2 rounded-lg transition-colors font-medium shadow-sm"
>
<SettingOutlined />
Go to Settings
</Link>
</div>
</div>
)
}


Create file app/dashboard/settings/page.tsx

import {ArrowLeftOutlined, SaveOutlined} from '@ant-design/icons'
import Link from 'next/link'

export default function SettingsPage() {
return (
<div className="max-w-2xl">
<Link
href="/dashboard"
className="flex items-center gap-2 text-slate-500 hover:text-blue-600 mb-6 transition-colors"
>
<ArrowLeftOutlined /> Back to Dashboard
</Link>
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-slate-800">
Account Settings
</h2>
<p className="text-slate-500">
Manage your profile and system preferences.
</p>
</div>
<div className="bg-white border border-slate-200 rounded-xl divide-y divide-slate-100">
<div className="p-4 flex items-center justify-between">
<div>
<p className="font-semibold text-slate-700">Email Address</p>
<p className="text-sm text-slate-500">admin@company.com</p>
</div>
<button className="text-blue-600 hover:underline text-sm font-medium">
Change
</button>
</div>
<div className="p-4 flex items-center justify-between">
<div>
<p className="font-semibold text-slate-700">Push Notifications</p>
<p className="text-sm text-slate-500">
Receive alerts about new orders.
</p>
</div>
<input
type="checkbox"
className="w-5 h-5 accent-blue-600"
defaultChecked
/>
</div>
<div className="p-4 flex items-center justify-between">
<div>
<p className="font-semibold text-slate-700">Appearance</p>
<p className="text-sm text-slate-500">
Set your preferred theme.
</p>
</div>
<select className="bg-slate-50 border border-slate-200 rounded px-2 py-1 text-sm outline-none">
<option>Light Mode</option>
<option>Dark Mode</option>
<option>System</option>
</select>
</div>
</div>
<button className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-4 rounded-xl transition-all shadow-lg shadow-blue-200">
<SaveOutlined />
Save All Changes
</button>
</div>
</div>
)
}


Create file app/dashboard/@analytics/loading.tsx

export default function LoadingAnalytics() {
return (
<div className="space-y-6 animate-pulse">
<div className="h-8 w-[200px] bg-slate-200 rounded-md" />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="bg-white p-6 rounded-xl border border-slate-100 shadow-sm space-y-3">
<div className="h-4 w-1/3 bg-slate-100 rounded" />
<div className="h-8 w-2/3 bg-slate-200 rounded" />
</div>
<div className="bg-white p-6 rounded-xl border border-slate-100 shadow-sm space-y-3">
<div className="h-4 w-1/3 bg-slate-100 rounded" />
<div className="h-8 w-2/3 bg-slate-200 rounded" />
</div>
</div>
</div>
)
}


Create file app/dashboard/@analytics/page.tsx

import {
DollarCircleOutlined,
LineChartOutlined,
RiseOutlined,
UserAddOutlined,
} from '@ant-design/icons'
import {Statistic} from 'antd'

async function getAnalytics() {
await new Promise(resolve => setTimeout(resolve, 1000))
return {revenue: 50000, users: 120}
}

export default async function AnalyticsPage() {
const data = await getAnalytics()
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<LineChartOutlined className="text-2xl text-blue-600" />
<h4 className="text-xl font-bold text-slate-800 m-0">
Detailed Analytics
</h4>
</div>
<span className="text-slate-400 text-xs uppercase tracking-wider">
Real-time updates
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="bg-blue-50/30 border border-blue-100 rounded-xl p-6 shadow-sm">
<Statistic
title={
<span className="text-blue-600 font-medium">Total Revenue</span>
}
value={data.revenue}
precision={0}
styles={{content: {color: '#1d4ed8', fontWeight: 800}}}
prefix={<DollarCircleOutlined />}
suffix={
<span className="text-sm font-normal text-slate-400 ml-1">
USD
</span>
}
/>
<div className="mt-2 flex items-center gap-1 text-green-600 text-xs font-bold">
<RiseOutlined /> +12.5% vs last month
</div>
</div>
<div className="bg-purple-50/30 border border-purple-100 rounded-xl p-6 shadow-sm">
<Statistic
title={
<span className="text-purple-600 font-medium">New Users</span>
}
value={data.users}
styles={{content: {color: '#7e22ce', fontWeight: 800}}}
prefix={<UserAddOutlined />}
/>
<div className="mt-2 flex items-center gap-1 text-slate-500 text-xs">
Updated just now
</div>
</div>
</div>
</div>
)
}


Create file app/dashboard/@analytics/default.tsx

export default function DefaultAnalytics() {
return (
<div>
<h4>📊 Summary</h4>
<p>
This section provides a quick overview of key metrics and trends. Dive
into the detailed analytics for in-depth insights.
</p>
</div>
)
}


Create file app/dashboard/@orders/page.tsx

import {orders} from '@/app/_data/common'
import {EyeOutlined, ShoppingCartOutlined} from '@ant-design/icons'
import Link from 'next/link'

export default function OrdersPage() {
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-slate-700">
<ShoppingCartOutlined className="text-xl" />
<h4 className="text-lg font-bold">Latest Orders</h4>
</div>
<ul className="divide-y divide-slate-100 border border-slate-200 rounded-xl overflow-hidden bg-white">
{orders.map(order => (
<li
key={order.id}
className="flex items-center justify-between p-4 hover:bg-slate-50 transition-colors"
>
<div className="flex flex-col">
<span className="font-semibold text-slate-800">{order.name}</span>
<span className="text-xs text-slate-400 uppercase font-mono">
ID: {order.id}
</span>
</div>
<Link
href={`/dashboard/order/${order.id}`}
className="flex items-center gap-2 text-blue-600 hover:text-blue-700 font-medium text-sm bg-blue-50 hover:bg-blue-100 px-3 py-1.5 rounded-lg transition-all"
>
<EyeOutlined />
Detail with Modal
</Link>
</li>
))}
</ul>
</div>
)
}


Create file app/dashboard/@orders/default.tsx

export default function DefaultAnalytics() {
return (
<div>
<h4>📊 Summary</h4>
<p>
This section provides a quick overview of key metrics and trends. Dive
into the detailed analytics for in-depth insights.
</p>
</div>
)
}


Create file app/dashboard/@orders/(.)order/[id]/ModalFrame.tsx

'use client'

import {
ArrowRightOutlined,
CloseOutlined,
InfoCircleOutlined,
ShoppingOutlined,
} from '@ant-design/icons'
import {Button, Space, Tag, Typography} from 'antd'
import {useRouter} from 'next/navigation'
import React from 'react'

const {Title, Text} = Typography

interface ModalFrameProps {
children: React.ReactNode
orderId: string
}

export default function ModalFrame({children, orderId}: ModalFrameProps) {
const router = useRouter()
return (
<div
onClick={e => e.target === e.currentTarget && router.back()}
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex justify-center items-center z-[1000] p-4"
>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden">
<div className="flex justify-between items-center px-6 py-4 border-b border-gray-100 bg-slate-50/50">
<Space>
<ShoppingOutlined className="text-blue-600 text-lg" />
<Title
level={5}
className="!mb-0 uppercase tracking-wide text-slate-700"
>
Order Details
</Title>
</Space>
<Button
type="text"
shape="circle"
icon={<CloseOutlined />}
onClick={() => router.back()}
/>
</div>
<div className="p-6 pb-0 flex items-center justify-between">
<div className="space-y-1">
<Text type="secondary" className="text-xs uppercase">
Order Reference
</Text>
<Title
level={4}
className="!mb-0 font-mono tracking-tight text-blue-600"
>
#{orderId}
</Title>
</div>
<Tag color="blue" icon={<InfoCircleOutlined />}>
Processing
</Tag>
</div>
{children}
<div className="px-6 py-4 bg-gray-50 flex gap-3 border-t border-gray-100">
<Button block size="large" onClick={() => router.back()}>
Dismiss
</Button>
<Button
type="primary"
block
size="large"
className="bg-blue-600 flex items-center justify-center gap-2"
onClick={() =>
(window.location.href = `/dashboard/order/${orderId}`)
}
>
Full View <ArrowRightOutlined />
</Button>
</div>
</div>
</div>
)
}

This component is a Client Component that plays the role of a "frame" for the Modal. It handles user interactions such as closing the modal (router.back()), preventing event propagation when clicking on the container area, and navigating to the full detail page. By separating this frame, we can wrap any Server Component content inside while keeping the client-side logic for the Modal's UI.


Create file app/dashboard/@orders/(.)order/[id]/page.tsx

import {InfoCircleOutlined} from '@ant-design/icons'
import ModalFrame from './ModalFrame'

type Props = {
params: Promise<{id: string}>
}

export default async function OrderModal({params}: Props) {
const {id: orderId} = await params
return (
<ModalFrame orderId={orderId}>
<div className="p-6 pt-4 space-y-6">
<div className="bg-blue-50 border border-blue-100 rounded-xl p-4 flex gap-3">
<InfoCircleOutlined className="text-blue-500 mt-1" />
<p className="text-blue-800 text-sm leading-relaxed m-0">
This information is currently displayed in{' '}
<strong>Popup Mode</strong>. Closing this will return you to the
previous dashboard view.
</p>
</div>
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-slate-500 font-medium">Customer</span>
<span className="font-bold text-slate-800 tracking-tight">
Name
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-slate-500 font-medium">Total Amount</span>
<span className="font-bold text-lg text-slate-900 font-mono tracking-tighter">
$1,234.00
</span>
</div>
</div>
</div>
</ModalFrame>
)
}


Create file app/dashboard/order/[id]/page.tsx

import {
ArrowLeftOutlined,
CalendarOutlined,
PauseOutlined,
UserOutlined,
} from '@ant-design/icons'
import Link from 'next/link'

type Props = {
params: Promise<{id: string}>
}

export default async function OrderFullPage({params}: Props) {
const {id: orderId} = await params
return (
<div className="bg-slate-50 p-4 md:p-8">
<div className="max-w-4xl mx-auto">
<Link
href="/dashboard"
className="inline-flex items-center gap-2 text-slate-500 hover:text-blue-600 mb-6 transition-colors font-medium"
>
<ArrowLeftOutlined /> Back to Dashboard
</Link>
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden">
<div className="bg-slate-900 p-6 md:p-8 text-white">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="bg-blue-500 p-3 rounded-xl">
<PauseOutlined className="text-2xl" />
</div>
<div>
<h1 className="text-2xl md:text-3xl font-bold">
Order #{orderId}
</h1>
<p className="text-slate-400 text-sm uppercase tracking-wider">
Status: Processing
</p>
</div>
</div>
<div className="bg-white/10 px-4 py-2 rounded-lg backdrop-blur-sm">
<span className="text-sm block text-slate-300">
Grand Total
</span>
<span className="text-xl font-bold font-mono">$120.00</span>
</div>
</div>
</div>
<div className="p-6 md:p-8 space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-4">
<h3 className="flex items-center gap-2 font-bold text-slate-800 border-b pb-2">
<UserOutlined /> Customer Details
</h3>
<div className="text-slate-600 space-y-1">
<p className="font-semibold text-slate-900">Name</p>
<p>+1 (123) 456-7890</p>
<p>123 Business Ave, Suite 100, New York, NY</p>
</div>
</div>
<div className="space-y-4">
<h3 className="flex items-center gap-2 font-bold text-slate-800 border-b pb-2">
<CalendarOutlined /> Shipping & Payment
</h3>
<div className="text-slate-600 space-y-1">
<p>
<span className="font-medium">Order Date:</span> March 20,
2024
</p>
<p>
<span className="font-medium">Payment:</span> Bank Transfer
</p>
<p>
<span className="font-medium">Shipping:</span> Express
Delivery
</p>
</div>
</div>
</div>
<div className="bg-blue-50 border border-blue-100 p-4 rounded-xl text-blue-700 text-sm italic">
Note: You are viewing the full-page layout. All changes made here
will be updated directly to the database.
</div>
</div>
<div className="bg-slate-50 p-6 border-t border-slate-100 flex justify-end gap-3">
<button className="px-6 py-2 rounded-lg border border-slate-300 font-semibold hover:bg-white transition-colors">
Print Invoice
</button>
<button className="px-6 py-2 rounded-lg bg-blue-600 text-white font-bold hover:bg-blue-700 transition-shadow shadow-md shadow-blue-200">
Update Status
</button>
</div>
</div>
</div>
</div>
)
}

This is the order detail page in full page mode. What's special here is thanks to the Intercepting Routes technique (with the (.)order folder), when the user clicks on the link from the Dashboard, this page will be "blocked" and displayed as a Modal (the @orders/(.)order/[id] folder). However, if the user reloads the page or copies and pastes the link directly into the browser, NextJS will render this page.tsx file to display full information on an independent page, ensuring a smooth user experience and optimizing for SEO.


The final folder structure will be like this


I will explain the function of each component as follows:

  • dashboard/layout.tsx: this is the layout apply to all routes when accessing /dashboard, contains slots for @analytics and @orders
  • dashboard/page.tsx: is the main page of dashboard
    • @analytics/loading.tsx: show loading while loading content from page.tsx because this is SSR
    • @analytics/page.tsx: main content will show on UI
    • @analytics/default.tsx: default page will show if no corresponding slot is found
    • @orders/page.tsx: main content will show on UI
    • @orders/default.tsx: default page will show if no corresponding slot is found
    • @orders/(.)order/[id]/page.tsx: server component is modal, contains static or data load from server, when accessed via navigation link then will open as modal
    • @orders/(.)order/[id]/ModalFrame.tsx: client component, because using modal needs interactions with UI so you must convert file page.tsx to client component or create additional 1 file like this to use
    • order/[id]/page.tsx: is the detail page of order, used when you access direct address url
    • settings/page.tsx: this is the page used to test default function of @analytics and @orders, if you don't add default.tsx file for 2 slots above then when accessing direct to /dashboard/settings will report 404 error
  • Here pay attention about the priority order of Intercepting Route:
    • If use navigation link: prioritize opening page @orders/(.)order/[id]/page.tsx
    • If access directly on address url of browser: prioritize opening page order/[id]/page.tsx


The result will be as follows





If you don't have default.tsx file for @analytics and @orders

Happy coding!

See more articles here.

Comments

Popular posts from this blog

All Practice Series

Kubernetes Deployment for Zero Downtime

Deploying a NodeJS Server on Google Kubernetes Engine

Setting up Kubernetes Dashboard with Kind

Practicing with Google Cloud Platform - Google Kubernetes Engine to deploy nginx

Using Kafka with Docker and NodeJS

Monitoring with cAdvisor, Prometheus and Grafana on Docker

Kubernetes Practice Series

Sitemap

NodeJS Practice Series