Parallel Routes
Parallel routes use @slot folders to render multiple pages simultaneously within a single layout. Each slot is an independent route segment with its own loading, error, and navigation state.
Search across all documentation pages
Parallel routes use @slot folders to render multiple pages simultaneously within a single layout. Each slot is an independent route segment with its own loading, error, and navigation state.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Quick-reference recipe card - copy-paste ready.
app/
├── layout.tsx # Receives @analytics and @team as props
├── page.tsx # Main content for /
├── @analytics/
│ ├── page.tsx # Analytics panel for /
│ ├── default.tsx # Fallback for unmatched sub-routes
│ └── loading.tsx # Independent loading state
├── @team/
│ ├── page.tsx # Team panel for /
│ └── default.tsx # Fallback for unmatched sub-routes
// 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>
);
}When to reach for this: Dashboards with independent panels, conditional content based on auth, or any layout that composes multiple independent views.
// app/dashboard/layout.tsx - Dashboard with three parallel slots
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">Metrics</h2>
{metrics}
</section>
<section>
<h2 className="mb-2 text-lg font-semibold">Activity</h2>
{activity}
</section>
</div>
</div>
);
}// app/dashboard/@metrics/page.tsx - Metrics slot
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>Revenue</span>
<span className="font-bold">${metrics.revenue.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span>Users</span>
<span className="font-bold">{metrics.users.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span>Conversion</span>
<span className="font-bold">{metrics.conversion}%</span>
</div>
</div>
);
}// app/dashboard/@metrics/loading.tsx - Independent loading state
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 - Activity slot
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 - Required fallback
export default function MetricsDefault() {
return null;
}
// app/dashboard/@activity/default.tsx - Required fallback
export default function ActivityDefault() {
return null;
}// app/dashboard/page.tsx - Main content (renders in {children})
export default function DashboardPage() {
return (
<p className="text-gray-600">
Welcome to your dashboard. Metrics and activity load independently below.
</p>
);
}@folder creates a named slot. The @ prefix tells Next.js to pass the slot as a prop to the parent layout. @metrics becomes the metrics prop.children is an implicit slot. The page.tsx in the same directory as the layout is automatically the children slot. You do not create @children.loading.tsx and error.tsx. Each slot streams and catches errors independently, so a slow API does not block the entire dashboard.default.tsx provides a fallback. When a slot does not have a matching sub-route, default.tsx renders instead. This prevents 404 errors during navigation.@metrics/page.tsx does not add /metrics to the URL. The URL is determined by the non-slot segments.// Conditional rendering based on auth
// 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 - Shown to admins
// app/dashboard/@user/page.tsx - Shown to regular users// Slots with sub-routes
// app/dashboard/@metrics/detailed/page.tsx
// Navigating to /dashboard/detailed renders this in the @metrics slot
// while @activity shows its default.tsx (or its own /detailed page)
export default function DetailedMetrics() {
return <div>Detailed metrics view</div>;
}# Parallel routes with independent navigation
app/dashboard/
├── layout.tsx
├── page.tsx
├── @left/
│ ├── page.tsx # Default left panel
│ ├── default.tsx
│ └── inbox/page.tsx # /dashboard/inbox → updates left panel
├── @right/
│ ├── page.tsx # Default right panel
│ ├── default.tsx
│ └── inbox/page.tsx # /dashboard/inbox → updates right panel too
// Layout with parallel route slots
interface DashboardLayoutProps {
children: React.ReactNode; // Implicit slot (page.tsx)
metrics: React.ReactNode; // @metrics slot
activity: React.ReactNode; // @activity slot
}
// Slot pages have the same props as regular pages
interface SlotPageProps {
params: Promise<Record<string, string>>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
// default.tsx takes no props
// loading.tsx takes no props
// error.tsx takes { error, reset } (must be "use client")default.tsx is critical. Without it, navigating to a sub-route that one slot has but another does not causes a 404. Always create default.tsx in every slot.default.tsx. If there is no default.tsx, Next.js renders a 404.@metrics never appears in the URL. Do not link to /@metrics/something.@slot inside another @slot.@metrics folders at the same level will conflict.children and named slots share the same URL. When the URL is /dashboard/settings, Next.js looks for /dashboard/settings/page.tsx (children), /dashboard/@metrics/settings/page.tsx, and /dashboard/@activity/settings/page.tsx. Missing pages fall back to default.tsx.page.tsx and default.tsx. For three slots, that is six files before adding loading and error handling.| Approach | When to Use |
|---|---|
Single page with <Suspense> boundaries | Simpler code when independent navigation is not needed |
| Client-side tabs or panels | When panel switching is purely visual, not URL-based |
| Intercepting Routes | When you want a modal overlay with a shareable URL |
| Server Components with streaming | When you want parallel data fetching without parallel routing |
| Route Groups | Organizing routes without multiple simultaneous views |
The @ prefix creates a named slot. Next.js passes the slot as a prop to the parent layout. For example, @metrics becomes the metrics prop in the layout component.
The page.tsx in the same directory as the layout automatically serves as the children slot. You do not need to create an @children folder.
Navigating to a sub-route that one slot has but another does not causes a 404. On hard navigation (refresh), unmatched slots without default.tsx also render a 404. Always create default.tsx in every slot.
No. Slots are invisible in the URL. @metrics/page.tsx does not add /metrics to the path. Do not link to /@metrics/something.
Yes. Each slot streams and catches errors independently. A slow API in one slot does not block the entire dashboard from rendering.
// 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>
);
}No. All slots must be direct children of the layout directory. Nested slots are not supported.
interface DashboardLayoutProps {
children: React.ReactNode;
metrics: React.ReactNode;
activity: React.ReactNode;
}Each slot is typed as React.ReactNode.
When the URL is /dashboard/settings, Next.js looks for:
/dashboard/settings/page.tsx (children)/dashboard/@metrics/settings/page.tsx/dashboard/@activity/settings/page.tsxMissing pages in a slot fall back to that slot's default.tsx.
Yes. When slots resolve at different times, the layout may jump. Use fixed dimensions or skeleton placeholders with consistent heights to prevent layout shift.
Each slot needs at minimum page.tsx and default.tsx. For three slots, that is six files before adding loading.tsx and error.tsx.
Both take no props. They are simple components:
// default.tsx
export default function Default() {
return null;
}
// loading.tsx
export default function Loading() {
return <div>Loading...</div>;
}Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