Modal — nói thật, đây là một trong những UI pattern phổ biến nhất nhưng cũng đau đầu nhất trên web. Làm sao để modal có URL chia sẻ được? Refresh trang thì modal có còn không? Nút back của trình duyệt có hoạt động đúng không? Trong Next.js 16, bộ ba Parallel Routes + Intercepting Routes + App Router giải quyết gần như toàn bộ những vấn đề đó một cách khai báo (declarative) — chỉ bằng việc đặt tên thư mục cho đúng.
Bài viết này mình sẽ hướng dẫn từ đầu đến cuối: từ cú pháp @slot và (.)folder, đến cách xây dựng một photo gallery kiểu Instagram với modal có thể chia sẻ URL, và (phần quan trọng nhất) những bug phổ biến mà 90% tutorial khác bỏ qua.
Tại sao cần Parallel + Intercepting Routes?
Hãy tưởng tượng một danh sách sản phẩm. Khi user click vào một sản phẩm, bạn muốn:
- Hiển thị chi tiết trong một modal overlay (giữ nguyên context của trang danh sách phía sau)
- URL phải đổi thành
/products/123để có thể chia sẻ link - Khi user nhấn back, modal đóng lại — chứ không quay về trang trước đó
- Khi user refresh hoặc dán link trực tiếp, hiển thị một full page hoàn chỉnh
- Có khả năng prefetch nội dung modal trước khi user click
Cách làm truyền thống với useState và một component <Modal> client-only? URL không đổi, share link hỏng, refresh là bay sạch state. Đó chính là khoảng trống mà routing pattern này lấp đầy.
Hiểu nhanh về Parallel Routes
Parallel Routes cho phép bạn render nhiều route segment cùng lúc trong cùng một layout. Mỗi segment được đặt trong một thư mục có prefix @, gọi là một slot (khe).
app/
├── layout.tsx // Nhận children + @modal làm props
├── page.tsx // Render ở vị trí children
├── @modal/
│ └── default.tsx // Render ở vị trí @modal khi không có route nào match
└── ...
Layout file giờ phải nhận thêm slot prop:
// app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html lang="vi">
<body>
{children}
{modal}
</body>
</html>
);
}
default.tsx — file mà gần như ai cũng quên
Khi user truy cập một route mà slot @modal không có nội dung tương ứng, Next.js cần biết phải render gì. Đó là vai trò của default.tsx. Thiếu file này, bạn sẽ ăn ngay lỗi 404 khi navigate sang một route khác — và mình từng debug cái này mất nguyên buổi tối trước khi nhận ra.
// app/@modal/default.tsx
export default function Default() {
return null;
}
Quy tắc bắt buộc: mỗi parallel slot phải có default.tsx ở mọi cấp layout chứa nó. Không có ngoại lệ.
Hiểu nhanh về Intercepting Routes
Intercepting Routes cho phép bạn "chặn" một navigation và render nội dung khác — trong khi URL vẫn cập nhật bình thường. Cú pháp dùng prefix dấu chấm trong tên thư mục:
(.)folder— chặn route ở cùng cấp(..)folder— chặn route ở cấp trên một bậc (theo route segment, không tính route group)(..)(..)folder— chặn route ở cấp trên hai bậc(...)folder— chặn route từ thư mục app gốc
Cảnh báo quan trọng: (..) được tính theo route segment, chứ không phải file system. Các thư mục (group) và @slot không được tính là segment, nên việc đếm cấp dễ gây nhầm lẫn lắm.
Kết hợp: Modal Pattern hoàn chỉnh
OK, bây giờ ghép hai khái niệm lại để xây dựng một photo gallery kiểu Instagram. Cấu trúc thư mục sẽ như sau:
app/
├── layout.tsx
├── page.tsx // Trang chủ
├── @modal/
│ ├── default.tsx // Bắt buộc, return null
│ └── (.)photos/
│ └── [id]/
│ └── page.tsx // Modal version
└── photos/
└── [id]/
└── page.tsx // Full page version
Logic hoạt động:
- User ở trang chủ click
<Link href="/photos/42"> - Soft navigation kích hoạt → Next.js phát hiện
@modal/(.)photos/[id]và render modal version trong slot, đồng thời giữ nguyênchildren(tức là trang chủ) - URL trên address bar đổi thành
/photos/42 - User refresh hoặc paste URL → hard navigation → Next.js render full page version ở
app/photos/[id]/page.tsx
Code chi tiết: Trang chủ với link
// app/page.tsx
import Link from 'next/link';
import { getPhotos } from '@/lib/data';
export default async function HomePage() {
const photos = await getPhotos();
return (
<main className="grid grid-cols-3 gap-2 p-4">
{photos.map((photo) => (
<Link key={photo.id} href={`/photos/${photo.id}`}>
<img src={photo.thumb} alt={photo.title} />
</Link>
))}
</main>
);
}
Modal component (intercepted)
// app/@modal/(.)photos/[id]/page.tsx
import { getPhoto } from '@/lib/data';
import Modal from '@/components/Modal';
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} />
<h2>{photo.title}</h2>
<p>{photo.description}</p>
</Modal>
);
}
Full page (hard navigation)
// app/photos/[id]/page.tsx
import { getPhoto } from '@/lib/data';
export default async function PhotoPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const photo = await getPhoto(id);
return (
<article className="container mx-auto p-8">
<img src={photo.url} alt={photo.title} className="w-full" />
<h1 className="text-3xl font-bold mt-4">{photo.title}</h1>
<p className="mt-2">{photo.description}</p>
</article>
);
}
Modal client component
// components/Modal.tsx
'use client';
import { useRouter } from 'next/navigation';
import { useEffect, useRef } from 'react';
export default function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter();
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
if (!dialogRef.current?.open) {
dialogRef.current?.showModal();
}
}, []);
function onClose() {
router.back();
}
return (
<dialog
ref={dialogRef}
onClose={onClose}
className="rounded-lg p-6 backdrop:bg-black/60"
>
<button onClick={onClose} aria-label="Đóng" className="absolute top-2 right-2">
✕
</button>
{children}
</dialog>
);
}
Một mẹo nhỏ mình thích: sử dụng thẻ <dialog> native cho phép trình duyệt xử lý focus trap, phím Escape, và backdrop một cách tự động — không cần Radix, không cần Headless UI, không cần gì cả.
Bốn lỗi phổ biến và cách fix
Lỗi 1: Dùng <Link replace> để đóng modal — không hoạt động
Nhiều người (kể cả mình lúc đầu) viết <Link href="/" replace>Đóng</Link> để quay về trang chủ. Vấn đề là: modal sẽ không đóng, vì Next.js không phát hiện được rằng cần "un-intercept". Giải pháp: dùng router.back() hoặc thẻ <a> nguyên bản (gây hard navigation).
// ❌ Sai
<Link href="/" replace>Đóng</Link>
// ✅ Đúng
<button onClick={() => router.back()}>Đóng</button>
Lỗi 2: Đặt slash sai trong (.)folder
// ❌ Sai — thêm slash sau (.)
app/@modal/(.)/photos/[id]/page.tsx
// ✅ Đúng
app/@modal/(.)photos/[id]/page.tsx
Lỗi 3: Đếm sai cấp khi dùng (..)
(..) bỏ qua thư mục (group) và @slot. Ví dụ với cấu trúc app/(home)/@modal/(..)settings/page.tsx, (..) sẽ trỏ tới app/settings, KHÔNG phải app/(home)/settings. Khi nghi ngờ? Cứ dùng (...) để bắt đầu từ root cho an toàn.
Lỗi 4: Quên default.tsx → 404 toàn site
Đây là lỗi mình nhắc đi nhắc lại vì nó cay nhất. Nếu thiếu app/@modal/default.tsx, ngay khi user navigate đến một route không khớp với intercepting pattern, toàn bộ slot sẽ throw 404. Luôn tạo file default.tsx trả về null — đây là dòng code 2 giây mà cứu bạn cả tiếng debug.
Prefetch — vì sao modal cảm giác "tức thì"
Next.js tự động prefetch các <Link> nằm trong viewport. Với intercepting routes, điều này có nghĩa là nội dung modal được tải trước cả khi user click. Đây là lý do pattern này thường mượt hơn các modal client-only truyền thống — gần như không cần loading spinner.
Trong Next.js 16 (với Cache Components và "use cache" directive), bạn còn có thể đẩy hiệu năng lên cao hơn nữa bằng cách cache data fetching ở phía server:
// lib/data.ts
import { unstable_cacheTag as cacheTag } from 'next/cache';
export async function getPhoto(id: string) {
'use cache';
cacheTag(`photo-${id}`);
const res = await fetch(`https://api.example.com/photos/${id}`);
return res.json();
}
Kết hợp với Server Actions để revalidate modal
Khi user thực hiện một mutation trong modal (ví dụ like ảnh), bạn có thể dùng updateTag (Next.js 16) để invalidate cache cho cả modal lẫn full page version cùng một lúc:
// app/actions.ts
'use server';
import { unstable_updateTag as updateTag } from 'next/cache';
export async function likePhoto(id: string) {
await db.photo.update({
where: { id },
data: { likes: { increment: 1 } },
});
updateTag(`photo-${id}`);
}
Khi nào KHÔNG nên dùng pattern này?
- Modal chỉ là confirmation dialog không cần URL — dùng client state đơn giản hơn nhiều
- Modal hiển thị form ngắn (login nhanh, subscribe newsletter) mà bạn không cần share URL
- App đang chạy Next.js trước phiên bản 15 — có nhiều bug đã biết, đặc biệt với dynamic routes
- Bạn đang dùng Pages Router — pattern này chỉ hoạt động trong App Router, không có cách nào khác
Checklist trước khi production
- Đã có
default.tsxtrong mọi parallel slot - Đã test refresh trên modal URL → render full page version
- Đã test back/forward button trên trình duyệt
- Đã thay tất cả
<Link replace>bằngrouter.back() - Đã thêm
aria-labelcho nút đóng modal - Đã test keyboard navigation (Tab, Escape)
- Đã test chia sẻ URL từ modal sang trình duyệt khác
Câu hỏi thường gặp (FAQ)
Parallel Routes và Intercepting Routes có chạy được trong page.tsx không?
Không. Parallel slot @folder chỉ hoạt động khi được nhận làm prop trong layout.tsx. Đặt slot trong page sẽ bị Next.js bỏ qua hoàn toàn.
Modal có hoạt động khi user disable JavaScript không?
Có, một phần. Nếu JavaScript bị tắt, link sẽ trigger hard navigation và render full page version ở app/photos/[id]/page.tsx. Đây chính là lý do bạn luôn cần có cả hai version: modal và full page.
Có thể có nhiều modal cùng lúc không?
Về kỹ thuật là có — bạn có thể tạo nhiều slot như @modal, @drawer, @toast. Tuy nhiên, theo khuyến nghị từ team Next.js, nên gom tất cả intercepting routes vào một parallel slot duy nhất để tránh xung đột state. Mình cũng đã thử cách tách nhiều slot và quả thật nó gây nhức đầu hơn là tiện lợi.
Khác biệt giữa (.), (..), và (...) là gì?
(.) match cùng cấp, (..) lên một segment, (...) trỏ thẳng về app/ root. Quan trọng: tính theo route segment, không tính (group) và @slot.
Pattern này có tương thích với Next.js 16 Cache Components không?
Hoàn toàn tương thích. Bạn có thể dùng directive "use cache" trong cả modal page lẫn full page, và dùng cacheTag + updateTag để invalidate cả hai cùng lúc khi data thay đổi.
Kết luận
Parallel + Intercepting Routes biến modal — vốn là một trong những UI khó làm đúng nhất — thành một pattern khai báo, có URL, có lịch sử trình duyệt, và có khả năng chia sẻ. Với Next.js 16, kết hợp thêm Cache Components và Server Actions, bạn có một bộ công cụ hoàn chỉnh để xây dựng UX hiện đại mà trước đây cần hàng trăm dòng JavaScript imperative.
Lần tới khi cần một modal có URL, đừng reach for useState — mở terminal và mkdir -p app/@modal/\(.\)photos luôn cho nhanh.