// app/@modal/(.)photos/[id]/page.tsx
'use client';
import { useRouter } from 'next/navigation';
import { useEffect, useRef } from 'react';
export default function PhotoModal({ params }: { params: { id: string } }) {
const router = useRouter();
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
if (!dialogRef.current?.open) {
dialogRef.current?.showModal();
}
}, []);
const onDismiss = () => {
router.back();
};
return (
<dialog
ref={dialogRef}
onClose={onDismiss}
className="backdrop:bg-black/80 rounded-lg p-6 bg-slate-900 text-white border border-slate-800"
>
<div className="flex flex-col gap-4">
<img src={`/api/photos/${params.id}`} alt="Preview" className="max-w-xl rounded" />
<button
onClick={onDismiss}
className="px-4 py-2 bg-rose-600 hover:bg-rose-700 text-white rounded font-medium self-end"
>
Închide
</button>
</div>
</dialog>
);
}Am avut de făcut recent pe un proiect cu peste 15k imagini un feed de galerii de tip Pinterest. Cerința era simplă la prima vedere: când userul dă click pe o poză din feed, deschizi un modal cu detalii fără să pierzi scroll-ul, dar schimbi URL-ul în /photos/123. Dacă cineva ia acel URL și îl trimite pe Slack, primitorul trebuie să vadă o pagină de sine stătătoare, nu un modal plutind peste un fundal gol.
Soluția veche versus pattern-ul din App Router
În trecut rezolvam mizeria asta cu un query param gen ?photoId=123 și un global state gestionat de Zustand sau Redux. Era un hack de doi lei. Nu aveai SSR curat pe pagina de detalii, iar SEO-ul suferea groaznic pentru că paginile individuale nu erau randate ca entități separate pentru crawlere.
În App Router, combinația dintre @modal (Parallel Routes) și (.)photos/[id] (Intercepting Routes) rezolvă problema asta elegant, direct la nivel de router.
Arhitectura folderelor – unde se încurcă lumea
Marea schemă e că trebuie să respecți cu sfințenie ierarhia. Dacă feed-ul tău e în app/page.tsx, layout-ul din app/layout.tsx trebuie să primească @modal ca prop alături de children.
Structura curată pe care am folosit-o eu arată așa:
app/layout.tsx(randarechildren+modalslot)app/default.tsx(returneazănullcând modalul nu e activ)app/@modal/default.tsx(returneazănullpentru fallback-ul slotului)app/@modal/(.)photos/[id]/page.tsx(interceptează navigarea client-side)app/photos/[id]/page.tsx(pagina completă, randată la F5 sau acces direct)
Partea unde am pierdut eu două ore la primul proiect: convenția de paranteze. (.) înseamnă "interceptează la același nivel", (..) înseamnă "un nivel mai sus". Dacă pui folderul greșit, Next.js ignoră interceptarea și face hard navigation, reîncărcând tot layout-ul.
Cum închizi modalul curat fără bug-uri de URL
Problema clasică la intercepting routes apare când utilizatorul apasă pe overlay sau pe butonul X. Dacă faci doar un setShowModal(false) intern, URL-ul rămâne blocat pe /photos/123 deși modalul a dispărut.
Varianta corectă este să apelezi router.back(). Asta face pop în istoricul browser-ului, URL-ul revine la cel anterior, iar Next.js golește automat slotul @modal (randând default.tsx-ul care dă null).
Trade-off-uri și ce am învățat pe pielea mea
UX-ul este absolut superb. Pe testele noastre am economisit în medie 300ms la afișarea detaliilor pentru că nu mai remonta layout-ul root și nu mai re-evalua querie-urile din feed. Scroll-ul rămâne exact unde l-ai lăsat.
Ce e nasol? Gestionarea de state e mai complicată dacă ai formulare în modal. Dacă folosești Server Actions în interiorul modalului interceptat și dai revalidatePath, uneori Next.js va încerca să re-randeze și pagina din spate, ceea ce poate duce la flickering dacă nu ești atent la caching.
De asemenea, debugging-ul pe server logs devine puțin ambiguu, pentru că un request de client-side navigation va executa doar componenta din slotul interceptat, nu toată pagina.
Ați trecut deja la pattern-ul ăsta în producție sau preferați în continuare abordarea clasică cu searchParams ?modal=true?