Focused woman using laptop in a modern office setting, working in tech development.

Building Multi-Tenant SaaS Dashboards with Next.js 15

· 6 min read

Multi-tenant SaaS applications require careful architectural decisions around data isolation, user experience, and route management. Next.js 15's parallel routes feature provides an elegant solution for building complex dashboard layouts where different sections need to load independently while sharing common UI elements.

Understanding Parallel Routes in Multi-Tenant Contexts

Parallel routes allow multiple pages to render simultaneously in the same layout, each with independent loading states and error boundaries. For SaaS dashboards serving multiple tenants, this architecture enables granular control over how different dashboard sections load and handle errors without affecting the entire page.

The key distinction from traditional nested routes lies in how parallel routes handle navigation. Each parallel route maintains its own navigation state and can be updated independently. This becomes particularly valuable when building dashboards where tenant-specific data, analytics, and settings need to load at different rates or handle failures gracefully.

Setting Up the Base Multi-Tenant Structure

The foundation of a multi-tenant dashboard requires a route structure that identifies tenants and organizes parallel sections effectively. Next.js 15 uses the @folder convention to define parallel route slots.

1// app/[tenantId]/dashboard/layout.tsx
2import { Suspense } from 'react';
3import { TenantProvider } from '@/lib/tenant-context';
4
5interface DashboardLayoutProps {
6  children: React.ReactNode;
7  analytics: React.ReactNode;
8  activity: React.ReactNode;
9  settings: React.ReactNode;
10  params: { tenantId: string };
11}
12
13export default function DashboardLayout({
14  children,
15  analytics,
16  activity,
17  settings,
18  params,
19}: DashboardLayoutProps) {
20  return (
21    <TenantProvider tenantId={params.tenantId}>
22      <div className="dashboard-grid">
23        <aside className="sidebar">
24          <TenantSwitcher currentTenant={params.tenantId} />
25          <Navigation />
26        </aside>
27        
28        <main className="main-content">
29          {children}
30        </main>
31        
32        <section className="analytics-panel">
33          <Suspense fallback={<AnalyticsSkeleton />}>
34            {analytics}
35          </Suspense>
36        </section>
37        
38        <section className="activity-feed">
39          <Suspense fallback={<ActivitySkeleton />}>
40            {activity}
41          </Suspense>
42        </section>
43        
44        {settings && (
45          <aside className="settings-panel">
46            {settings}
47          </aside>
48        )}
49      </div>
50    </TenantProvider>
51  );
52}

The directory structure follows this pattern:

1app/
2├── [tenantId]/
3│   └── dashboard/
4│       ├── layout.tsx
5│       ├── page.tsx
6│       ├── @analytics/
7│       │   ├── page.tsx
8│       │   └── loading.tsx
9│       ├── @activity/
10│       │   ├── page.tsx
11│       │   └── error.tsx
12│       └── @settings/
13│           └── [...catchall]/
14│               └── page.tsx

Implementing Tenant-Specific Data Fetching

Each parallel route can fetch data independently using React Server Components. This approach allows the dashboard to stream content as it becomes available, improving perceived performance for tenants with varying data volumes.

1// app/[tenantId]/dashboard/@analytics/page.tsx
2import { db } from '@/lib/db';
3import { cache } from 'react';
4
5const getTenantAnalytics = cache(async (tenantId: string) => {
6  // Data is automatically cached per request
7  const analytics = await db.analytics.findMany({
8    where: {
9      tenantId,
10      timestamp: {
11        gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // Last 7 days
12      },
13    },
14    orderBy: { timestamp: 'desc' },
15  });
16  
17  return {
18    totalUsers: analytics.reduce((sum, a) => sum + a.userCount, 0),
19    activeUsers: analytics[0]?.userCount || 0,
20    growth: calculateGrowth(analytics),
21    chartData: prepareChartData(analytics),
22  };
23});
24
25interface AnalyticsPageProps {
26  params: { tenantId: string };
27}
28
29export default async function AnalyticsPage({ params }: AnalyticsPageProps) {
30  const data = await getTenantAnalytics(params.tenantId);
31  
32  return (
33    <div className="analytics-container">
34      <h2>Analytics Overview</h2>
35      <div className="metrics-grid">
36        <MetricCard 
37          label="Total Users" 
38          value={data.totalUsers} 
39          trend={data.growth} 
40        />
41        <MetricCard 
42          label="Active Now" 
43          value={data.activeUsers} 
44        />
45      </div>
46      <Chart data={data.chartData} />
47    </div>
48  );
49}
50
51// Revalidate every 5 minutes
52export const revalidate = 300;

