Sunday, April 20th 2025 · 3 min read

Building a SaaS Dashboard with React Server Components

Learn how to build fast, data-rich SaaS dashboards using React Server Components in Next.js. Covers streaming, suspense boundaries, parallel data fetching, and real-world patterns.

React Server Components (RSC) have revolutionized how we build data-heavy applications. For SaaS dashboards—which typically fetch data from multiple sources and display complex analytics—RSC provides massive performance improvements while simplifying our code.

In this guide, we'll build a production-ready SaaS dashboard using React Server Components, covering streaming, suspense boundaries, parallel data fetching, and caching strategies.

Why Server Components for Dashboards?

SaaS dashboards have unique challenges:

  • Multiple data sources: Users, subscriptions, analytics, activity logs
  • Heavy computations: Aggregations, charts, statistics
  • Personalized content: Role-based views, tenant-specific data
  • Real-time updates: Activity feeds, notifications

Server Components solve these elegantly:

Traditional ApproachServer Components
Fetch data client-sideFetch on server, stream to client
Bundle chart librariesKeep heavy deps server-only
Waterfalls (fetch → render → fetch)Parallel fetching with streaming
Expose API endpointsDirect database access
Loading spinners everywhereInstant shell, progressive loading

Project Structure

Here's our dashboard structure:

project-structure.txt
app/
├── dashboard/
│   ├── layout.tsx          # Dashboard layout with sidebar
│   ├── page.tsx            # Main dashboard (overview)
│   ├── loading.tsx         # Loading skeleton
│   ├── analytics/
│   │   └── page.tsx        # Analytics page
│   ├── customers/
│   │   └── page.tsx        # Customer list
│   └── settings/
│       └── page.tsx        # Settings
├── components/
│   ├── dashboard/
│   │   ├── stats-cards.tsx     # KPI cards
│   │   ├── revenue-chart.tsx   # Revenue chart
│   │   ├── recent-activity.tsx # Activity feed
│   │   └── top-customers.tsx   # Top customers table
│   └── ui/
│       └── ...                 # UI components
└── lib/
    ├── data/
    │   ├── analytics.ts    # Analytics queries
    │   ├── customers.ts    # Customer queries
    │   └── activity.ts     # Activity queries
    └── db.ts               # Database client

The Dashboard Layout

Start with a layout that provides the navigation shell:

app/dashboard/layout.tsx
import { redirect } from 'next/navigation';

import { Header } from '@/components/dashboard/header';
import { Sidebar } from '@/components/dashboard/sidebar';
import { auth } from '@/lib/auth';

export default async function DashboardLayout({
  children
}: {
  children: React.ReactNode;
}) {
  const session = await auth();

  if (!session?.user) {
    redirect('/login');
  }

  return (
    <div className="flex h-screen">
      <Sidebar user={session.user} />
      <div className="flex-1 flex flex-col overflow-hidden">
        <Header user={session.user} />
        <main className="flex-1 overflow-auto bg-muted/40 p-6">{children}</main>
      </div>
    </div>
  );
}

Building the Overview Dashboard

The main dashboard page orchestrates multiple data components:

app/dashboard/page.tsx
import { Suspense } from 'react';

import {
  RecentActivity,
  RecentActivitySkeleton
} from '@/components/dashboard/recent-activity';
import {
  RevenueChart,
  RevenueChartSkeleton
} from '@/components/dashboard/revenue-chart';
import {
  StatsCards,
  StatsCardsSkeleton
} from '@/components/dashboard/stats-cards';
import {
  TopCustomers,
  TopCustomersSkeleton
} from '@/components/dashboard/top-customers';

export default function DashboardPage() {
  return (
    <div className="space-y-6">
      <div>
        <h1 className="text-3xl font-bold">Dashboard</h1>
        <p className="text-muted-foreground">
          Welcome back! Here's what's happening with your business.
        </p>
      </div>

      {/* Stats Cards - Load first (most important) */}
      <Suspense fallback={<StatsCardsSkeleton />}>
        <StatsCards />
      </Suspense>

      {/* Main content grid */}
      <div className="grid gap-6 lg:grid-cols-7">
        {/* Revenue Chart - Takes more space */}
        <div className="lg:col-span-4">
          <Suspense fallback={<RevenueChartSkeleton />}>
            <RevenueChart />
          </Suspense>
        </div>

        {/* Recent Activity - Sidebar */}
        <div className="lg:col-span-3">
          <Suspense fallback={<RecentActivitySkeleton />}>
            <RecentActivity />
          </Suspense>
        </div>
      </div>

      {/* Top Customers Table */}
      <Suspense fallback={<TopCustomersSkeleton />}>
        <TopCustomers />
      </Suspense>
    </div>
  );
}

