Rutas paralelas
Las rutas paralelas utilizan carpetas @slot para renderizar múltiples páginas simultáneamente dentro de un único layout. Cada slot es un segmento de ruta independiente con su propio estado de loading, error y navegación.
Receta
Tarjeta de receta de referencia rápida — lista para copiar y pegar.
app/
├── layout.tsx # Recibe @analytics y team como props
├── page.tsx # Contenido principal para /
├── @analytics/
│ ├── page.tsx # Panel de análisis para /
│ ├── default.tsx # Alternativa para sub-rutas sin coincidencia
│ └── loading.tsx # Estado de loading independiente
├── @team/
│ ├── page.tsx # Panel de equipo para /
│ └── default.tsx # Alternativa para sub-rutas sin coincidencia
// app/layout.tsx
export default function Layout({
children,
analytics,
team,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
team: React.ReactNode;
}) {
return (
<div>
{children}
<div className="grid grid-cols-2 gap-4">
{analytics}
{team}
</div>
</div>
);
}Cuándo usarlo: Dashboards con paneles independientes, contenido condicional basado en autenticación, o cualquier layout que componga múltiples vistas independientes.
Ejemplo funcional
// app/dashboard/layout.tsx — Dashboard con tres slots paralelos
export default function DashboardLayout({
children,
metrics,
activity,
}: {
children: React.ReactNode;
metrics: React.ReactNode;
activity: React.ReactNode;
}) {
return (
<div className="p-6">
<h1 className="mb-6 text-2xl font-bold">Dashboard</h1>
<div className="mb-6">{children}</div>
<div className="grid grid-cols-2 gap-6">
<section>
<h2 className="mb-2 text-lg font-semibold">Métricas</h2>
{metrics}
</section>
<section>
<h2 className="mb-2 text-lg font-semibold">Actividad</h2>
{activity}
</section>
</div>
</div>
);
}// app/dashboard/@metrics/page.tsx — Slot de métricas
async function getMetrics() {
const res = await fetch("https://api.example.com/metrics", {
next: { revalidate: 60 },
});
return res.json();
}
export default async function MetricsPanel() {
const metrics = await getMetrics();
return (
<div className="space-y-3 rounded-lg border p-4">
<div className="flex justify-between">
<span>Ingresos</span>
<span className="font-bold">${metrics.revenue.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span>Usuarios</span>
<span className="font-bold">{metrics.users.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span>Conversión</span>
<span className="font-bold">{metrics.conversion}%</span>
</div>
</div>
);
}// app/dashboard/@metrics/loading.tsx — Estado de loading independiente
export default function MetricsLoading() {
return (
<div className="space-y-3 rounded-lg border p-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex justify-between">
<div className="h-4 w-20 animate-pulse rounded bg-gray-200" />
<div className="h-4 w-16 animate-pulse rounded bg-gray-200" />
</div>
))}
</div>
);
}// app/dashboard/@activity/page.tsx — Slot de actividad
async function getActivity() {
const res = await fetch("https://api.example.com/activity", {
next: { revalidate: 30 },
});
return res.json();
}
export default async function ActivityPanel() {
const items = await getActivity();
return (
<ul className="space-y-2 rounded-lg border p-4">
{items.map((item: { id: string; message: string; time: string }) => (
<li key={item.id} className="flex justify-between text-sm">
<span>{item.message}</span>
<span className="text-gray-500">{item.time}</span>
</li>
))}
</ul>
);
}// app/dashboard/@metrics/default.tsx — Alternativa requerida
export default function MetricsDefault() {
return null;
}
// app/dashboard/@activity/default.tsx — Alternativa requerida
export default function ActivityDefault() {
return null;
}// app/dashboard/page.tsx — Contenido principal (se renderiza en {children})
export default function DashboardPage() {
return (
<p className="text-gray-600">
Bienvenido a tu dashboard. Las métricas y la actividad se cargan independientemente a continuación.
</p>
);
}Análisis profundo
Cómo funciona
@foldercrea un slot con nombre. El prefijo@le indica a Next.js que pase el slot como prop al layout padre.@metricsse convierte en el propmetrics.childrenes un slot implícito. Elpage.tsxen el mismo directorio que el layout automáticamente sirve como slotchildren. No necesitas crear una carpeta@children.- Cada slot navega de forma independiente. Hacer clic en un enlace en el panel de métricas actualiza solo el slot de métricas — otros slots permanecen sin cambios.
- Los slots pueden tener sus propios
loading.tsxyerror.tsx. Cada slot hace streaming y captura errores de forma independiente, por lo que una API lenta no bloquea el renderizado de todo el dashboard. default.tsxproporciona una alternativa. Cuando un slot no tiene una sub-ruta coincidente,default.tsxse renderiza en su lugar. Esto previene errores 404 durante la navegación.- Los slots no afectan la URL.
@metrics/page.tsxno agrega/metricsa la URL. La URL se determina por los segmentos que no son slots. - Las rutas paralelas permiten renderizado condicional. Puedes verificar el estado de autenticación en el layout y renderizar diferentes slots según el rol del usuario.
Variaciones
// Renderizado condicional basado en autenticación
// app/dashboard/layout.tsx
import { auth } from "@/lib/auth";
export default async function DashboardLayout({
children,
admin,
user,
}: {
children: React.ReactNode;
admin: React.ReactNode;
user: React.ReactNode;
}) {
const session = await auth();
const isAdmin = session?.user?.role === "admin";
return (
<div>
{children}
{isAdmin ? admin : user}
</div>
);
}
// app/dashboard/@admin/page.tsx — Se muestra a los administradores
// app/dashboard/@user/page.tsx — Se muestra a los usuarios regulares// Slots con sub-rutas
// app/dashboard/@metrics/detailed/page.tsx
// Navegar a /dashboard/detailed renderiza esto en el slot @metrics
// mientras que @activity muestra su default.tsx (o su propia página /detailed)
export default function DetailedMetrics() {
return <div>Vista de métricas detalladas</div>;
}# Rutas paralelas con navegación independiente
app/dashboard/
├── layout.tsx
├── page.tsx
├── @left/
│ ├── page.tsx # Panel izquierdo por defecto
│ ├── default.tsx
│ └── inbox/page.tsx # /dashboard/inbox → actualiza panel izquierdo
├── @right/
│ ├── page.tsx # Panel derecho por defecto
│ ├── default.tsx
│ └── inbox/page.tsx # /dashboard/inbox → también actualiza panel derecho
Notas de TypeScript
// Layout con slots de rutas paralelas
interface DashboardLayoutProps {
children: React.ReactNode; // Slot implícito (page.tsx)
metrics: React.ReactNode; // Slot @metrics
activity: React.ReactNode; // Slot @activity
}
// Las páginas de slots tienen los mismos props que páginas regulares
interface SlotPageProps {
params: Promise<Record<string, string>>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
// default.tsx no recibe props
// loading.tsx no recibe props
// error.tsx recibe { error, reset } (debe ser "use client")Problemas comunes
default.tsxes crítico. Sin él, navegar a una sub-ruta que un slot tiene pero otro no causa un error 404. Siempre creadefault.tsxen cada slot.- En navegación dura (recarga), los slots sin coincidencia renderizan
default.tsx. Si no haydefault.tsx, Next.js renderiza un 404. - Los slots no son segmentos de URL.
@metricsnunca aparece en la URL. No enlaces a/@metrics/something. - Todos los slots deben ser hijos directos del directorio del layout. No puedes anidar
@slotdentro de otro@slot. - Los nombres de slots deben ser únicos dentro de un layout. Dos carpetas
@metricsal mismo nivel causarán conflicto. childreny los slots nombrados comparten la misma URL. Cuando la URL es/dashboard/settings, Next.js busca/dashboard/settings/page.tsx(children),/dashboard/@metrics/settings/page.tsx, y/dashboard/@activity/settings/page.tsx. Las páginas faltantes recurren adefault.tsx.- El loading independiente puede causar cambios de layout. Cuando los slots se resuelven en diferentes momentos, el layout puede saltar. Usa dimensiones fijas o placeholders esqueléticos.
- Las rutas paralelas aumentan el recuento de archivos. Cada slot necesita como mínimo
page.tsxydefault.tsx. Para tres slots, eso es seis archivos antes de agregar loading y manejo de errores.
Alternativas
| Enfoque | Cuándo usar |
|---|---|
Página única con límites <Suspense> | Código más simple cuando la navegación independiente no es necesaria |
| Pestañas o paneles del lado del cliente | Cuando el cambio de panel es puramente visual, no basado en URL |
| Rutas de interceptación | Cuando quieres una superposición modal con una URL compartible |
| Componentes de servidor con streaming | Cuando quieres obtención de datos paralela sin rutas paralelas |
| Grupos de rutas | Organizar rutas sin múltiples vistas simultáneas |
Preguntas frecuentes
¿Cómo funciona la convención @folder?
El prefijo @ crea un slot con nombre. Next.js pasa el slot como prop al layout padre. Por ejemplo, @metrics se convierte en el prop metrics en el componente de layout.
¿Qué es el slot children implícito?
El page.tsx en el mismo directorio que el layout automáticamente sirve como slot children. No necesitas crear una carpeta @children.
Problema común: ¿Qué sucede si olvidas default.tsx en un slot?
Navegar a una sub-ruta que un slot tiene pero otro no causa un error 404. En navegación dura (recarga), los slots sin coincidencia sin default.tsx también renderizan un 404. Siempre crea default.tsx en cada slot.
¿Los nombres de slots como @metrics aparecen en la URL?
No. Los slots son invisibles en la URL. @metrics/page.tsx no agrega /metrics al camino. No enlaces a /@metrics/something.
¿Puede cada slot tener su propio loading.tsx y error.tsx?
Sí. Cada slot hace streaming y captura errores de forma independiente. Una API lenta en un slot no bloquea el renderizado de todo el dashboard.
¿Cómo renderizas condicionalmente diferentes slots según el rol del usuario?
// app/dashboard/layout.tsx
import { auth } from "@/lib/auth";
export default async function Layout({
children,
admin,
user,
}: {
children: React.ReactNode;
admin: React.ReactNode;
user: React.ReactNode;
}) {
const session = await auth();
const isAdmin = session?.user?.role === "admin";
return (
<div>
{children}
{isAdmin ? admin : user}
</div>
);
}¿Puedes anidar @slot dentro de otro @slot?
No. Todos los slots deben ser hijos directos del directorio del layout. Los slots anidados no son compatibles.
¿Cuál es el tipo de TypeScript para un layout que recibe slots de rutas paralelas?
interface DashboardLayoutProps {
children: React.ReactNode;
metrics: React.ReactNode;
activity: React.ReactNode;
}Cada slot se tipifica como React.ReactNode.
¿Cómo funcionan las sub-rutas de slots con la URL?
Cuando la URL es /dashboard/settings, Next.js busca:
/dashboard/settings/page.tsx(children)/dashboard/@metrics/settings/page.tsx/dashboard/@activity/settings/page.tsx
Las páginas faltantes en un slot recurren a default.tsx de ese slot.
Problema común: ¿Puede el loading independiente de slots causar cambios de layout?
Sí. Cuando los slots se resuelven en diferentes momentos, el layout puede saltar. Usa dimensiones fijas o placeholders esqueléticos con alturas consistentes para prevenir cambios de layout.
¿Cuáles son los archivos mínimos necesarios por slot?
Cada slot necesita como mínimo page.tsx y default.tsx. Para tres slots, eso son seis archivos antes de agregar loading.tsx y error.tsx.
¿Qué tipo de TypeScript deben usar default.tsx y loading.tsx?
Ambos no reciben props. Son componentes simples:
// default.tsx
export default function Default() {
return null;
}
// loading.tsx
export default function Loading() {
return <div>Cargando...</div>;
}Relacionado
- Rutas de interceptación — patrones modales usando rutas paralelas
- Layouts — cómo se componen los slots dentro de los layouts
- Loading y Error — estados de loading y error por slot
- Conceptos básicos de App Router — descripción general de convenciones de archivos