eduardweb.
App RouterAvansat#nextjs#react#app-router#routing

Modal pe /photos/[id] cu parallel și intercepting routes în Next.js App Router

De Paul Ene, 15 oct. 2026 · 6 vizualizări · 2 like-uri

Postat acum 1 zi
typescript
// app/layout.tsx
export default function RootLayout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <html lang="ro">
      <body className="relative min-h-screen bg-neutral-900 text-white">
        {children}
        {modal}
      </body>
    </html>
  );
}

// app/@modal/default.tsx
export default function DefaultModal() {
  return null;
}

// app/@modal/(.)photos/[id]/page.tsx
import { useRouter } from 'next/navigation';

export default function PhotoModal({ params }: { params: { id: string } }) {
  const router = useRouter();
  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
      <div className="relative max-w-2xl rounded-xl bg-neutral-800 p-6">
        <button 
          onClick={() => router.back()} 
          className="absolute right-4 top-4 text-sm text-neutral-400 hover:text-white"
        >
          Închide (Esc)
        </button>
        <h2 className="text-xl font-bold">Poză: {params.id}</h2>
        <p className="mt-2 text-neutral-300">Randată direct în overlay fără reload.</p>
      </div>
    </div>
  );
}

Pattern-ul clasic de Instagram sau Twitter: dai click pe o poză din feed, URL-ul se schimbă în /photos/123, ți se deschide un modal peste listă, dar dacă dai refresh sau trimiți link-ul pe Slack, celălalt vede direct pagina întreagă a pozei. În Pages Router improvizam cu query params și shallow: true, dar era o mizerie la mentenanță. În App Router avem Parallel Routes (@slot) combinate cu Intercepting Routes ((..)photos), însă primele trei încercări mi-au dat bătăi serioase de cap.

La un proiect foto cu vreo 15k vizitatori unici pe lună, am vrut exact flow-ul ăsta fără să recreez stări globale în Zustand sau Redux. Voiam ca routerul să fie sursa unică de adevăr.

Structura de foldere care chiar funcționează

Secretul stă în convențiile de numire. Dacă greșești parantezele sau uiți un default.tsx, Next.js pur și simplu îți va da 404 sau va randa o pagină goală la refresh.

Arborele arată cam așa:

app/
├── layout.tsx
├── page.tsx
├── @modal/
│   ├── default.tsx
│   └── (.)photos/
│       └── [id]/
│           └── page.tsx
└── photos/
    └── [id]/
        └── page.tsx

Nivelul contează critic. Folosești (.)photos cu un singur punct dacă interceptezi un segment la același nivel din arborele rutei, sau (..)photos dacă ești cu un nivel mai adânc. În exemplul de mai sus, pentru că @modal este un slot paralel montat direct în layout-ul rădăcină (app/layout.tsx), interceptorul e la același nivel cu ruta normală /photos.

Fișierul pe care toată lumea îl uită: default.tsx

Când navighezi client-side prin Link, Next.js interceptează cererea și randează componenta din app/@modal/(.)photos/[id]/page.tsx în slotul {modal} din layout.tsx.

Dar ce se întâmplă când ești pe home page (/) și nu ai niciun modal deschis? Aici intervine default.tsx. Dacă nu pui un default.tsx în interiorul @modal care să returneze null, la primul refresh pe homepage Next.js nu știe ce să pună în slot și crapă sau dă fallback urât.

Trade-off-uri pe bune

Merge impecabil pentru experiența de browsing: utilizatorul are back-button funcțional pe istoricul browserului (apeși Back, se închide modalul, revii la scroll-ul intact din feed). Salvezi bandwidth și vreo 250ms de latență la navigare internă fiindcă nu reîncarci layout-ul de bază.

Unde devine nasol? La cache invalidation. Dacă din interiorul modalului faci un server action care editează tag-urile pozei și dai revalidatePath('/photos/[id]'), s-ar putea să observi că feed-ul din spate rămâne desincronizat până la un refresh manual. Trebuie să revalidezi ambele căi sau să faci update optimist în client.

Altă capcană: dacă vrei să închizi modalul programatic, nu te baza pe router.push('/') dacă utilizatorul a venit direct pe acel URL via external link, pentru că îi vei strica istoricul din browser. Cel mai curat e un simplu router.back() legat la butonul de close sau la click pe backdrop.

Voi ați trecut la pattern-ul ăsta în producție sau preferați în continuare modaluri simple controlate prin query strings?

Răspunsuri 0

Se încarcă răspunsurile…

Loghează-te pentru a răspunde

Doar membrii comunității pot lăsa comentarii.