Key insight: Each <Suspense> boundary creates an independent loading stream. The page shell renders immediately, and each component streams in as its data resolves.

Stats Cards Component

The stats cards show key metrics at a glance:

components/dashboard/stats-cards.tsx
import { Activity, CreditCard, DollarSign, Users } from 'lucide-react';

import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { getStats } from '@/lib/data/analytics';

export async function StatsCards() {
  const stats = await getStats();

  return (
    <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
      <Card>
        <CardHeader className="flex flex-row items-center justify-between pb-2">
          <CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
          <DollarSign className="h-4 w-4 text-muted-foreground" />
        </CardHeader>
        <CardContent>
          <div className="text-2xl font-bold">
            ${stats.totalRevenue.toLocaleString()}
          </div>
          <p className="text-xs text-muted-foreground">
            <span
              className={
                stats.revenueChange >= 0 ? 'text-green-600' : 'text-red-600'
              }
            >
              {stats.revenueChange >= 0 ? '+' : ''}
              {stats.revenueChange}%
            </span>{' '}
            from last month
          </p>
        </CardContent>
      </Card>

      <Card>
        <CardHeader className="flex flex-row items-center justify-between pb-2">
          <CardTitle className="text-sm font-medium">Subscriptions</CardTitle>
          <Users className="h-4 w-4 text-muted-foreground" />
        </CardHeader>
        <CardContent>
          <div className="text-2xl font-bold">+{stats.newSubscriptions}</div>
          <p className="text-xs text-muted-foreground">
            <span
              className={
                stats.subscriptionChange >= 0
                  ? 'text-green-600'
                  : 'text-red-600'
              }
            >
              {stats.subscriptionChange >= 0 ? '+' : ''}
              {stats.subscriptionChange}%
            </span>{' '}
            from last month
          </p>
        </CardContent>
      </Card>

      <Card>
        <CardHeader className="flex flex-row items-center justify-between pb-2">
          <CardTitle className="text-sm font-medium">Sales</CardTitle>
          <CreditCard className="h-4 w-4 text-muted-foreground" />
        </CardHeader>
        <CardContent>
          <div className="text-2xl font-bold">+{stats.totalSales}</div>
          <p className="text-xs text-muted-foreground">
            <span
              className={
                stats.salesChange >= 0 ? 'text-green-600' : 'text-red-600'
              }
            >
              {stats.salesChange >= 0 ? '+' : ''}
              {stats.salesChange}%
            </span>{' '}
            from last month
          </p>
        </CardContent>
      </Card>

      <Card>
        <CardHeader className="flex flex-row items-center justify-between pb-2">
          <CardTitle className="text-sm font-medium">Active Now</CardTitle>
          <Activity className="h-4 w-4 text-muted-foreground" />
        </CardHeader>
        <CardContent>
          <div className="text-2xl font-bold">+{stats.activeUsers}</div>
          <p className="text-xs text-muted-foreground">
            {stats.activeUsersChange} since last hour
          </p>
        </CardContent>
      </Card>
    </div>
  );
}

export function StatsCardsSkeleton() {
  return (
    <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
      {Array.from({ length: 4 }).map((_, i) => (
        <Card key={i}>
          <CardHeader className="flex flex-row items-center justify-between pb-2">
            <div className="h-4 w-24 bg-muted animate-pulse rounded" />
            <div className="h-4 w-4 bg-muted animate-pulse rounded" />
          </CardHeader>
          <CardContent>
            <div className="h-8 w-20 bg-muted animate-pulse rounded mb-2" />
            <div className="h-3 w-32 bg-muted animate-pulse rounded" />
          </CardContent>
        </Card>
      ))}
    </div>
  );
}

Data Fetching Layer

Keep your data fetching clean with dedicated functions:

lib/data/analytics.ts
import { cache } from 'react';

import { auth } from '@/lib/auth';
import { prisma } from '@/lib/db';

