Rutas Interceptadas
Las rutas interceptadas te permiten cargar una ruta dentro del layout actual durante la navegación del lado del cliente mientras se preserva la página completa en acceso directo o al actualizar. El caso de uso clásico es un modal que se abre en línea pero tiene su propia URL compartible.
Receta
Tarjeta de referencia rápida — lista para copiar y pegar.
Convention Coincidencias
(.)folder Mismo nivel (como ./folder)
(..)folder Un nivel hacia arriba (como ../folder)
(..)(..)folder Dos niveles hacia arriba (como ../../folder)
(...)folder Nivel raíz (como /folder desde cualquier lugar)
app/
├── layout.tsx
├── @modal/
│ ├── default.tsx # No renderiza nada cuando ningún modal está activo
│ └── (.)photo/[id]/page.tsx # Intercepta /photo/:id → renderiza en modal
├── photo/[id]/
│ └── page.tsx # Página completa para /photo/:id (acceso directo)
└── page.tsx # Página de galería con miniaturas de fotos
Cuándo usarlo: Galerías de fotos con vistas previas en modal, modales de inicio de sesión con URLs /login, vistas previas de detalles de elementos en una lista, o cualquier patrón donde una ruta deba mostrarse como modal durante la navegación pero como página completa en acceso directo.
Ejemplo Funcional
// app/layout.tsx — Layout raíz con slot modal
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html lang="es">
<body>
{children}
{modal}
</body>
</html>
);
}// app/@modal/default.tsx — No renderiza nada cuando no se intercepta un modal
export default function ModalDefault() {
return null;
}// app/@modal/(.)photo/[id]/page.tsx — Ruta interceptada (se muestra como modal)
"use client";
import { useRouter } from "next/navigation";
export default function PhotoModal({
params,
}: {
params: Promise<{ id: string }>;
}) {
const router = useRouter();
// En un componente cliente, usa React.use() para desempacar params
const { id } = require("react").use(params);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
onClick={() => router.back()}
>
<div
className="relative max-h-[90vh] max-w-3xl overflow-hidden rounded-lg bg-white p-4"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => router.back()}
className="absolute right-2 top-2 text-gray-500 hover:text-gray-800"
>
Cerrar
</button>
<img
src={`/photos/${id}.jpg`}
alt={`Foto ${id}`}
className="max-h-[80vh] w-auto"
/>
<p className="mt-2 text-center text-sm text-gray-600">Foto #{id}</p>
</div>
</div>
);
}// app/photo/[id]/page.tsx — Página completa (acceso directo o actualización)
export default async function PhotoPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return (
<div className="flex min-h-screen flex-col items-center justify-center p-8">
<img
src={`/photos/${id}.jpg`}
alt={`Foto ${id}`}
className="max-h-[80vh] rounded-lg"
/>
<h1 className="mt-4 text-2xl font-bold">Foto #{id}</h1>
<a href="/" className="mt-4 text-blue-600 hover:underline">
Volver a la galería
</a>
</div>
);
}// app/page.tsx — Página de galería con enlaces que activan la interception
import Link from "next/link";
const photos = Array.from({ length: 12 }, (_, i) => ({
id: String(i + 1),
title: `Foto ${i + 1}`,
}));
export default function GalleryPage() {
return (
<div className="p-8">
<h1 className="mb-6 text-3xl font-bold">Galería de Fotos</h1>
<div className="grid grid-cols-3 gap-4">
{photos.map((photo) => (
<Link key={photo.id} href={`/photo/${photo.id}`}>
<img
src={`/photos/${photo.id}.jpg`}
alt={photo.title}
className="aspect-square rounded-lg object-cover hover:opacity-80"
/>
</Link>
))}
</div>
</div>
);
}Inmersión Profunda
Cómo Funciona
- La interception solo ocurre en navegación del lado del cliente. Cuando un usuario hace clic en un
<Link>, Next.js renderiza la ruta interceptada en lugar de la ruta de destino. - El acceso directo o la actualización de la página evita la interception. Escribir la URL o actualizar carga la versión de página completa en
app/photo/[id]/page.tsx. - Las rutas interceptadas usan slots de rutas paralelas. La carpeta
@modales una ruta paralela. La ruta interceptada(.)photo/[id]se coloca dentro de ella. - La convención de puntos indica profundidad relativa.
(.)significa mismo nivel,(..)significa un nivel arriba. Esto es relativo al segmento de ruta, no al sistema de archivos. default.tsxes requerido en el slot. Sin él, el slot no renderiza nada en rutas no interceptadas, y Next.js puede lanzar errores en navegación dura.router.back()cierra el modal. Ya que la interception usa navegación del lado del cliente, volver atrás restaura la vista anterior.- La página completa aún existe en su URL. La interception no elimina la ruta original — solo la oculta durante navegación suave.
Variaciones
# Modal de inicio de sesión interceptado desde cualquier página
app/
├── layout.tsx # Incluye slot @modal
├── @modal/
│ ├── default.tsx
│ └── (...)login/page.tsx # Intercepta /login desde nivel raíz
├── login/
│ └── page.tsx # Página de inicio de sesión completa (acceso directo)
└── page.tsx
// app/@modal/(...)login/page.tsx — Modal de inicio de sesión
"use client";
import { useRouter } from "next/navigation";
export default function LoginModal() {
const router = useRouter();
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg bg-white p-8">
<h2 className="text-xl font-bold">Iniciar Sesión</h2>
<form className="mt-4 space-y-4">
<input type="email" placeholder="Correo Electrónico" className="w-full rounded border p-2" />
<input type="password" placeholder="Contraseña" className="w-full rounded border p-2" />
<button type="submit" className="w-full rounded bg-blue-600 py-2 text-white">
Iniciar Sesión
</button>
</form>
<button onClick={() => router.back()} className="mt-4 text-sm text-gray-500">
Cancelar
</button>
</div>
</div>
);
}# Interception anidada — detalle de elemento en una lista
app/
├── feed/
│ ├── layout.tsx
│ ├── @detail/
│ │ ├── default.tsx
│ │ └── (..)post/[id]/page.tsx # Intercepta /post/:id
│ └── page.tsx # Lista de feed
├── post/[id]/
│ └── page.tsx # Página de post completa
Notas de TypeScript
// Las páginas de rutas interceptadas tienen los mismos props que las páginas regulares
interface InterceptedPageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
// Layout recibiendo un slot de ruta paralela
interface LayoutWithModalProps {
children: React.ReactNode;
modal: React.ReactNode; // El slot @modal
}Trampas
- La convención de puntos se refiere a segmentos de rutas, no directorios del sistema de archivos.
(..)sube un segmento de ruta, lo que puede no corresponder a un nivel de directorio si están involucrados grupos de rutas. default.tsxdebe existir en el slot. Sin él, navegar lejos de la ruta interceptada y luego volver puede causar errores.- Actualizar la página carga la ruta completa, no la interceptada. Los usuarios que compartan la URL verán la página completa, no el modal — esto es intencional.
- Los grupos de rutas
(group)cuentan como un segmento para la convención de puntos. Si tu ruta interceptada está dentro de un grupo de rutas, puedes necesitar un nivel(..)adicional. - La navegación hacia atrás puede no funcionar como se espera con historiales complejos. Si el usuario navegó a través de múltiples rutas interceptadas,
router.back()extrae la última entrada, que podría ser otro modal. - Las rutas interceptadas añaden complejidad. Si no necesitas el comportamiento de URL compartible como página completa, un estado modal simple del lado del cliente es más simple.
- Los slots de rutas paralelas deben ser hijos directos de un layout. No puedes anidar
@modalarbitrariamente — debe ser un hermano del layout que lo renderiza.
Alternativas
| Enfoque | Cuándo Usar |
|---|---|
| Estado modal del lado del cliente | Modales simples sin URLs compartibles |
Parámetro de consulta modal (?modal=photo&id=1) | Estado compartible sin complejidad de interception de rutas |
| Rutas Paralelas sin interception | Vistas lado a lado que no son modales |
Elemento dialog con useSearchParams | Modales ligeros impulsados por parámetros de búsqueda URL |
Preguntas Frecuentes
¿Cuándo ocurre la interception de rutas y cuándo no?
- La interception ocurre solo durante navegación del lado del cliente (hacer clic en un
<Link>) - El acceso directo, actualización de página, o enlaces compartidos evitan la interception y cargan la versión de página completa
¿Qué significan las convenciones de puntos (.), (..), (..)(..), y (...)?
(.)coincide con el mismo nivel de ruta(..)coincide con un nivel de ruta arriba(..)(..)coincide con dos niveles de ruta arriba(...)coincide desde el nivel raíz
¿Por qué es requerido default.tsx en el slot @modal?
Sin default.tsx, navegar lejos de la ruta interceptada y luego volver puede causar errores. Proporciona un fallback (típicamente devolviendo null) cuando no hay modal activo.
¿Cómo descartas un modal interceptado?
Llama router.back() del hook useRouter. Ya que la interception usa navegación del lado del cliente, volver atrás restaura la vista anterior.
"use client";
import { useRouter } from "next/navigation";
const router = useRouter();
// En el botón de cerrar:
<button onClick={() => router.back()}>Cerrar</button>Trampa: ¿Las convenciones de puntos se refieren a directorios del sistema de archivos o segmentos de rutas?
Segmentos de rutas, no directorios del sistema de archivos. Esta distinción importa cuando están involucrados grupos de rutas, porque los grupos de rutas (group) cuentan como un segmento para la convención de puntos y pueden requerir un nivel (..) adicional.
¿La ruta interceptada reemplaza la ruta original?
No. La página completa original aún existe en su URL. La interception solo la oculta durante navegación suave (lado del cliente). La original se renderiza en acceso directo o actualización.
¿Cuál es la relación entre rutas interceptadas y rutas paralelas?
Las rutas interceptadas se colocan dentro de slots de rutas paralelas (p. ej., @modal). El slot se renderiza como un prop en el layout padre junto a children, permitiendo que el modal y la página se muestren simultáneamente.
¿Cuáles son los tipos de TypeScript para una página de ruta interceptada?
interface InterceptedPageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{
[key: string]: string | string[] | undefined;
}>;
}
interface LayoutWithModalProps {
children: React.ReactNode;
modal: React.ReactNode;
}¿Cómo escribes los params en un componente cliente de ruta interceptada?
Usa React.use() para desempacar la Promise ya que no puedes usar await en Componentes Cliente.
"use client";
import { use } from "react";
export default function Modal({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
return <div>Elemento {id}</div>;
}Trampa: ¿Qué sucede con historiales de navegación complejos y múltiples rutas interceptadas?
router.back() extrae la última entrada del historial, que podría ser otro modal en lugar de la página sin modal. Los usuarios navegando a través de múltiples rutas interceptadas pueden necesitar volver varias veces.
¿Cuándo deberías usar un modal simple del lado del cliente en lugar de rutas interceptadas?
Cuando no necesitas una URL compartible para el contenido del modal. Las rutas interceptadas añaden complejidad del sistema de archivos. Si el modal no necesita su propia URL o fallback de página completa, el estado del lado del cliente es más simple.
¿Puede @modal estar anidado dentro de otro @slot?
No. Los slots de rutas paralelas deben ser hijos directos de un directorio layout. No puedes anidar @modal dentro de otro slot.
Relacionado
- Rutas Paralelas — segmentos @slot que permiten interception
- Navegación —
useRouterpara descartar modales - Rutas Dinámicas — segmentos parametrizados en rutas interceptadas
- Conceptos Básicos de App Router — descripción general de convenciones de archivo