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.
Recipe
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.
Working Example
// 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>
);
}Deep Dive
How It Works
@foldercreates a named slot. The@prefix tells Next.js to pass the slot as a prop to the parent layout.@metricsbecomes themetricsprop.childrenis an implicit slot. Thepage.tsxin the same directory as the layout is automatically thechildrenslot. You do not create@children.- Each slot navigates independently. Clicking a link in the metrics panel updates only the metrics slot — other slots remain unchanged.
- Slots can have their own
loading.tsxanderror.tsx. Each slot streams and catches errors independently, so a slow API does not block the entire dashboard. default.tsxprovides a fallback. When a slot does not have a matching sub-route,default.tsxrenders instead. This prevents 404 errors during navigation.- Slots do not affect the URL.
@metrics/page.tsxdoes not add/metricsto the URL. The URL is determined by the non-slot segments. - Parallel routes enable conditional rendering. You can check auth state in the layout and render different slots based on the user's role.
Variations
// 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
TypeScript Notes
// 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")Gotchas
default.tsxis critical. Without it, navigating to a sub-route that one slot has but another does not causes a 404. Always createdefault.tsxin every slot.- On hard navigation (refresh), unmatched slots render
default.tsx. If there is nodefault.tsx, Next.js renders a 404. - Slots are not URL segments.
@metricsnever appears in the URL. Do not link to/@metrics/something. - All slots must be direct children of the layout directory. You cannot nest
@slotinside another@slot. - Slot names must be unique within a layout. Two
@metricsfolders at the same level will conflict. childrenand 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 todefault.tsx.- Independent loading can cause layout shift. When slots resolve at different times, the layout may jump. Use fixed dimensions or skeleton placeholders.
- Parallel routes increase file count. Each slot needs at minimum
page.tsxanddefault.tsx. For three slots, that is six files before adding loading and error handling.
Alternatives
| 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 |
FAQs
How does the @folder convention work?
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.
What is the implicit children slot?
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.
Gotcha: What happens if you forget default.tsx in a slot?
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.
Do slot names like @metrics appear in the URL?
No. Slots are invisible in the URL. @metrics/page.tsx does not add /metrics to the path. Do not link to /@metrics/something.
Can each slot have its own loading.tsx and error.tsx?
Yes. Each slot streams and catches errors independently. A slow API in one slot does not block the entire dashboard from rendering.
How do you conditionally render different slots based on user role?
// 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>
);
}Can you nest @slot inside another @slot?
No. All slots must be direct children of the layout directory. Nested slots are not supported.
What is the TypeScript type for a layout that receives parallel route slots?
interface DashboardLayoutProps {
children: React.ReactNode;
metrics: React.ReactNode;
activity: React.ReactNode;
}Each slot is typed as React.ReactNode.
How do slot sub-routes work with the URL?
When the URL is /dashboard/settings, Next.js looks for:
/dashboard/settings/page.tsx(children)/dashboard/@metrics/settings/page.tsx/dashboard/@activity/settings/page.tsx
Missing pages in a slot fall back to that slot's default.tsx.
Gotcha: Can independent slot loading cause layout shift?
Yes. When slots resolve at different times, the layout may jump. Use fixed dimensions or skeleton placeholders with consistent heights to prevent layout shift.
What are the minimum files needed per slot?
Each slot needs at minimum page.tsx and default.tsx. For three slots, that is six files before adding loading.tsx and error.tsx.
What TypeScript type should default.tsx and loading.tsx use?
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>;
}Related
- Intercepting Routes — modal patterns using parallel routes
- Layouts — how slots compose within layouts
- Loading & Error — per-slot loading and error states
- App Router Basics — file conventions overview