Bạn đang xem một feed ảnh trên Instagram.
Click vào một ảnh — một modal xuất hiện, URL đổi thành /photo/123, bạn có thể copy link đó gửi cho bạn bè. Người nhận mở link → thấy trang ảnh đầy đủ, không phải modal. Nhấn Back → quay lại feed, không phải trang trắng. Đây là pattern shareable modal, và đây chính xác là thứ Intercepting Routes được sinh ra để giải quyết. Trước khi có tính năng này, bạn phải tự quản lý URL state, history API, scroll position bằng JavaScript thuần, rất dễ bug.
Vấn đề Intercepting Routes giải quyết
Với routing thông thường, bạn chỉ có hai lựa chọn:
Modal không có URL — không thể share link, F5 là mất, SEO không có Chuyển trang hoàn toàn — mất context trang cũ, trải nghiệm bị ngắt quãng Intercepting Routes cho phép bạn có cả hai cùng lúc:
Cú pháp Intercepting Routes
Intercepting Routes dùng ký hiệu tương tự relative path, nhưng dựa trên route segment chứ không phải file system:
Lưu ý quan trọng: @slot không tính là route segment vì nó không ảnh hưởng URL. Nên khi tính “cấp”, bạn chỉ đếm các thư mục thực sự tạo ra URL segment.
Xây dựng Photo Gallery step-by-step
Mục tiêu
/ → Trang feed ảnh
/photo/[id] → Trang ảnh đầy đủ (khi truy cập trực tiếp)
→ Modal overlay trên feed (khi click từ feed)
Bước 1: Cấu trúc thư mục
/app
├── @modal
│ ├── (..)photo
│ │ └── [id]
│ │ └── page.tsx ← intercepted page (hiển thị modal)
│ └── default.tsx ← trả về null khi không có modal
├── photo
│ └── [id]
│ └── page.tsx ← full page (khi hard navigate)
├── layout.tsx
└── page.tsx ← feed ảnh
Tại sao dùng (..)photo mà không phải (.)photo?
Slot @modal nằm trong app/, cùng cấp với photo/. Nhưng vì @modal không tạo URL segment, khi tính cấp để intercept /photo/[id], ta đang đứng ở app/ nhìn vào app/photo/ — tức là một cấp dưới → dùng (..).
Bước 2: Layout nhận slot @modal
// app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode
modal: React.ReactNode
}) {
return (
<html lang="en">
<body>
{children}
{modal} {/* modal render ở đây, chồng lên feed */}
</body>
</html>
)
}
Bước 3: default.tsx — không render gì khi không có modal
// app/@modal/default.tsx
export default function ModalDefault() {
return null
}
Khi người dùng ở trang feed / mà chưa click ảnh nào, slot @modal cần render gì đó — null là câu trả lời đúng.
Bước 4: Feed ảnh — trang chính
// app/page.tsx
import Link from 'next/link'
const photos = [
{ id: '1', src: 'https://picsum.photos/seed/1/400/300', title: 'Ảnh 1' },
{ id: '2', src: 'https://picsum.photos/seed/2/400/300', title: 'Ảnh 2' },
{ id: '3', src: 'https://picsum.photos/seed/3/400/300', title: 'Ảnh 3' },
]
export default function FeedPage() {
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8 }}>
{photos.map((photo) => (
<Link key={photo.id} href={`/photo/${photo.id}`}>
<img src={photo.src} alt={photo.title} style={{ width: '100%' }} />
</Link>
))}
</div>
)
}
Không có gì đặc biệt ở đây — chỉ là <Link> bình thường trỏ đến /photo/[id].
Bước 5: Trang ảnh đầy đủ — cho hard navigation
// app/photo/[id]/page.tsx
const photos = [
{ id: '1', src: 'https://picsum.photos/seed/1/800/600', title: 'Ảnh 1' },
{ id: '2', src: 'https://picsum.photos/seed/2/800/600', title: 'Ảnh 2' },
{ id: '3', src: 'https://picsum.photos/seed/3/800/600', title: 'Ảnh 3' },
]
export default function PhotoPage({ params }: { params: { id: string } }) {
const photo = photos.find((p) => p.id === params.id)
if (!photo) notFound()
return (
<div>
<h1>{photo.title}</h1>
<img src={photo.src} alt={photo.title} style={{ maxWidth: '100%' }} />
</div>
)
}
Bước 6: Modal — intercepted page
// app/@modal/(..)photo/[id]/page.tsx
'use client'
import { useRouter } from 'next/navigation'
const photos = [
{ id: '1', src: 'https://picsum.photos/seed/1/800/600', title: 'Ảnh 1' },
{ id: '2', src: 'https://picsum.photos/seed/2/800/600', title: 'Ảnh 2' },
{ id: '3', src: 'https://picsum.photos/seed/3/800/600', title: 'Ảnh 3' },
]
export default function PhotoModal({ params }: { params: { id: string } }) {
const router = useRouter()
const photo = photos.find((p) => p.id === params.id)
if (!photo) return null
return (
// Overlay backdrop
<div
onClick={() => router.back()}
style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.7)',
display: 'grid', placeItems: 'center',
zIndex: 50,
}}
>
{/* Modal content — click không đóng modal */}
<div onClick={(e) => e.stopPropagation()} style={{ background: 'white', padding: 24, borderRadius: 8 }}>
<h2>{photo.title}</h2>
<img src={photo.src} alt={photo.title} style={{ maxWidth: '80vw' }} />
<button onClick={() => router.back()}>Đóng</button>
</div>
</div>
)
}
router.back() đóng modal bằng cách quay lại trang trước trong history — đúng hành vi người dùng kỳ vọng.
Luồng hoạt động — tổng kết
Người dùng ở /feed, click ảnh id=1
↓
Next.js soft navigate → /photo/1
↓
Next.js phát hiện: đang trong feed, có intercepting route (..)photo/[id]
↓
@modal slot → render PhotoModal (URL hiển thị: /photo/1)
children → vẫn render FeedPage bên dưới
↓
Người dùng copy URL /photo/1, mở tab mới (hard navigate)
↓
Không có context để intercept
↓
@modal slot → render default.tsx (null)
children → render PhotoPage đầy đủ
Ứng dụng thực tế trong production
Intercepting Routes + Parallel Routes không chỉ dùng cho gallery ảnh. Dưới đây là các pattern phổ biến trong production:
1. Login modal với fallback page
Nút “Đăng nhập” ở navbar mở modal, nhưng /login vẫn là trang độc lập cho người dùng không có JavaScript hoặc truy cập trực tiếp. Pattern này giúp progressive enhancement — app hoạt động tốt kể cả khi JS chưa load xong.
/app
├── @auth
│ ├── (.)login
│ │ └── page.tsx ← login modal
│ └── default.tsx ← null
├── login
│ └── page.tsx ← login page đầy đủ
└── layout.tsx
2. Quick view sản phẩm — E-commerce
Người dùng hover/click vào card sản phẩm → modal hiển thị thông tin nhanh (ảnh, giá, nút thêm vào giỏ), URL đổi thành /products/abc. Người dùng muốn xem chi tiết hơn → click “Xem chi tiết” mở trang sản phẩm đầy đủ. URL /products/abc có thể share được, SEO đầy đủ.
3. Shopping cart side drawer
Click icon giỏ hàng → drawer trượt ra từ phải, URL đổi thành /cart. Người dùng vẫn thấy trang đang xem bên dưới. Nhấn Back hoặc click ngoài → drawer đóng, quay lại trang cũ.
4. User profile popover
Trong ứng dụng social, click vào tên người dùng → popover hiển thị thông tin tóm tắt và nút Follow, URL đổi thành /users/[username]. Người dùng click vào tên → trang profile đầy đủ.
5. Image/video lightbox trong CMS
Trong trang quản lý media, click vào file → lightbox xem trước mở ra, URL dẫn đến file cụ thể — có thể share link để cộng tác viên xem đúng file đó.
Bảng ví dụ tổng hợp
Nested Routes vs Intercepting Routes — Khi nào dùng cái nào?
Đây là điểm dễ nhầm nhất. Cả hai đều cho shareable URL và SEO đầy đủ, nhưng hành vi khi hard navigate thì khác nhau hoàn toàn.
— hard navigate và soft navigate cho kết quả giống nhau: Tôi đã có bài viết về , trong đó sử dụng Nested Routes để tạo Modal. app/
├── list/
│ ├── new/
│ │ └── page.tsx → /list/new
│ ├── layout.tsx → render <UrlList /> + {children}
│ └── page.tsx → /list
Dù người dùng click từ /list hay paste thẳng /list/new vào tab mới, họ đều thấy list + modal cùng lúc — vì layout.tsx luôn render.
Intercepting Routes — hard navigate và soft navigate cho kết quả khác nhau:
Câu hỏi thực tế để chọn đúng: "Khi người dùng paste link vào tab mới, tôi muốn họ thấy gì?"
Thấy modal trong context của trang cha (list vẫn hiển thị bên dưới) → Nested Routes là đủ, đơn giản hơn nhiều Thấy trang độc lập, không có context trang cha (chỉ thấy mỗi trang ảnh, không có gallery) → cần Intercepting Routes Ví dụ Instagram: click ảnh từ profile → modal overlay trên profile. Nhưng paste link ảnh vào tab mới → trang ảnh đầy đủ, không thấy profile của ai cả. Đây là lý do Instagram cần Intercepting Routes, còn một trang /list/new đơn giản thì không cần.
Best practices