Skip to content

Thực hành Intercepting Routes trong Next.js qua ví dụ Photo Gallery

Đây là pattern shareable modal, và đây chính xác là thứ Intercepting Routes được sinh ra để giải quyết.
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:
Tình huống
Hiển thị
Click từ feed → /photo/123
Modal overlay trên feed
Paste URL /photo/123 vào tab mới
Trang ảnh đầy đủ
F5 khi đang mở modal
Trang ảnh đầy đủ
Nhấn Back
Quay lại feed, modal đóng
There are no rows in this table

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:
Ký hiệu
Ý nghĩa
(.)folder
Intercept segment cùng cấp
(..)folder
Intercept segment một cấp trên
(..)(..)folder
Intercept segment hai cấp trên
(...)folder
Intercept từ root /app
There are no rows in this table
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>
Want to print your doc?
This is not the way.
Try clicking the ··· in the right corner or using a keyboard shortcut (
CtrlP
) instead.