Parallel Routes e Intercepting Routes in Next.js 16: Modali Condivisibili senza Trucchi

Slot @, default.tsx e notazione (.), (..), (...): la guida pratica per costruire modali condivisibili con Parallel e Intercepting Routes in Next.js 16, con URL propri, back del browser e refresh gestito senza trucchi.

Aggiornato: 20 agosto 2026

I Parallel Routes e gli Intercepting Routes di Next.js 16 sono due convenzioni di file dell'App Router che permettono di renderizzare più segmenti di pagina contemporaneamente dentro lo stesso layout e di intercettare una navigazione client-side per mostrare quel contenuto in un contesto diverso, ad esempio una modale. Insieme risolvono un problema classico dei framework React: costruire modali che hanno un URL proprio, sopravvivono al refresh, tornano indietro con il tasto back del browser e sono condivisibili come link normali. In pratica: @slot per il layout, (.) per l'intercettazione.

  • I Parallel Routes si dichiarano con cartelle prefissate da @ (ad esempio @modal) e vengono passati come prop al layout.tsx genitore, senza modificare l'URL.
  • Ogni slot parallelo richiede un file default.tsx: senza di esso la build fallisce o compaiono 404 dopo un refresh.
  • Gli Intercepting Routes usano la notazione (.), (..), (..)(..) e (...) per catturare una navigazione client-side e mostrare la pagina in un contesto diverso.
  • La combinazione tipica è @modal/(.)items/[id]/page.tsx: click interno = modale, URL diretto = pagina piena.
  • L'intercettazione avviene solo su navigazione client-side (<Link>, router.push): refresh, tab nuova e URL incollato passano dalla route standard.
  • Loading, error e not-found sono per-slot: puoi mostrare uno spinner nella modale mentre il feed dietro resta interattivo.

Cosa sono i Parallel Routes in Next.js 16

Un Parallel Route è una cartella prefissata da @ (Next.js la chiama slot) che diventa una prop del layout.tsx genitore. Il risultato è che lo stesso layout può renderizzare più alberi di pagina in parallelo, ognuno con il proprio stato di loading, errore e navigazione. Vengo dai tempi del pages router e ammetto che la prima volta che ho visto una cartella chiamata @modal ho pensato fosse un errore di battitura. Poi ho capito che era esattamente la primitiva che mi mancava per costruire dashboard con pannelli indipendenti senza puntellare tutto con context provider.

La chiave è che lo slot non compare nell'URL. Se hai app/dashboard/@analytics/views/page.tsx, la rotta effettiva è /dashboard/views, non /dashboard/@analytics/views. Il carattere @ è solo un segnale per il router che quella cartella deve essere risolta in parallelo e iniettata come prop nominata nel layout. La prop children che riceverai è a sua volta uno slot implicito: app/dashboard/page.tsx equivale a app/dashboard/@children/page.tsx, quindi conviene pensarla come uno slot di default piuttosto che come qualcosa di magico.

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  notifications,
}: {
  children: React.ReactNode
  analytics: React.ReactNode
  notifications: React.ReactNode
}) {
  return (
    <div className="grid grid-cols-[1fr_320px] gap-6">
      <main>{children}</main>
      <aside className="space-y-4">
        {analytics}
        {notifications}
      </aside>
    </div>
  )
}

Ogni slot ha il proprio loading boundary, il proprio error boundary e, dettaglio importante, la propria navigazione locale. Se dentro @analytics l'utente clicca un link, aggiorna solo quel segmento senza rimontare @notifications o children. È l'idea del Partial Prerendering e streaming dell'architettura server-first portata al livello del layout: non solo pezzi che arrivano in tempi diversi, ma pezzi che navigano in tempi diversi.

Slot @, default.tsx e il ruolo dei fallback

Il primo errore che praticamente tutti commettono con i Parallel Routes è dimenticare default.tsx. La regola è semplice: ogni slot deve avere un default.tsx alla radice del suo segmento. Questo file dice a Next.js cosa mostrare nello slot quando la URL corrente non ha un match esplicito per quello slot durante una navigazione hard (refresh, URL diretto, tab nuova). Senza default.tsx, la build fallisce con un messaggio del tipo Missing Required default.js for Parallel Route, oppure a runtime vedi un 404 che non compariva mai cliccando in giro nell'applicazione.

