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 Approach | Server Components |
|---|---|
| Fetch data client-side | Fetch on server, stream to client |
| Bundle chart libraries | Keep heavy deps server-only |
| Waterfalls (fetch → render → fetch) | Parallel fetching with streaming |
| Expose API endpoints | Direct database access |
| Loading spinners everywhere | Instant shell, progressive loading |
Project Structure
Here's our dashboard structure:
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 clientThe Dashboard Layout
Start with a layout that provides the navigation shell:
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:
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:
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:
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:
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>
);
}'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:
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:
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:
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
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
// 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
// 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:
- Faster initial loads: The shell renders instantly while data streams in
- Simpler code: No API routes needed, fetch directly in components
- Better DX: Collocate data fetching with components
- Smaller bundles: Keep heavy dependencies server-side
- 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
Related Articles
- Multi-Tenant Architecture in Next.js - Build organization-scoped dashboards with data isolation
- Prisma vs Drizzle ORM - Choose the right database layer for your dashboard
- Implementing Stripe Billing in Next.js - Add billing widgets to your dashboard
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.
Related Articles
CVE-2026-23864 - React Server Components DoS Vulnerabilities
Multiple denial of service vulnerabilities discovered in React Server Components. All Achromatic starter kits updated to patched versions.
React DoS & Source Code Exposure - Starter Kits Updated
Two new React Server Components vulnerabilities discovered. All Achromatic starter kits updated to patched versions.
Introducing shadcn-modal-manager
We open sourced shadcn-modal-manager - a lightweight, type-safe modal manager for shadcn/ui built with pure React and zero dependencies.