The cache function ensures that if multiple components request the same tenant data during a single request, only one database query executes. This becomes critical in multi-tenant scenarios where the same tenant context is used across multiple parallel routes.

Handling Modal Intercepting Routes for Settings

Parallel routes shine when combined with intercepting routes for modal workflows. Settings panels can render as modals when navigated to from the dashboard, but as full pages when accessed directly or refreshed.

Advertisement

1// app/[tenantId]/dashboard/@settings/(..)settings/[section]/page.tsx
2import { Modal } from '@/components/modal';
3import { SettingsForm } from '@/components/settings-form';
4
5interface SettingsModalProps {
6  params: { 
7    tenantId: string;
8    section: string;
9  };
10}
11
12export default function SettingsModal({ params }: SettingsModalProps) {
13  return (
14    <Modal>
15      <SettingsForm 
16        tenantId={params.tenantId}
17        section={params.section}
18      />
19    </Modal>
20  );
21}
22
23// app/[tenantId]/settings/[section]/page.tsx
24// This renders when accessing settings directly
25export default function SettingsPage({ params }: SettingsModalProps) {
26  return (
27    <div className="settings-page">
28      <h1>Settings</h1>
29      <SettingsForm 
30        tenantId={params.tenantId}
31        section={params.section}
32      />
33    </div>
34  );
35}

The (..) syntax intercepts routes at the parent level. When a user clicks a settings link from the dashboard, the modal version renders. Refreshing the page or accessing the URL directly shows the full page version. This pattern maintains URL integrity while providing an enhanced UX for in-app navigation.

Managing Default Slots and Error States

Parallel routes require default fallbacks for when a slot doesn't match the current URL. This becomes essential in multi-tenant dashboards where not all tenants have access to all features.

1// app/[tenantId]/dashboard/@settings/default.tsx
2export default function SettingsDefault() {
3  return null; // Renders nothing when settings aren't active
4}
5
6// app/[tenantId]/dashboard/@activity/error.tsx
7'use client';
8
9import { useEffect } from 'react';
10
11interface ActivityErrorProps {
12  error: Error & { digest?: string };
13  reset: () => void;
14}
15
16export default function ActivityError({ error, reset }: ActivityErrorProps) {
17  useEffect(() => {
18    // Log to error reporting service
19    console.error('Activity feed error:', error);
20  }, [error]);
21
22  return (
23    <div className="error-container">
24      <h3>Unable to load activity feed</h3>
25      <p>The activity feed is temporarily unavailable for this tenant.</p>
26      <button onClick={reset}>Retry</button>
27    </div>
28  );
29}

Error boundaries in parallel routes isolate failures. If the activity feed fails to load due to a database timeout or permission issue, the analytics and main content sections continue functioning normally. This isolation is crucial for maintaining dashboard availability across different tenant configurations and data access patterns.

Performance Considerations and Trade-offs

Parallel routes introduce complexity that requires careful consideration. Each parallel route creates an additional server component boundary, which can increase initial bundle size if not managed properly. The streaming benefits are most apparent when routes have significantly different data fetching times.

For tenants with small datasets where all sections load quickly (under 100ms), the overhead of multiple parallel routes may not justify the complexity. A simpler approach using a single server component with Promise.all for parallel data fetching might suffice. The parallel routes architecture provides the most value when dealing with varied data volumes across tenants or when independent error handling is critical.

Route caching behavior also changes with parallel routes. Each slot maintains its own cache key, which can lead to increased memory usage in applications with many tenants and frequent navigation. Implementing proper revalidation strategies and monitoring cache hit rates becomes more important than with traditional nested routes.

The mental model shift for developers is significant. Understanding how navigation affects which parallel routes render and how to structure default slots requires practice. Teams need clear conventions around when to use parallel routes versus simpler alternatives like client-side tabs or server component composition.

Key Implementation Patterns

Successful multi-tenant dashboards with parallel routes follow several patterns. Tenant context should be established at the layout level and provided through React context or props drilling, ensuring all parallel routes have access to tenant identification. Data fetching should leverage React's cache function to deduplicate requests across parallel boundaries.

Loading states work best when implemented per-slot using loading.tsx files rather than a single loading state for the entire dashboard. This granular approach lets users interact with loaded sections while others are still streaming. Error boundaries should be specific to each parallel route, providing targeted recovery options rather than failing the entire dashboard.

Navigation between tenant dashboards requires careful URL construction to maintain the current parallel route state. Using Next.js's Link component with proper href construction ensures smooth transitions without losing the user's position in the dashboard layout.

Advertisement

Share this page

Article by Marcus Rodriguez

Full-stack developer specializing in Next.js and modern React patterns. Creator of several open-source React libraries with over 50k downloads.

Related Content

Continue learning with these related articles