Demo

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 deduplicationexport 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 renderingimport { 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 independentasync 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 dataasync 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 timesexport 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 componentimport { 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 revalidationimport { 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.