Il comportamento è asimmetrico e vale la pena capirlo bene. Durante la navigazione soft (utente che clicca <Link>), Next.js preserva lo stato attivo di ogni slot: se stai su /dashboard con @modal vuoto e poi vai su /dashboard/impostazioni, lo slot @modal resta vuoto perché il router ricorda che non c'era nulla. Durante la navigazione hard (refresh, URL incollato), quella memoria non esiste: il server deve rendere qualcosa in ogni slot, e se non trova un page.tsx corrispondente cerca default.tsx. Se non trova neanche quello, restituisce 404.

// app/@modal/default.tsx
// Fallback vuoto: nessuna modale attiva.
export default function Default() {
  return null
}

Il pattern che uso in produzione è: default.tsx che ritorna null per ogni slot "opzionale" (modali, drawer, banner condizionali) e un default.tsx con contenuto reale per gli slot che devono sempre mostrare qualcosa (widget della dashboard, sidebar). È un file di due righe, quindi la tentazione di saltarlo è forte. Non farlo.

Intercepting Routes e la notazione con i punti

Un Intercepting Route è un modo per dire al router: "quando l'utente naviga a questa URL rimanendo dentro l'app, non mostrare la pagina che sta al percorso reale, mostra invece questa mia pagina alternativa; ma se qualcuno arriva alla stessa URL con un refresh o incollandola nel browser, allora sì, mostra la pagina reale". La convenzione usa parentesi con punti, e il numero di punti indica quanti livelli di cartelle risalire per trovare il segmento intercettato:

NotazioneSignificatoEsempio d'uso
(.)Intercetta segmenti allo stesso livello@modal/(.)photo/[id] intercetta ./photo/[id]
(..)Intercetta segmenti un livello sopra@modal/(..)products/[id] quando il modale è annidato di un livello
(..)(..)Intercetta segmenti due livelli sopraCasi di modali molto annidati in layout multipli
(...)Intercetta dalla root app/@modal/(...)login per una modale di login globale

La cosa contro-intuitiva è che (.), (..) e compagnia non contano le cartelle del filesystem, contano i segmenti di route. Le cartelle prefissate da @ e i route group con parentesi tonde non sono segmenti, quindi non li conti. È qui che quasi tutti sbagliano la prima volta: metti (.), la modale non si apre, non c'è errore visibile, e resti mezz'ora a capire che avresti dovuto scrivere (..).

Costruire una modale condivisibile passo per passo

Passiamo al caso concreto che copre il 90% degli utilizzi: una griglia di prodotti dove il click apre un quick view in modale, ma /products/123 diretto renderizza la pagina prodotto piena. La struttura di cartelle è questa:

app/
  layout.tsx              <- layout root con lo slot @modal
  page.tsx                <- homepage
  @modal/
    default.tsx           <- ritorna null
    (.)products/
      [id]/
        page.tsx          <- quick view modale
  products/
    page.tsx              <- griglia prodotti
    [id]/
      page.tsx            <- pagina prodotto piena

Il layout root riceve modal come prop e lo posiziona dopo children, così la modale finisce sopra alla pagina nel DOM order. Non serve createPortal: Next.js monta lo slot dove tu lo metti nel JSX del layout.

// app/layout.tsx
export default function RootLayout({
  children,
  modal,
}: {
  children: React.ReactNode
  modal: React.ReactNode
}) {
  return (
    <html lang="it">
      <body>
        {children}
        {modal}
        <div id="modal-root" />
      </body>
    </html>
  )
}

La modale intercettata è una pagina normale, con la differenza che vive dentro @modal/(.)products/[id]/page.tsx. Uso un componente client per gestire l'overlay e la chiusura con il tasto back:

// app/@modal/(.)products/[id]/page.tsx
import { getProduct } from '@/lib/products'
import { Modal } from '@/components/modal'

