Next.js
Parallel e intercepting routes
Explica slots navegables, default.tsx e Intercepting Routes para crear dashboards, paneles y modales con URL propia y comportamiento correcto al recargar.
- Última actualización
- Actualizada
- Nivel
- Profundización
Next.js
Explica slots navegables, default.tsx e Intercepting Routes para crear dashboards, paneles y modales con URL propia y comportamiento correcto al recargar.
Parallel Routes permiten que un layout renderice varias ramas navegables al mismo tiempo. Intercepting Routes permiten mostrar una ruta canónica dentro del contexto visual de otra ruta, por ejemplo como modal. Juntas modelan navegación avanzada, pero exigen diseñar hard navigation, historial, defaults, foco y recuperación.
Un árbol normal tiene una rama activa por segmento:
DashboardLayout
└─ children
└─ OrdersPageParallel Routes agregan slots nombrados:
DashboardLayout
├─ children
├─ analytics
└─ activityCada slot puede mantener su propio subestado de ruta durante navegación cliente.
Intercepting Routes cambian cómo se presenta una ruta según el contexto desde el que se navega:
/feed
└─ click /photos/42
→ URL /photos/42
→ modal sobre feed
refresh /photos/42
→ página completa de la fotoLa URL sigue siendo compartible y el botón atrás puede cerrar el modal.
Una pantalla puede necesitar regiones que navegan independientemente:
Sin slots, todas las regiones deben pertenecer a una sola page o administrar subrutas manualmente mediante query strings y state.
Parallel Routes hacen que esas regiones formen parte del router.
El prefijo @ define un named slot:
app/dashboard/
├─ @analytics/
│ └─ page.tsx
├─ @activity/
│ └─ page.tsx
├─ layout.tsx
└─ page.tsxchildren es un slot implícito.
El layout recibe cada slot:
// app/dashboard/layout.tsx
import type { ReactNode } from "react";
type DashboardLayoutProps = {
children: ReactNode;
analytics: ReactNode;
activity: ReactNode;
};
export default function DashboardLayout({
children,
analytics,
activity,
}: DashboardLayoutProps) {
return (
<div className="dashboard">
<main>{children}</main>
<aside aria-label="Analítica">{analytics}</aside>
<section aria-label="Actividad reciente">{activity}</section>
</div>
);
}Las carpetas @analytics y @activity no aparecen en la URL. Son props de composición del layout.
@analytics
→ slot del layout
→ no modifica pathname
analytics
→ segmento normal
→ añade /analytics al pathnameEsto importa al calcular Intercepting Routes: los marcadores cuentan segmentos de ruta, no carpetas físicas ni slots.
Los slots pueden tener subpáginas:
app/dashboard/
├─ @analytics/
│ ├─ page.tsx
│ └─ monthly/page.tsx
├─ @activity/
│ ├─ page.tsx
│ └─ mentions/page.tsx
└─ layout.tsxDurante soft navigation, Next.js recuerda el estado activo de cada slot. Navegar a una subpágina de analytics puede preservar la rama activa de activity.
analytics = monthly
activity = mentionsEl pathname visible no describe necesariamente todo el estado interno de los slots. Esa capacidad es potente, pero dificulta reproducir el árbol desde una carga directa.
El router cliente conoce el estado anterior de los slots:
current tree + destination
→ preserve unmatched slot stateEn una recarga o acceso directo, el servidor solo conoce la URL:
URL
→ no existe historial de slot activo
→ necesita default.tsxSi un slot no coincide con la URL actual, Next.js busca default.tsx.
app/dashboard/
├─ @analytics/
│ ├─ default.tsx
│ └─ page.tsx
├─ @activity/
│ ├─ default.tsx
│ └─ page.tsx
└─ layout.tsx// app/dashboard/@analytics/default.tsx
export default function AnalyticsDefault() {
return null;
}Puede devolver:
null cuando la región debe quedar vacía.Sin default.tsx, una carga completa puede fallar o mostrar not found para slots nombrados cuyo estado no puede inferirse.
También considera default.tsx para el slot implícito children cuando la estructura avanzada lo requiere.
Un slot puede representar estado de autenticación:
app/
├─ @auth/
│ ├─ login/page.tsx
│ └─ default.tsx
├─ @dashboard/
│ ├─ page.tsx
│ └─ default.tsx
└─ layout.tsxEl layout decide qué rama mostrar según sesión.
Esto puede organizar UI, pero la autorización sigue perteneciendo a datos y operaciones. Un usuario no autorizado no debe obtener contenido sensible aunque el layout no lo muestre.
Un Client Component puede leer el segmento dentro de un slot:
"use client";
import { useSelectedLayoutSegment } from "next/navigation";
export function AnalyticsTabs() {
const segment = useSelectedLayoutSegment("analytics");
return (
<nav aria-label="Vistas de analítica">
<Tab active={segment === null} href="/dashboard">Resumen</Tab>
<Tab active={segment === "monthly"} href="/dashboard/monthly">
Mensual
</Tab>
</nav>
);
}El nombre del slot se pasa sin @.
No hagas depender toda la arquitectura del layout de un hook cliente si el router puede expresar la estructura directamente.
Cada slot puede tener boundaries propias:
@analytics/
├─ loading.tsx
├─ error.tsx
└─ page.tsxEsto permite que analytics esté pendiente o falle sin reemplazar activity.
Demasiadas boundaries pueden crear una pantalla fragmentada donde cada región muestra estados independientes sin contexto. Diseña unidades visuales completas.
Un marcador de intercepción indica que una ruta debe cargarse dentro del layout actual durante navegación cliente.
Patrones:
(.)segment → mismo nivel
(..)segment → un nivel de segmento arriba
(..)(..)segment → dos niveles arriba
(...)segment → desde raíz de appLa cuenta ignora:
@folder.Estructura conceptual:
app/
├─ feed/
│ ├─ page.tsx
│ └─ @modal/
│ ├─ default.tsx
│ └─ (.)photos/[photoId]/page.tsx
└─ photos/[photoId]/page.tsxDependiendo de la ubicación concreta, el marcador debe apuntar al segmento canónico de photos. La documentación oficial debe consultarse al construir el árbol porque el cálculo se basa en segmentos, no en indentación visual.
// app/photos/[photoId]/page.tsx
export default async function PhotoPage({
params,
}: PageProps<"/photos/[photoId]">) {
const { photoId } = await params;
const photo = await getPhoto(photoId);
if (!photo) notFound();
return <FullPhotoPage photo={photo} />;
}Esta page funciona al abrir o recargar /photos/42.
// page inside @modal intercepting branch
export default async function PhotoModalPage({
params,
}: PageProps<"/photos/[photoId]">) {
const { photoId } = await params;
const photo = await getPhoto(photoId);
if (!photo) notFound();
return <PhotoModal photo={photo} />;
}Durante navegación desde feed, Next.js mantiene el feed como contexto y renderiza el contenido dentro de @modal.
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useRef } from "react";
export function PhotoModal({ photo }: { photo: Photo }) {
const router = useRouter();
const closeButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
closeButtonRef.current?.focus();
}, []);
function close() {
router.back();
}
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="photo-modal-title"
onKeyDown={(event) => {
if (event.key === "Escape") close();
}}
>
<h1 id="photo-modal-title">{photo.title}</h1>
<button ref={closeButtonRef} type="button" onClick={close}>
Cerrar
</button>
<PhotoContent photo={photo} />
</div>
);
}Este ejemplo simplifica focus trap, fondo inert y restauración al trigger. En producción usa una primitive accesible o implementa el patrón completo.
router.back() funciona cuando el modal se abrió desde el feed:
/feed
→ push /photos/42
→ back
→ /feedPero si el usuario llegó directamente a /photos/42, no debería estar en la variante modal; verá la page completa.
Si tu modal puede abrirse mediante replace, enlaces externos o historiales inesperados, define un fallback seguro:
function close() {
if (window.history.length > 1) {
router.back();
} else {
router.replace("/feed");
}
}El acceso a window solo pertenece al Client Component.
La principal ventaja sobre un modal local es que la URL:
Si el modal no necesita estas propiedades, state local suele ser más simple.
La misma ruta puede interceptarse desde distintos lugares:
/feed → photo modal
/search → photo preview
/profile → photo modalCada contexto puede definir su propia presentación interceptada y compartir la page canónica.
Evita duplicar reglas de datos y autorización entre las variantes. Extrae un loader o Data Access Layer común.
La URL canónica /photos/42 debe tener metadata coherente incluso cuando se presenta como modal. El título visible del documento y announcements durante navegación deben seguir siendo comprensibles.
No generes canonicals diferentes para la variante interceptada; no es una URL distinta.
Al abrir un modal de ruta:
El router puede preservar el árbol, pero el bloqueo de scroll y accesibilidad pertenecen al componente de modal.
Flujo esperado:
trigger con focus
→ abrir modal
→ focus entra al diálogo
→ Tab permanece dentro
→ Escape o cerrar
→ focus vuelve al triggerUna route interception no implementa este comportamiento automáticamente.
Cada slot puede transmitir contenido de manera independiente:
Dashboard shell
├─ analytics skeleton → analytics result
└─ activity skeleton → activity resultEsto reduce bloqueo, pero puede aumentar el número de requests de prefetch por segmento en el modelo actual. Next.js 16.2 ofrece experimental.prefetchInlining para evaluar otro trade-off, pero debe marcarse experimental.
No habilites flags para corregir una arquitectura de slots innecesariamente fragmentada.
/inbox
→ lista completa
/inbox/messages/42 desde lista
→ panel lateral sobre inbox
refresh /inbox/messages/42
→ página de detalle completa o layout de inbox reproduciblePuedes usar:
children para lista.@detail para panel.default.tsx para cerrar panel en hard navigation no coincidente.Si el detalle siempre debe convivir con la lista, una nested route normal puede ser suficiente. Interception aporta cuando la misma entidad también necesita una page canónica independiente.
Cada variante debe consultar mediante la misma autorización:
async function requireAccessiblePhoto(photoId: string, userId: string) {
const photo = await repository.findAccessible({ photoId, userId });
if (!photo) notFound();
return photo;
}No confíes en que el modal solo puede abrirse desde un feed filtrado. La URL es invocable directamente.
Debe existir un default.tsx razonable para las ramas no recuperables.
La variante canónica debe funcionar sin historia previa.
La siguiente navegación o refresh debe mostrar not found y cerrar la experiencia de forma coherente.
Evita stacks ilimitados de modales representados por historial sin reglas de cierre.
La consulta y mutación del modal deben volver a autorizar.
Un modal desktop puede necesitar convertirse en página o sheet en pantallas pequeñas sin romper semántica.
La soft navigation funciona, pero el refresh falla. Prueba cargas directas.
El marcador (..) apunta al destino incorrecto cuando hay slots o groups.
Introduce routing, cache, metadata y estados que un boolean local no necesita.
La UI parece modal, pero teclado navega por el fondo.
La page canónica y modal divergen. Comparte loader autorizado.
Puede llevar al usuario fuera del producto si no existe entrada interna anterior.
Una excepción puede reemplazar más regiones de las esperadas.
El router puede conservar subestado que no es visible en la URL.
/photos/42 directamente.@slot crea una prop de layout, no un segmento URL.default.tsx para ramas no inferibles.default.tsx aunque soft navigation funcione?@analytics y analytics?(..) no cuenta la carpeta @modal?Server Components en Next.js cambia el foco desde la estructura del router hacia dónde se ejecuta cada componente, qué código viaja al navegador y cómo se cargan datos.