// Use React's cache() for request deduplication
export const getStats = cache(async () => {
  const session = await auth();
  if (!session?.user?.organizationId) {
    throw new Error('Unauthorized');
  }

  const organizationId = session.user.organizationId;
  const now = new Date();
  const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
  const startOfLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);

  // Parallel queries for better performance
  const [
    currentRevenue,
    lastMonthRevenue,
    currentSubscriptions,
    lastMonthSubscriptions,
    currentSales,
    lastMonthSales,
    activeUsers
  ] = await Promise.all([
    // Current month revenue
    prisma.payment.aggregate({
      where: {
        organizationId,
        createdAt: { gte: startOfMonth },
        status: 'succeeded'
      },
      _sum: { amount: true }
    }),
    // Last month revenue
    prisma.payment.aggregate({
      where: {
        organizationId,
        createdAt: { gte: startOfLastMonth, lt: startOfMonth },
        status: 'succeeded'
      },
      _sum: { amount: true }
    }),
    // Current subscriptions
    prisma.subscription.count({
      where: {
        organizationId,
        createdAt: { gte: startOfMonth }
      }
    }),
    // Last month subscriptions
    prisma.subscription.count({
      where: {
        organizationId,
        createdAt: { gte: startOfLastMonth, lt: startOfMonth }
      }
    }),
    // Current sales
    prisma.order.count({
      where: {
        organizationId,
        createdAt: { gte: startOfMonth }
      }
    }),
    // Last month sales
    prisma.order.count({
      where: {
        organizationId,
        createdAt: { gte: startOfLastMonth, lt: startOfMonth }
      }
    }),
    // Active users (last hour)
    prisma.session.count({
      where: {
        organizationId,
        lastActive: { gte: new Date(Date.now() - 60 * 60 * 1000) }
      }
    })
  ]);

  const totalRevenue = (currentRevenue._sum.amount || 0) / 100;
  const lastRevenue = (lastMonthRevenue._sum.amount || 0) / 100;
  const revenueChange =
    lastRevenue > 0
      ? Math.round(((totalRevenue - lastRevenue) / lastRevenue) * 100)
      : 0;

  const subscriptionChange =
    lastMonthSubscriptions > 0
      ? Math.round(
          ((currentSubscriptions - lastMonthSubscriptions) /
            lastMonthSubscriptions) *
            100
        )
      : 0;

  const salesChange =
    lastMonthSales > 0
      ? Math.round(((currentSales - lastMonthSales) / lastMonthSales) * 100)
      : 0;

  return {
    totalRevenue,
    revenueChange,
    newSubscriptions: currentSubscriptions,
    subscriptionChange,
    totalSales: currentSales,
    salesChange,
    activeUsers,
    activeUsersChange: `+${Math.floor(Math.random() * 50)}` // Would be calculated
  };
});

Revenue Chart with Server-Only Dependencies

Charts can be heavy. Keep chart libraries server-side:

components/dashboard/revenue-chart.tsx
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle
} from '@/components/ui/card';
import { getRevenueData } from '@/lib/data/analytics';

// Client component just for the chart rendering
import { RevenueChartClient } from './revenue-chart-client';

export async function RevenueChart() {
  const data = await getRevenueData();

  return (
    <Card>
      <CardHeader>
        <CardTitle>Revenue Overview</CardTitle>
        <CardDescription>Monthly revenue for the current year</CardDescription>
      </CardHeader>
      <CardContent>
        <RevenueChartClient data={data} />
      </CardContent>
    </Card>
  );
}

export function RevenueChartSkeleton() {
  return (
    <Card>
      <CardHeader>
        <div className="h-5 w-32 bg-muted animate-pulse rounded" />
        <div className="h-4 w-48 bg-muted animate-pulse rounded mt-2" />
      </CardHeader>
      <CardContent>
        <div className="h-[300px] bg-muted animate-pulse rounded" />
      </CardContent>
    </Card>
  );
}
components/dashboard/revenue-chart-client.tsx
'use client';

import {
  Area,
  AreaChart,
  CartesianGrid,
  ResponsiveContainer,
  Tooltip,
  XAxis,
  YAxis
} from 'recharts';

interface RevenueData {
  month: string;
  revenue: number;
  subscriptions: number;
}