export default async function ProductModal({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const product = await getProduct(id)

  return (
    <Modal>
      <h2>{product.name}</h2>
      <p>{product.description}</p>
      <span>€{product.price.toFixed(2)}</span>
    </Modal>
  )
}
// components/modal.tsx
'use client'

import { useRouter } from 'next/navigation'
import { useEffect, useRef } from 'react'

export function Modal({ children }: { children: React.ReactNode }) {
  const router = useRouter()
  const dialogRef = useRef<HTMLDialogElement>(null)

  useEffect(() => {
    dialogRef.current?.showModal()
  }, [])

  return (
    <dialog
      ref={dialogRef}
      onClose={() => router.back()}
      className="rounded-lg p-6 backdrop:bg-black/40"
    >
      {children}
      <form method="dialog" className="mt-4">
        <button>Chiudi</button>
      </form>
    </dialog>
  )
}

Il tocco importante è onClose={() => router.back()}. Quando l'utente preme Esc o clicca fuori, l'elemento <dialog> emette close, e noi facciamo pop della history. Così la modale si chiude e l'URL torna a /products, che è esattamente quello che l'utente si aspetta. Se invece l'utente condivide l'URL /products/123, il quick view non entra in gioco: Next.js vede una navigazione hard, cerca @modal/(.)products/[id]/page.tsx, non lo intercetta e cade sulla route reale products/[id]/page.tsx, che renderizza la pagina piena.

Qual è la differenza tra Parallel Routes e Intercepting Routes?

La confusione tra le due convenzioni è frequente perché nei tutorial vengono sempre mostrate insieme, ma risolvono problemi diversi:

  • Parallel Routes risponde alla domanda: "come renderizzo più pagine indipendenti nello stesso layout?". La primitiva è lo slot con @. Serve per dashboard, drawer, sezioni tabellate con navigazione locale, feed multi-colonna.
  • Intercepting Routes risponde alla domanda: "come mostro lo stesso URL in due modi diversi a seconda di come l'utente ci arriva?". La primitiva è la notazione con i punti. Serve per modali, quick view, preview e overlay che devono comunque essere navigabili come pagine piene.

Puoi usarli separatamente. Un dashboard con quattro pannelli non ha bisogno di intercettazione. Una modale di preview non ha bisogno di uno slot parallelo se sei disposto a metterla dentro children (ma perderesti la possibilità di renderizzarla sopra tutto il resto senza rimontare la pagina sotto). La combinazione dei due, slot @modal più intercettazione (.), è potente perché la pagina dietro alla modale non viene rimontata: lo scroll, lo stato dei form, i video in riproduzione restano dove erano.

Loading, error e not-found nei singoli slot

Un aspetto poco documentato è che ogni slot ha il proprio set di file speciali. Puoi mettere loading.tsx, error.tsx e not-found.tsx dentro @modal/ e reagiranno solo agli eventi di quel segmento. Nella pratica, questo significa poter mostrare uno spinner dentro la modale mentre carica i dati del prodotto, senza far lampeggiare tutta la griglia dietro.

// app/@modal/(.)products/[id]/loading.tsx
export default function Loading() {
  return (
    <div className="fixed inset-0 grid place-items-center bg-black/40">
      <div className="rounded-lg bg-white p-6">Caricamento…</div>
    </div>
  )
}

Lo stesso vale per gli errori. Se getProduct(id) lancia un'eccezione, viene catturata dall'error.tsx più vicino nello slot @modal, non da quello di children. Puoi mostrare un messaggio di errore dentro la modale e lasciare la griglia dietro perfettamente funzionante. Se combini questo con le Server Actions per la validazione e la sicurezza, ottieni form dentro modali che restituiscono errori validati senza mai perdere il contesto della pagina sottostante.

Perché la modale mostra 404 al refresh e altri errori comuni

Ecco l'elenco dei problemi che vedo ripetersi nei code review, in ordine di frequenza:

  1. 404 al refresh della modale. È il problema di design: la route intercettata esiste solo per navigazione client-side. Devi anche avere una pagina reale (non intercettata) allo stesso path, tipicamente products/[id]/page.tsx. Senza quella, il refresh o l'URL incollato produce 404. Non è un bug, è la specifica.
  2. Manca default.tsx in uno slot. Errore di build in produzione, spesso invisibile in dev. La regola pigra: ogni cartella @qualcosa deve avere un default.tsx, punto.
  3. Notazione con i punti sbagliata. Ti aspetti (.), ma il segmento è un livello sopra a causa di uno slot o route group in mezzo, quindi serve (..). Verifica sempre con il tab Network del browser.
  4. Contenuto duplicato tra modale e pagina piena. Le due versioni sono file separati, quindi finiscono per divergere. Estrai il markup in un componente condiviso (<ProductDetail />) e importalo in entrambe le pagine.
  5. Modale che rimonta la pagina dietro. Se stai vedendo lo scroll che torna a zero quando apri la modale, hai messo lo slot @modal in un layout troppo profondo e la navigazione sta interessando children. Sposta lo slot al layout root.
  6. Modali che non si chiudono con il back. Assicurati che router.back() sia legato all'evento close del <dialog>, non a un click su un div overlay custom, altrimenti la history stack si disallinea.

Quando conviene davvero usarli (e quando no)

Onestamente, per anni ho costruito modali con state locale e useState, e per il 60% dei casi va ancora benissimo. Se la modale è un semplice conferma-di-cancellazione, non ha bisogno di URL propria, non deve essere condivisibile, non deve sopravvivere al refresh. Allora non tirare in ballo Parallel Routes. Aggiungere due cartelle e un default.tsx per uno stato booleano è over-engineering.

Le situazioni in cui i Parallel Routes più Intercepting Routes fanno la differenza vera:

  • Quick view di prodotti in un e-commerce (Amazon, Zalando fanno esattamente questo).
  • Feed con dettaglio-in-modale come Instagram, Pinterest, Dribbble.
  • Dashboard con drawer di dettaglio (record di CRM, ticket di supporto).
  • Wizard multi-step dove ogni step ha un URL condivisibile e il refresh non deve perdere lo stato.
  • Login/signup modali che devono anche funzionare come pagina piena (per SEO, per gli utenti che disabilitano JS, per gli screen reader).

Se il tuo caso è in questa lista, il costo iniziale di capire slot e notazione con i punti si ripaga velocemente. Se stai scrivendo un dialog di conferma, tieni useState. Per un panorama più ampio sulle strategie di caching che si combinano bene con questi pattern (perché una modale intercettata beneficia enormemente dai dati pre-cachati), dai un occhio alla guida su caching e revalidazione in Next.js 16.

Le fonti ufficiali che uso come riferimento per queste convenzioni sono la documentazione dei Parallel Routes su nextjs.org e la sezione dedicata agli Intercepting Routes nell'App Router. Sono le uniche versioni sempre allineate con l'ultima release, e chiariscono edge case che i tutorial di terze parti spesso saltano.

Domande frequenti

Che cos'è default.tsx nei Parallel Routes di Next.js?

default.tsx è il file che Next.js renderizza in uno slot parallelo quando la URL corrente non ha un page.tsx corrispondente per quello slot, tipicamente durante una navigazione hard o un refresh. Senza di esso la build fallisce o compaiono 404 apparentemente casuali. Per gli slot opzionali come le modali, un default.tsx che ritorna null è sufficiente.

Perché la mia modale intercettata mostra 404 dopo il refresh?

Perché l'intercettazione funziona solo su navigazione client-side. Al refresh, Next.js cerca la pagina reale al percorso corrispondente (ad esempio products/[id]/page.tsx): se non esiste, restituisce 404. La soluzione è mantenere sempre una versione "piena" della pagina come fallback, in modo che l'URL sia condivisibile.

Qual è la differenza tra (.), (..) e (...) in Next.js?

Sono i tre livelli di intercettazione: (.) intercetta segmenti allo stesso livello, (..) un livello sopra, (..)(..) due livelli sopra, e (...) intercetta dalla root dell'app. Attenzione: i livelli si contano sui segmenti di route effettivi, ignorando gli slot @ e i route group tra parentesi tonde.

Posso usare i Parallel Routes senza gli Intercepting Routes?

Sì, e molto spesso conviene farlo. I Parallel Routes da soli sono perfetti per dashboard, sidebar dinamiche o feed multi-pannello. Gli Intercepting Routes servono solo quando vuoi che lo stesso URL si presenti in due modi diversi a seconda di come l'utente ci arriva, tipicamente il pattern modale più pagina piena.

I Parallel Routes influiscono sull'URL della pagina?

No. Le cartelle prefissate da @ non sono segmenti di route: sono solo un modo per passare props nominate al layout genitore. Una struttura app/dashboard/@analytics/views/page.tsx serve l'URL /dashboard/views, non /dashboard/@analytics/views.

Ben Howard
Sull'Autore Ben Howard

Full-stack Next.js developer who's been with the framework since pages-only days. Slowly warming up to App Router.