Next.js Parallel Routes make complex dashboard layouts surprisingly simple
I was building an analytics dashboard where different sections had wildly different load times — the user stats loaded in 100ms but the revenue chart needed 3 seconds of aggregation. With normal Next.js layouts, the slow section blocked the whole page. Then I discovered Parallel Routes and the fast sections started rendering immediately while the slow ones showed their own loading state. Here is how it works.
What Parallel Routes do
Parallel Routes let you render multiple pages in the same layout simultaneously, each with independent loading, error, and content states. They use a special @slotName folder convention.
The folder structure
app/
dashboard/
layout.tsx ← Receives all slots as props
page.tsx ← Optional default content
@stats/
page.tsx ← Renders immediately (fast query)
loading.tsx ← Shows while page.tsx loads
@revenue/
page.tsx ← May take 3s to render
loading.tsx ← Shows immediately, replaced when ready
@activity/
page.tsx ← Independent loading state
error.tsx ← Catches errors in this slot only
The layout receives slots as props
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
stats,
revenue,
activity,
}: {
children: React.ReactNode;
stats: React.ReactNode;
revenue: React.ReactNode;
activity: React.ReactNode;
}) {
return (
{children}
{stats}
{revenue}
{activity}
);
}
Each slot loads independently
// app/dashboard/@stats/page.tsx — fast, renders in ~100ms
export default async function StatsPage() {
const stats = await db.query.userStats.findMany({
where: { period: 'today' },
});
return (
);
}
// app/dashboard/@stats/loading.tsx — shows while StatsPage loads
export default function StatsLoading() {
return (
);
}
// app/dashboard/@revenue/page.tsx — slow, 2-3s aggregation
export default async function RevenuePage() {
// This slow query only blocks this slot
const revenue = await db.execute(sql`
SELECT
date_trunc('day', created_at) as date,
SUM(amount_cents) / 100.0 as revenue
FROM orders
WHERE created_at > NOW() - INTERVAL '30 days'
GROUP BY 1
ORDER BY 1
`);
return ;
}
// app/dashboard/@revenue/loading.tsx
export default function RevenueLoading() {
return ;
}
// app/dashboard/@revenue/error.tsx — isolated error boundary
export default function RevenueError({ reset }: { reset: () => void }) {
return (
Revenue chart failed to load
);
}
The result
The dashboard renders the skeleton layout immediately. Stats load in 100ms and replace their skeleton. Revenue loads in 2-3 seconds and replaces its skeleton. If revenue fails, only the revenue slot shows an error — the rest of the dashboard works normally.
t=0ms : Layout renders, all slots show loading skeletons t=100ms : Stats slot replaces skeleton with real data t=150ms : Activity slot replaces skeleton with real data t=2800ms : Revenue slot replaces skeleton with chart
Conditional slots
You can conditionally render slots by returning null from a slot's page — or provide a default.tsx that renders when the slot is not matched:
// app/dashboard/@adminPanel/default.tsx
// Renders when the @adminPanel slot is not actively matched
// (e.g., for non-admin users)
export default function AdminPanelDefault() {
return null; // Renders nothing, takes no space
}
// app/dashboard/@adminPanel/page.tsx
import { getSession } from '@/lib/auth';
export default async function AdminPanel() {
const session = await getSession();
if (!session?.user.isAdmin) return null;
return ;
}
Parallel Routes are one of the more complex App Router features, but for dashboards or any page where sections have independent loading requirements, they are the right tool. The alternative — wrapping each section in its own Suspense boundary with a Client Component — is more code for the same outcome.