|$ curl https://forge-ai.dev/api/markdown?path=docs/nextjs/parallel-routes
$cat docs/next.js-—-parallel-routes.md
updated Last week·14 min read·published
Next.js — Parallel Routes
Introduction
Parallel Routes let you render multiple pages in the same layout simultaneously using named slots. This is useful for complex layouts like dashboards where different sections load independently, or for split views where two routes render side by side.
@slot Syntax
app/dashboard/layout.tsx
TSX
| 1 | // Define slots in the layout with @ prefix |
| 2 | // app/dashboard/layout.tsx |
| 3 | |
| 4 | export default function DashboardLayout({ |
| 5 | children, |
| 6 | analytics, |
| 7 | team, |
| 8 | settings, |
| 9 | }: { |
| 10 | children: React.ReactNode; |
| 11 | analytics: React.ReactNode; |
| 12 | team: React.ReactNode; |
| 13 | settings: React.ReactNode; |
| 14 | }) { |
| 15 | return ( |
| 16 | <div className="flex min-h-screen"> |
| 17 | {/* Sidebar navigation */} |
| 18 | <nav className="w-64 border-r p-4"> |
| 19 | <h2 className="font-bold mb-4">Dashboard</h2> |
| 20 | <ul className="space-y-2"> |
| 21 | <li><a href="/dashboard">Overview</a></li> |
| 22 | <li><a href="/dashboard/analytics">Analytics</a></li> |
| 23 | <li><a href="/dashboard/team">Team</a></li> |
| 24 | <li><a href="/dashboard/settings">Settings</a></li> |
| 25 | </ul> |
| 26 | </nav> |
| 27 | |
| 28 | {/* Named slot for analytics */} |
| 29 | <div className="flex-1 p-6"> |
| 30 | {analytics} |
| 31 | </div> |
| 32 | |
| 33 | {/* Named slot for team */} |
| 34 | <div className="w-80 border-l p-4"> |
| 35 | {team} |
| 36 | </div> |
| 37 | </div> |
| 38 | ); |
| 39 | } |
| 40 | |
| 41 | // The @analytics, @team, @settings folders create the slots: |
| 42 | // app/ |
| 43 | // dashboard/ |
| 44 | // layout.tsx |
| 45 | // page.tsx → children slot |
| 46 | // analytics/ |
| 47 | // page.tsx → analytics slot |
| 48 | // team/ |
| 49 | // page.tsx → team slot |
| 50 | // settings/ |
| 51 | // page.tsx → settings slot |
ℹ
info
Each named slot corresponds to a folder with the same name prefixed by @. The layout receives them as props matching the slot names.
Concurrent Rendering
concurrent.tsx
TSX
| 1 | // Each slot renders independently — they don't block each other |
| 2 | |
| 3 | // app/dashboard/analytics/page.tsx |
| 4 | async function getAnalytics() { |
| 5 | // This might be slow |
| 6 | const data = await fetch("https://api.example.com/analytics", { |
| 7 | next: { revalidate: 3600 }, |
| 8 | }); |
| 9 | return data.json(); |
| 10 | } |
| 11 | |
| 12 | export default async function AnalyticsPage() { |
| 13 | const analytics = await getAnalytics(); |
| 14 | |
| 15 | return ( |
| 16 | <div> |
| 17 | <h1 className="text-2xl font-bold mb-4">Analytics</h1> |
| 18 | <div className="grid grid-cols-2 gap-4"> |
| 19 | <div className="p-4 border rounded"> |
| 20 | <p className="text-sm text-gray-500">Page Views</p> |
| 21 | <p className="text-3xl font-bold">{analytics.pageViews}</p> |
| 22 | </div> |
| 23 | <div className="p-4 border rounded"> |
| 24 | <p className="text-sm text-gray-500">Unique Visitors</p> |
| 25 | <p className="text-3xl font-bold">{analytics.visitors}</p> |
| 26 | </div> |
| 27 | </div> |
| 28 | </div> |
| 29 | ); |
| 30 | } |
| 31 | |
| 32 | // app/dashboard/team/page.tsx |
| 33 | async function getTeamMembers() { |
| 34 | const data = await fetch("https://api.example.com/team"); |
| 35 | return data.json(); |
| 36 | } |
| 37 | |
| 38 | export default async function TeamPage() { |
| 39 | const members = await getTeamMembers(); |
| 40 | |
| 41 | return ( |
| 42 | <div> |
| 43 | <h2 className="font-bold mb-3">Team Members</h2> |
| 44 | <ul className="space-y-2"> |
| 45 | {members.map((member: any) => ( |
| 46 | <li key={member.id} className="flex items-center gap-2"> |
| 47 | <img src={member.avatar} className="w-8 h-8 rounded-full" /> |
| 48 | <span>{member.name}</span> |
| 49 | </li> |
| 50 | ))} |
| 51 | </ul> |
| 52 | </div> |
| 53 | ); |
| 54 | } |
| 55 | |
| 56 | // Both slots load in parallel — the dashboard layout doesn't wait |
| 57 | // for one to finish before rendering the other |
Loading & Error States
loading_error.tsx
TSX
| 1 | // Each slot has its own loading.tsx and error.tsx |
| 2 | |
| 3 | // app/dashboard/analytics/loading.tsx |
| 4 | export default function AnalyticsLoading() { |
| 5 | return ( |
| 6 | <div className="animate-pulse space-y-4"> |
| 7 | <div className="h-8 w-48 bg-gray-800 rounded" /> |
| 8 | <div className="grid grid-cols-2 gap-4"> |
| 9 | <div className="h-24 bg-gray-800 rounded" /> |
| 10 | <div className="h-24 bg-gray-800 rounded" /> |
| 11 | </div> |
| 12 | </div> |
| 13 | ); |
| 14 | } |
| 15 | |
| 16 | // app/dashboard/analytics/error.tsx |
| 17 | "use client"; |
| 18 | |
| 19 | export default function AnalyticsError({ |
| 20 | error, |
| 21 | reset, |
| 22 | }: { |
| 23 | error: Error; |
| 24 | reset: () => void; |
| 25 | }) { |
| 26 | return ( |
| 27 | <div className="p-4 border border-red-500/30 rounded"> |
| 28 | <h2 className="text-red-400 font-bold">Analytics Error</h2> |
| 29 | <p className="text-sm text-gray-400">{error.message}</p> |
| 30 | <button onClick={reset} className="mt-2 text-sm text-red-400 underline"> |
| 31 | Try again |
| 32 | </button> |
| 33 | </div> |
| 34 | ); |
| 35 | } |
| 36 | |
| 37 | // app/dashboard/team/loading.tsx |
| 38 | export default function TeamLoading() { |
| 39 | return ( |
| 40 | <div className="animate-pulse space-y-3"> |
| 41 | {[1, 2, 3].map((i) => ( |
| 42 | <div key={i} className="flex items-center gap-2"> |
| 43 | <div className="w-8 h-8 bg-gray-800 rounded-full" /> |
| 44 | <div className="h-4 w-24 bg-gray-800 rounded" /> |
| 45 | </div> |
| 46 | ))} |
| 47 | </div> |
| 48 | ); |
| 49 | } |
| 50 | |
| 51 | // app/dashboard/team/error.tsx |
| 52 | "use client"; |
| 53 | |
| 54 | export default function TeamError({ |
| 55 | error, |
| 56 | reset, |
| 57 | }: { |
| 58 | error: Error; |
| 59 | reset: () => void; |
| 60 | }) { |
| 61 | return ( |
| 62 | <div className="p-4 border border-yellow-500/30 rounded"> |
| 63 | <h2 className="text-yellow-400 font-bold">Team Error</h2> |
| 64 | <p className="text-sm text-gray-400">{error.message}</p> |
| 65 | <button onClick={reset} className="mt-2 text-sm text-yellow-400 underline"> |
| 66 | Retry |
| 67 | </button> |
| 68 | </div> |
| 69 | ); |
| 70 | } |
ℹ
info
Each slot can have its own loading.tsx and error.tsx. This means a slow or failing analytics section won't affect the team section.
Use Cases
use_cases.tsx
TSX
| 1 | // Use case 1: Dashboard with independent sections |
| 2 | // app/dashboard/layout.tsx |
| 3 | export default function Layout({ |
| 4 | overview, |
| 5 | chart, |
| 6 | table, |
| 7 | }: { |
| 8 | overview: React.ReactNode; |
| 9 | chart: React.ReactNode; |
| 10 | table: React.ReactNode; |
| 11 | }) { |
| 12 | return ( |
| 13 | <div className="grid grid-cols-3 gap-4"> |
| 14 | <div>{overview}</div> |
| 15 | <div>{chart}</div> |
| 16 | <div className="col-span-2">{table}</div> |
| 17 | </div> |
| 18 | ); |
| 19 | } |
| 20 | |
| 21 | // Use case 2: Split view (email client) |
| 22 | // app/mail/layout.tsx |
| 23 | export default function MailLayout({ |
| 24 | folder, |
| 25 | message, |
| 26 | }: { |
| 27 | folder: React.ReactNode; |
| 28 | message: React.ReactNode; |
| 29 | }) { |
| 30 | return ( |
| 31 | <div className="flex h-screen"> |
| 32 | <div className="w-64 border-r overflow-auto">{folder}</div> |
| 33 | <div className="w-80 border-r overflow-auto">{message}</div> |
| 34 | <div className="flex-1">{/* Email content */}</div> |
| 35 | </div> |
| 36 | ); |
| 37 | } |
| 38 | |
| 39 | // Use case 3: Marketing site with shared navigation |
| 40 | // app/marketing/layout.tsx |
| 41 | export default function MarketingLayout({ |
| 42 | header, |
| 43 | content, |
| 44 | footer, |
| 45 | }: { |
| 46 | header: React.ReactNode; |
| 47 | content: React.ReactNode; |
| 48 | footer: React.ReactNode; |
| 49 | }) { |
| 50 | return ( |
| 51 | <div> |
| 52 | {header} |
| 53 | <main>{content}</main> |
| 54 | {footer} |
| 55 | </div> |
| 56 | ); |
| 57 | } |
| 58 | |
| 59 | // Use case 4: Tabs with parallel content |
| 60 | // app/settings/layout.tsx |
| 61 | export default function SettingsLayout({ |
| 62 | profile, |
| 63 | billing, |
| 64 | security, |
| 65 | }: { |
| 66 | profile: React.ReactNode; |
| 67 | billing: React.ReactNode; |
| 68 | security: React.ReactNode; |
| 69 | }) { |
| 70 | return ( |
| 71 | <div> |
| 72 | <Tabs> |
| 73 | <Tab label="Profile">{profile}</Tab> |
| 74 | <Tab label="Billing">{billing}</Tab> |
| 75 | <Tab label="Security">{security}</Tab> |
| 76 | </Tabs> |
| 77 | </div> |
| 78 | ); |
| 79 | } |
Active Route Indicators
active_routes.tsx
TSX
| 1 | // Use useSelectedLayoutSegment to detect active slot |
| 2 | |
| 3 | "use client"; |
| 4 | import { useSelectedLayoutSegment } from "next/navigation"; |
| 5 | |
| 6 | export function NavLink({ |
| 7 | href, |
| 8 | children, |
| 9 | }: { |
| 10 | href: string; |
| 11 | children: React.ReactNode; |
| 12 | }) { |
| 13 | const segment = useSelectedLayoutSegment(); |
| 14 | const isActive = href === "/" + segment; |
| 15 | |
| 16 | return ( |
| 17 | <a |
| 18 | href={href} |
| 19 | className={`block px-3 py-2 rounded ${ |
| 20 | isActive ? "bg-blue-600 text-white" : "text-gray-400 hover:bg-gray-800" |
| 21 | }`} |
| 22 | > |
| 23 | {children} |
| 24 | </a> |
| 25 | ); |
| 26 | } |
| 27 | |
| 28 | // Use in the sidebar |
| 29 | export function Sidebar() { |
| 30 | return ( |
| 31 | <nav className="space-y-1"> |
| 32 | <NavLink href="/dashboard">Overview</NavLink> |
| 33 | <NavLink href="/dashboard/analytics">Analytics</NavLink> |
| 34 | <NavLink href="/dashboard/team">Team</NavLink> |
| 35 | <NavLink href="/dashboard/settings">Settings</NavLink> |
| 36 | </nav> |
| 37 | ); |
| 38 | } |
| 39 | |
| 40 | // Conditional slot rendering |
| 41 | // Hide a slot when no active route matches |
| 42 | export default function Layout({ |
| 43 | children, |
| 44 | modal, |
| 45 | }: { |
| 46 | children: React.ReactNode; |
| 47 | modal: React.ReactNode; |
| 48 | }) { |
| 49 | // modal slot renders null (via default.tsx) when no route matches |
| 50 | return ( |
| 51 | <div> |
| 52 | {children} |
| 53 | {modal} |
| 54 | </div> |
| 55 | ); |
| 56 | } |
| 57 | |
| 58 | // app/@modal/default.tsx — renders when no route matches the slot |
| 59 | export default function Default() { |
| 60 | return null; // Slot is empty when not matched |
| 61 | } |
$Blueprint — Engineering Documentation·Section ID: NEXT-PRL·Revision: 1.0