import { ModalWrapper } from '@/components/modal-wrapper';
import { getPhoto } from '@/lib/api';
import Image from 'next/image';
interface Props {
params: Promise<{ id: string }>;
}
export default async function PhotoModal({ params }: Props) {
const { id } = await params;
const photo = await getPhoto(id);
return (
<ModalWrapper>
<div className="relative h-[80vh] w-[80vw] max-w-4xl bg-black rounded-lg overflow-hidden">
<Image
src={photo.url}
alt={photo.title}
fill
className="object-contain"
priority
/>
<div className="absolute bottom-0 p-4 bg-gradient-to-t from-black/80 to-transparent w-full text-white">
<h2 className="text-lg font-bold">{photo.title}</h2>
</div>
</div>
</ModalWrapper>
);
}Am implementat recent pattern-ul ăsta la o platformă de imobiliare cu peste 15k de imagini indexate, unde UX-ul trebuia să fie impecabil. Voiam ca userul să dea click pe o poză din galerie, să se deschidă un modal instant, URL-ul să se schimbe în /photos/123, dar dacă dă Refresh (F5) sau trimite linkul pe WhatsApp, să se încarce pagina dedicată completă, nu modalul.
În vechiul pages/ router rezolvam asta cu tot felul de hacks urâte în query și state global care crăpau la SSR sau încurcau SEO-ul. Cu App Router din Next.js, combinația dintre Parallel Routes (sloturi cu @) și Intercepting Routes (foldere cu sintaxa (.) sau (..)) e soluția nativă, deși prima dată când te uiți pe structura de directoare zici că e un puzzle scăpat pe jos.
Structura de foldere care te zăpăcește la început
Ca să meargă treaba fără bâlbâieli, ai nevoie de un slot paralel în layout.tsx-ul părinte și de folderul de interceptare definit exact la nivelul de adâncime potrivit.
Dacă vrei ca din pagina principală sau din /feed să interceptezi ruta /photos/[id], structura în app/ arată cam așa:
app/
├── @modal/
│ └── (.)photos/
│ └── [id]/
│ └── page.tsx
├── photos/
│ └── [id]/
│ └── page.tsx
├── default.tsx
├── layout.tsx
└── page.tsx
Sintaxa (.) îi spune lui Next.js: „când navighezi din interiorul aplicației (client-side transition) către o rută din același nivel numită photos/[id], nu încărca pagina întreagă, ci randează componenta asta direct în slotul @modal”.
Legătura din Layout și capcana cu default.tsx
În app/layout.tsx, trebuie să primești slotul @modal ca prop alături de children. Modificarea e banală: randezi {children} și dedesubt {modal}.
Aici vine însă greșeala pe care am văzut-o la vreo trei colegi juniori în ultimele luni: uită să definească fișierul default.tsx.
Când nu ai niciun modal activ (adică ești pe homepage fără niciun pop-up deschis), Next.js trebuie să știe ce să pună în slotul @modal. Dacă nu găsește un default.tsx care să returneze null (atât în rădăcină, cât și în @modal), aplicația va arunca eroare 404 la prima navigare directă sau la un simplu hard-refresh.
Trade-off-uri reale și performanță
Pe partea de viteză, rezultatele sunt fantastice. La proiectul nostru am scăzut latența percepută la deschiderea imaginii de la ~350ms (când routerul trebuia să schimbe tot layout-ul) la sub 40ms, pentru că facem re-render doar pe componenta de modal peste lista deja aflată în memorie.
Dar sunt câteva minusuri pe care trebuie să le știi înainte să le promiți clienților că durează o oră implementarea:
- Managementul stării de închidere: Ca să închizi modalul, trebuie să apelezi
router.back(). Dacă userul a ajuns pe acel modal prin copy-paste la URL (link direct),router.back()îl va arunca înapoi pe Google sau pe alt site extern. A trebuit să scriem o logică custom care verifică stiva dinwindow.historyși facerouter.push('/')ca fallback. - Framer Motion și Server Components: Pagina interceptată poate fi un Server Component unde faci fetch direct la DB, dar dacă vrei animații la Exit (un-mount), va trebui să împachetezi totul într-un Client Component wrapper cu
'use client'. - Router Cache: Cache-ul agresiv din App Router poate reține starea modalului în mod neașteptat când navighezi rapid cu săgețile Stânga/Dreapta între poze.
Voi cum gestionați cazurile astea de dialoguri cu URL dedicat? Ați trecut pe intercepting routes în producție sau folosiți în continuare useSearchParams?