// app/@modal/(.)photos/[id]/page.tsx
import { Modal } from '@/components/ui/modal';
import PhotoDetail from '@/components/photo-detail';
interface Props {
params: Promise<{ id: string }>;
}
export default async function PhotoModal({ params }: Props) {
const { id } = await params;
return (
<Modal>
<PhotoDetail photoId={id} isModal={true} />
</Modal>
);
}Dacă ai încercat vreodată să faci un feed cu vizualizare de imagini stil Instagram pe Pages Router, știi ce bătaie de cap era să sincronizezi URL-ul cu starea unui modal. În App Router, combinația dintre @modal (parallel route) și (.)photos/[id] (intercepting route) rezolvă fix problema asta la nivel arhitectural.
La un proiect foto cu vreo 18.000 de imagini indexate, aveam nevoie ca click-ul pe o miniatură să deschidă poza pe fundalul listei curente, dar un share de link sau un refresh direct din browser să deschidă pagina completă de detaliu. Am scăpat de vreo 250 de linii de boilerplate cu useSearchParams și context-uri sincronizate forțat.
Structura de foldere care face magia
Secretul stă în convențiile de denumire din App Router. În folderul app, definești un slot paralel @modal și pagina standard photos/[id]:
app/
├── @modal/
│ ├── (.)photos/[id]/
│ │ └── page.tsx <- se randează peste layout doar la client-side navigation
│ └── default.tsx <- obligatoriu: returnează null când slotul e gol
├── photos/[id]/
│ └── page.tsx <- se randează când intri direct pe URL (hard refresh)
├── layout.tsx <- primește prop-ul `modal` pe lângă `children`
└── page.tsx
Prefixul (.) îi spune lui Next.js: „Când userul navighează client-side la /photos/[id], nu încărca pagina completă, ci randează componenta asta în slotul @modal din layout-ul părinte.”
Capcana clasică: default.tsx și închiderea modalului
Cea mai frecventă greșeală pe care am văzut-o (și pe care am făcut-o și eu prima dată) este lipsa fișierului app/@modal/default.tsx. Fără el, dacă userul navighează pe o altă pagină care nu interceptează nimic, Next.js aruncă 404 pentru că nu știe ce să pună în slotul @modal.
default.tsx trebuie pur și simplu să returneze null:
export default function Default() {
return null;
}
Pentru închiderea modalului, nu setezi vreun isOpen: false. Apelezi direct router.back() din next/navigation. Asta duce URL-ul înapoi la /, iar slotul @modal revine automat la starea din default.tsx.
Trade-off-uri reale din producție
Pattern-ul e excelent pentru galerii, pagini de preview de produse sau flow-uri rapide de auth. Experiența pentru user e fluidă, iar link-urile partajate pe Slack sau WhatsApp duc direct la pagina completă, cu SEO intact și metadata generat pe server prin generateMetadata.
Totuși, debugging-ul devine enervant când ai layere multiple de layout-uri imbricate. Dacă ai layout-uri diferite între pagina sursă și ruta interceptată, sloturile paralele pot produce comportamente ciudate la animații de tranziție. De asemenea, dacă folosești router.back() pentru închidere, dar userul a aterizat pe modal direct din Google și dă „Close”, s-ar putea să-l trimiți înapoi pe Google în loc să-l duci pe feed-ul tău dacă nu verifici istoricul de navigare.
Voi folosiți pattern-ul ăsta în producție sau preferați în continuare modal clasic gestionat prin query params (?photoId=123)?