Parallel Routes in Next.js
A complete guide to parallel routes in Next.js App Router: @slots, simultaneous rendering, conditional slots, loading and error states, intercepting routes, and advanced layout patterns.
What you'll learn
- ✓How parallel routes enable simultaneous page rendering
- ✓Creating and using @slots in layouts
- ✓Conditional slot rendering based on auth or state
- ✓Loading and error boundaries per slot
- ✓Combining parallel routes with intercepting routes
Prerequisites
- •Next.js App Router fundamentals
- •Understanding of layouts and nested routing
- •React Server Components basics
Parallel routes let you render multiple pages simultaneously in the same layout. Instead of a layout wrapping a single children slot, it can receive multiple named slots, each independently routable, each with its own loading and error states. This unlocks dashboard layouts, split views, and conditional rendering patterns that would otherwise require client-side state management.
What parallel routes solve
In a traditional layout, you have one children prop that corresponds to the current route. If you want a dashboard with a main content area and a sidebar that shows different content based on the route, you would typically reach for client components and state. Parallel routes make this declarative and server-rendered.
app/
dashboard/
layout.tsx ← receives @main and @sidebar as props
@main/
page.tsx ← renders in the main slot
analytics/
page.tsx
@sidebar/
page.tsx ← renders in the sidebar slot
analytics/
page.tsx
page.tsx ← the default children
Each @slot directory defines a named slot. The layout receives these as props alongside children.
Creating slots
A slot is created by adding a directory prefixed with @ inside a route segment. The directory name (without the @) becomes the prop name in the parent layout.
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
main,
sidebar,
}: {
children: React.ReactNode;
main: React.ReactNode;
sidebar: React.ReactNode;
}) {
return (
<div className="dashboard">
<nav className="dashboard-nav">
{children}
</nav>
<main className="dashboard-main">
{main}
</main>
<aside className="dashboard-sidebar">
{sidebar}
</aside>
</div>
);
}
// app/dashboard/@main/page.tsx
export default function MainContent() {
return (
<div>
<h1>Dashboard Overview</h1>
<p>Welcome to your dashboard.</p>
</div>
);
}
// app/dashboard/@sidebar/page.tsx
export default function Sidebar() {
return (
<div>
<h2>Quick Stats</h2>
<ul>
<li>Revenue: $12,400</li>
<li>Users: 1,204</li>
<li>Orders: 342</li>
</ul>
</div>
);
}
When you navigate to /dashboard, both @main/page.tsx and @sidebar/page.tsx render simultaneously. The layout composes them into the final UI.
Sub-routes within slots
Each slot can have its own nested routes. When you navigate to /dashboard/analytics, Next.js looks for @main/analytics/page.tsx and @sidebar/analytics/page.tsx.
// app/dashboard/@main/analytics/page.tsx
export default function AnalyticsMain() {
return (
<div>
<h1>Analytics</h1>
<div className="chart-grid">
<div className="chart">Traffic over time</div>
<div className="chart">Conversion funnel</div>
</div>
</div>
);
}
// app/dashboard/@sidebar/analytics/page.tsx
export default function AnalyticsSidebar() {
return (
<div>
<h2>Analytics Filters</h2>
<form>
<label>Date Range</label>
<select>
<option>Last 7 days</option>
<option>Last 30 days</option>
<option>Last 90 days</option>
</select>
<label>Channel</label>
<select>
<option>All</option>
<option>Organic</option>
<option>Paid</option>
</select>
</form>
</div>
);
}
Both slots update when the URL changes, but they are independent. You can have different loading speeds, different data sources, and different error boundaries.
The default.tsx fallback
When a slot does not have a matching route for the current URL, Next.js looks for a default.tsx file. Without it, the slot renders a 404. This is critical for slots that should show fallback content when their route does not match.
// app/dashboard/@sidebar/default.tsx
export default function DefaultSidebar() {
return (
<div>
<h2>Navigation</h2>
<ul>
<li><a href="/dashboard">Overview</a></li>
<li><a href="/dashboard/analytics">Analytics</a></li>
<li><a href="/dashboard/settings">Settings</a></li>
</ul>
</div>
);
}
If you navigate to /dashboard/settings and @sidebar does not have a settings/page.tsx, it will render default.tsx instead of showing a 404.
A common pattern is to have default.tsx re-export the slot’s page.tsx:
// app/dashboard/@sidebar/default.tsx
export { default } from "./page";
Independent loading states
Each slot can have its own loading.tsx, which means different parts of the page can show loading indicators independently.
// app/dashboard/@main/loading.tsx
export default function MainLoading() {
return (
<div className="main-skeleton">
<div className="skeleton-header" />
<div className="skeleton-chart" />
<div className="skeleton-chart" />
</div>
);
}
// app/dashboard/@sidebar/loading.tsx
export default function SidebarLoading() {
return (
<div className="sidebar-skeleton">
<div className="skeleton-line" />
<div className="skeleton-line" />
<div className="skeleton-line" />
</div>
);
}
If the main content takes 2 seconds to load but the sidebar takes 200ms, the sidebar appears immediately while the main area shows its skeleton. This is Suspense at the routing level, with no client-side code needed.
Independent error boundaries
Similarly, each slot can have its own error.tsx. An error in one slot does not take down the entire page.
// app/dashboard/@main/error.tsx
"use client";
export default function MainError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="error-container">
<h2>Something went wrong loading the main content</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>Try again</button>
</div>
);
}
If the analytics data fetch fails in @main, the sidebar still renders normally. The error is contained to the slot that failed.
Conditional slots
One of the most powerful patterns is rendering different slots based on conditions like authentication status. The layout can check auth state and decide which slot to show.
// app/dashboard/layout.tsx
import { getSession } from "@/lib/auth";
export default async function DashboardLayout({
admin,
user,
}: {
admin: React.ReactNode;
user: React.ReactNode;
}) {
const session = await getSession();
const isAdmin = session?.role === "admin";
return (
<div className="dashboard">
{isAdmin ? admin : user}
</div>
);
}
app/
dashboard/
layout.tsx
@admin/
page.tsx ← full admin dashboard
settings/
page.tsx ← admin settings
@user/
page.tsx ← limited user dashboard
settings/
page.tsx ← user settings
Both slots are defined, but only one is rendered. The unrendered slot’s page component never executes, so its data fetches never fire. This is a clean way to implement role-based UIs without client-side conditional logic.
Parallel routes with modals
A common pattern is using parallel routes to implement modals that have their own URL. This is achieved by combining parallel routes with intercepting routes.
app/
layout.tsx
@modal/
(.)photo/[id]/
page.tsx ← modal version
default.tsx ← renders null when no modal
photo/[id]/
page.tsx ← full page version
feed/
page.tsx
// app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html>
<body>
{children}
{modal}
</body>
</html>
);
}
// app/@modal/default.tsx
export default function ModalDefault() {
return null;
}
// app/@modal/(.)photo/[id]/page.tsx
import { Modal } from "@/components/Modal";
export default function PhotoModal({
params,
}: {
params: { id: string };
}) {
return (
<Modal>
<img src={`/photos/${params.id}.jpg`} alt="Photo" />
<p>Photo ID: {params.id}</p>
</Modal>
);
}
// app/photo/[id]/page.tsx (full page fallback)
export default function PhotoPage({
params,
}: {
params: { id: string };
}) {
return (
<div className="photo-full">
<img src={`/photos/${params.id}.jpg`} alt="Photo" />
<p>Photo ID: {params.id}</p>
</div>
);
}
The (.) prefix is an intercepting route convention. When you click a link to /photo/123 from the feed, the modal slot intercepts the navigation and renders the modal version. If you refresh the page or navigate directly to /photo/123, you get the full page version. The best of both worlds: modal UX with shareable URLs.
Tab layouts with parallel routes
Parallel routes work well for tab-based interfaces where each tab has its own URL and loading state.
// app/settings/layout.tsx
import Link from "next/link";
export default function SettingsLayout({
children,
profile,
billing,
notifications,
}: {
children: React.ReactNode;
profile: React.ReactNode;
billing: React.ReactNode;
notifications: React.ReactNode;
}) {
return (
<div>
<h1>Settings</h1>
<nav className="tabs">
<Link href="/settings">Profile</Link>
<Link href="/settings/billing">Billing</Link>
<Link href="/settings/notifications">Notifications</Link>
</nav>
<div className="tab-content">
{children}
</div>
</div>
);
}
Each tab can stream independently. The tab navigation is instant because Next.js prefetches the routes, and each tab has its own Suspense boundary.
Soft navigation behavior
Parallel routes have a specific behavior during client-side (soft) navigation vs full page loads (hard navigation).
During soft navigation (clicking a Link), Next.js keeps the previously active slots visible. If @sidebar does not have a matching route for the new URL, it continues showing its last matched content. This is usually what you want for persistent UI elements like sidebars.
During hard navigation (page refresh, typing URL directly), unmatched slots render their default.tsx or 404. This is why default.tsx is important — it prevents 404 errors on page refresh.
// Always add default.tsx to every slot to prevent hard navigation issues
// app/dashboard/@sidebar/default.tsx
export default function SidebarDefault() {
return <div>Default sidebar content</div>;
}
// app/dashboard/@main/default.tsx
export default function MainDefault() {
return <div>Select a section from the sidebar</div>;
}
When to use parallel routes
Parallel routes are the right tool when you have multiple independently loadable sections on a page, when you want URL-driven modals with full-page fallbacks, when different user roles should see different content for the same URL, or when you want independent error and loading states per section.
They add complexity to your file structure, so avoid them for simple pages where a single children slot is sufficient. The power comes from the independence: each slot is a fully capable route with its own data fetching, loading, error handling, and navigation behavior.
Related articles
- Next.js Parallel and Intercepting Routes in Next.js
How parallel slots and intercepting routes power dashboards, modals, and tabbed UIs in the Next.js App Router — the file conventions, when to reach for each, and the patterns that hold up in production.
- Next.js The Next.js App Router: Pages, Layouts, and Routing
A practical guide to the Next.js App Router — file-based routing, nested layouts, dynamic segments, client-side navigation with Link and useRouter, and the special loading and error files.
- Next.js Next.js API Routes: Best Practices and Patterns
Build robust Next.js API routes with proper validation, error handling, authentication, rate limiting, and testing patterns for production apps.
- Next.js Next.js i18n: Complete Internationalization Guide
Build multilingual Next.js apps with the App Router using locale routing, server-side translations, dynamic language switching, and SEO best practices.