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 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:
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> );}'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 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:
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 timesexport const getUser = cache(async (userId: string) => { return prisma.user.findUnique({ where: { id: userId } });});2. Preload Data for Faster Navigation
// 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
// 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:
- 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.