React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

parallel-routesslotsdefaultconditional-renderingdashboard

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

  • @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.
  • 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.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.
  • Slots do not affect the URL. @metrics/page.tsx does not add /metrics to 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.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.
  • On hard navigation (refresh), unmatched slots render default.tsx. If there is no default.tsx, Next.js renders a 404.
  • Slots are not URL segments. @metrics never appears in the URL. Do not link to /@metrics/something.
  • All slots must be direct children of the layout directory. You cannot nest @slot inside another @slot.
  • Slot names must be unique within a layout. Two @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.
  • 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.tsx and default.tsx. For three slots, that is six files before adding loading and error handling.

Alternatives

ApproachWhen to Use
Single page with <Suspense> boundariesSimpler code when independent navigation is not needed
Client-side tabs or panelsWhen panel switching is purely visual, not URL-based
Intercepting RoutesWhen you want a modal overlay with a shareable URL
Server Components with streamingWhen you want parallel data fetching without parallel routing
Route GroupsOrganizing 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>;
}