export function RevenueChartClient({ data }: { data: RevenueData[] }) {
  return (
    <ResponsiveContainer
      width="100%"
      height={300}
    >
      <AreaChart data={data}>
        <defs>
          <linearGradient
            id="colorRevenue"
            x1="0"
            y1="0"
            x2="0"
            y2="1"
          >
            <stop
              offset="5%"
              stopColor="hsl(var(--primary))"
              stopOpacity={0.3}
            />
            <stop
              offset="95%"
              stopColor="hsl(var(--primary))"
              stopOpacity={0}
            />
          </linearGradient>
        </defs>
        <CartesianGrid
          strokeDasharray="3 3"
          className="stroke-muted"
        />
        <XAxis
          dataKey="month"
          className="text-xs"
          tick={{ fill: 'hsl(var(--muted-foreground))' }}
        />
        <YAxis
          className="text-xs"
          tick={{ fill: 'hsl(var(--muted-foreground))' }}
          tickFormatter={(value) => `$${value.toLocaleString()}`}
        />
        <Tooltip
          contentStyle={{
            backgroundColor: 'hsl(var(--background))',
            border: '1px solid hsl(var(--border))',
            borderRadius: '8px'
          }}
          formatter={(value: number) => [
            `$${value.toLocaleString()}`,
            'Revenue'
          ]}
        />
        <Area
          type="monotone"
          dataKey="revenue"
          stroke="hsl(var(--primary))"
          fillOpacity={1}
          fill="url(#colorRevenue)"
        />
      </AreaChart>
    </ResponsiveContainer>
  );
}

Recent Activity with Streaming

Activity feeds benefit from streaming—show them progressively:

components/dashboard/recent-activity.tsx
import { formatDistanceToNow } from 'date-fns';

import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { getRecentActivity } from '@/lib/data/activity';

export async function RecentActivity() {
  const activities = await getRecentActivity();

  return (
    <Card>
      <CardHeader>
        <CardTitle>Recent Activity</CardTitle>
      </CardHeader>
      <CardContent>
        <div className="space-y-4">
          {activities.map((activity) => (
            <div
              key={activity.id}
              className="flex items-start gap-4"
            >
              <Avatar className="h-9 w-9">
                <AvatarImage
                  src={activity.user.image}
                  alt={activity.user.name}
                />
                <AvatarFallback>
                  {activity.user.name?.slice(0, 2).toUpperCase()}
                </AvatarFallback>
              </Avatar>
              <div className="flex-1 space-y-1">
                <p className="text-sm">
                  <span className="font-medium">{activity.user.name}</span>{' '}
                  <span className="text-muted-foreground">
                    {activity.action}
                  </span>{' '}
                  <span className="font-medium">{activity.target}</span>
                </p>
                <p className="text-xs text-muted-foreground">
                  {formatDistanceToNow(new Date(activity.createdAt), {
                    addSuffix: true
                  })}
                </p>
              </div>
            </div>
          ))}
        </div>
      </CardContent>
    </Card>
  );
}

export function RecentActivitySkeleton() {
  return (
    <Card>
      <CardHeader>
        <div className="h-5 w-32 bg-muted animate-pulse rounded" />
      </CardHeader>
      <CardContent>
        <div className="space-y-4">
          {Array.from({ length: 5 }).map((_, i) => (
            <div
              key={i}
              className="flex items-start gap-4"
            >
              <div className="h-9 w-9 bg-muted animate-pulse rounded-full" />
              <div className="flex-1 space-y-2">
                <div className="h-4 w-full bg-muted animate-pulse rounded" />
                <div className="h-3 w-20 bg-muted animate-pulse rounded" />
              </div>
            </div>
          ))}
        </div>
      </CardContent>
    </Card>
  );
}

Parallel Data Fetching Pattern

For pages needing multiple independent data sources, use parallel fetching:

app/dashboard/analytics/page.tsx
import { Suspense } from 'react';

// These functions can be called in parallel because they're independent
async function fetchPageViews() {
  // Simulated delay for demo
  await new Promise((r) => setTimeout(r, 1000));
  return { total: 125000, change: 12.5 };
}

async function fetchConversionRate() {
  await new Promise((r) => setTimeout(r, 1500));
  return { rate: 3.2, change: 0.5 };
}

async function fetchBounceRate() {
  await new Promise((r) => setTimeout(r, 800));
  return { rate: 42, change: -2.1 };
}

// Components that fetch their own data
async function PageViewsCard() {
  const data = await fetchPageViews();
  return (
    <Card>
      <CardHeader>Page Views</CardHeader>
      <CardContent>{data.total.toLocaleString()}</CardContent>
    </Card>
  );
}

async function ConversionCard() {
  const data = await fetchConversionRate();
  return (
    <Card>
      <CardHeader>Conversion Rate</CardHeader>
      <CardContent>{data.rate}%</CardContent>
    </Card>
  );
}

async function BounceRateCard() {
  const data = await fetchBounceRate();
  return (
    <Card>
      <CardHeader>Bounce Rate</CardHeader>
      <CardContent>{data.rate}%</CardContent>
    </Card>
  );
}

// All cards load in parallel, not sequentially!
export default function AnalyticsPage() {
  return (
    <div className="grid gap-4 md:grid-cols-3">
      <Suspense fallback={<CardSkeleton />}>
        <PageViewsCard />
      </Suspense>
      <Suspense fallback={<CardSkeleton />}>
        <ConversionCard />
      </Suspense>
      <Suspense fallback={<CardSkeleton />}>
        <BounceRateCard />
      </Suspense>
    </div>
  );
}

Loading States Done Right

Create a cohesive loading experience with loading.tsx:

app/dashboard/loading.tsx
import { RecentActivitySkeleton } from '@/components/dashboard/recent-activity';
import { RevenueChartSkeleton } from '@/components/dashboard/revenue-chart';
import { StatsCardsSkeleton } from '@/components/dashboard/stats-cards';
import { TopCustomersSkeleton } from '@/components/dashboard/top-customers';

export default function DashboardLoading() {
  return (
    <div className="space-y-6">
      <div>
        <div className="h-9 w-48 bg-muted animate-pulse rounded" />
        <div className="h-5 w-96 bg-muted animate-pulse rounded mt-2" />
      </div>

      <StatsCardsSkeleton />

      <div className="grid gap-6 lg:grid-cols-7">
        <div className="lg:col-span-4">
          <RevenueChartSkeleton />
        </div>
        <div className="lg:col-span-3">
          <RecentActivitySkeleton />
        </div>
      </div>

      <TopCustomersSkeleton />
    </div>
  );
}

Performance Tips

1. Use React's cache() for Deduplication

lib/data/users.ts
import { cache } from 'react';

// This function will only run once per request, even if called multiple times
export const getUser = cache(async (userId: string) => {
  return prisma.user.findUnique({ where: { id: userId } });
});

2. Preload Data for Faster Navigation

lib/data/preload.ts
// In your layout or parent component
import { preloadDashboardData } from '@/lib/data/preload';

import { getRecentActivity } from './activity';
import { getStats } from './analytics';

export function preloadDashboardData() {
  void getStats();
  void getRecentActivity();
}

export default function Layout({ children }) {
  preloadDashboardData(); // Start fetching before render
  return <>{children}</>;
}

3. Revalidate Strategically

app/actions/orders.ts
// Or use on-demand revalidation
import { revalidatePath } from 'next/cache';

// For data that changes frequently (revalidate every 60 seconds)
export const revalidate = 60;

export async function createOrder(data: OrderData) {
  await prisma.order.create({ data });
  revalidatePath('/dashboard'); // Refresh dashboard data
}

Conclusion

React Server Components transform how we build SaaS dashboards:

  1. Faster initial loads: The shell renders instantly while data streams in
  2. Simpler code: No API routes needed, fetch directly in components
  3. Better DX: Collocate data fetching with components
  4. Smaller bundles: Keep heavy dependencies server-side
  5. Progressive enhancement: Each section loads independently

The key patterns to remember:

  • Wrap async components in <Suspense> for streaming
  • Use cache() for request deduplication
  • Fetch data in parallel when possible
  • Keep heavy dependencies (charts, date libraries) server-side
  • Create matching skeleton components for smooth loading states

Ready to build your SaaS dashboard? Our starter kits come with a complete dashboard implementation:

  • Prisma Kit - Full dashboard with analytics, billing, and team management
  • Drizzle Kit - Lightweight dashboard with streaming and Server Components

Check out our live demo to see Server Components in action, or visit our pricing page to get started.