SaaS Metrics Dashboard

Track and analyze key SaaS metrics including MRR, NRR, CAC, LTV with SQL queries and dashboard setup

The SaaS Metrics workflow helps you build a comprehensive metrics dashboard to track the health and growth of your business. From MRR to CAC/LTV ratios, this guide provides SQL queries, calculation methods, and benchmarks.

Overview#

PropertyValue
Metrics15+ key SaaS metrics
TierFree
Typical Duration1-2 weeks to implement
Best ForSaaS businesses tracking growth

Outcomes#

A successful metrics implementation delivers:

  • Real-time metrics dashboard
  • Automated metric calculations
  • Historical trend analysis
  • Benchmarking against industry standards
  • Data-driven decision making

Core Metrics Framework#

SAAS METRICS HIERARCHY ┌──────────────┐ │ ARR │ │ Revenue │ └──────┬───────┘ │ ┌─────────────────┼─────────────────┐ │ │ │ ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ │ New MRR │ │ Expansion │ │ Churn │ │ │ │ MRR │ │ MRR │ └──────┬──────┘ └─────────────┘ └─────────────┘ │ ┌─────────┴─────────┐ │ │ ┌───▼───┐ ┌────▼────┐ │ CAC │ │ LTV │ │ │ │ │ └───────┘ └─────────┘

Revenue Metrics#

Monthly Recurring Revenue (MRR)#

Definition: Total predictable revenue normalized to one month

1-- Current MRR 2SELECT 3 SUM( 4 CASE plan_interval 5 WHEN 'monthly' THEN amount 6 WHEN 'yearly' THEN amount / 12 7 END 8 ) as mrr 9FROM subscriptions 10WHERE status = 'active';

MRR Components:

1-- MRR breakdown by type 2WITH mrr_changes AS ( 3 SELECT 4 DATE_TRUNC('month', created_at) as month, 5 customer_id, 6 SUM(amount) as amount, 7 LAG(SUM(amount)) OVER ( 8 PARTITION BY customer_id 9 ORDER BY DATE_TRUNC('month', created_at) 10 ) as prev_amount, 11 MIN(DATE_TRUNC('month', created_at)) OVER ( 12 PARTITION BY customer_id 13 ) as first_month 14 FROM payments 15 GROUP BY DATE_TRUNC('month', created_at), customer_id 16) 17SELECT 18 month, 19 SUM(CASE WHEN month = first_month THEN amount ELSE 0 END) as new_mrr, 20 SUM(CASE 21 WHEN month > first_month AND amount > COALESCE(prev_amount, 0) 22 THEN amount - COALESCE(prev_amount, 0) 23 ELSE 0 24 END) as expansion_mrr, 25 SUM(CASE 26 WHEN month > first_month AND amount < COALESCE(prev_amount, 0) 27 THEN COALESCE(prev_amount, 0) - amount 28 ELSE 0 29 END) as contraction_mrr, 30 SUM(CASE 31 WHEN prev_amount > 0 AND amount = 0 32 THEN prev_amount 33 ELSE 0 34 END) as churned_mrr 35FROM mrr_changes 36GROUP BY month 37ORDER BY month;

Annual Recurring Revenue (ARR)#

1// Simple calculation 2const arr = mrr * 12; 3 4// With trailing average for smoothing 5async function getARR() { 6 const last3MonthsMRR = await prisma.$queryRaw` 7 SELECT AVG(mrr) as avg_mrr 8 FROM ( 9 SELECT 10 DATE_TRUNC('month', created_at) as month, 11 SUM(amount) as mrr 12 FROM payments 13 WHERE created_at > NOW() - INTERVAL '3 months' 14 GROUP BY DATE_TRUNC('month', created_at) 15 ) monthly 16 `; 17 18 return last3MonthsMRR[0].avg_mrr * 12; 19}

Net Revenue Retention (NRR)#

Definition: Revenue from existing customers compared to same period last year

1-- NRR calculation 2WITH customer_revenue AS ( 3 SELECT 4 customer_id, 5 SUM(CASE 6 WHEN payment_date BETWEEN '2023-01-01' AND '2023-12-31' 7 THEN amount ELSE 0 8 END) as revenue_2023, 9 SUM(CASE 10 WHEN payment_date BETWEEN '2024-01-01' AND '2024-12-31' 11 THEN amount ELSE 0 12 END) as revenue_2024 13 FROM payments 14 GROUP BY customer_id 15 HAVING SUM(CASE 16 WHEN payment_date BETWEEN '2023-01-01' AND '2023-12-31' 17 THEN amount ELSE 0 18 END) > 0 -- Only customers who existed in 2023 19) 20SELECT 21 SUM(revenue_2024) / NULLIF(SUM(revenue_2023), 0) * 100 as nrr_percent 22FROM customer_revenue;

NRR Benchmarks:

NRRRatingInterpretation
>120%ExcellentStrong expansion
100-120%GoodHealthy growth
90-100%FairMaintaining
<90%PoorRevenue declining

Gross Revenue Retention (GRR)#

-- GRR (excludes expansion, only looks at retention) SELECT SUM(LEAST(revenue_2024, revenue_2023)) / NULLIF(SUM(revenue_2023), 0) * 100 as grr_percent FROM customer_revenue;

Customer Metrics#

Customer Acquisition Cost (CAC)#

Definition: Cost to acquire one customer

1// lib/metrics/cac.ts 2export async function calculateCAC(period: { start: Date; end: Date }) { 3 // Get marketing + sales spend 4 const spend = await prisma.expense.aggregate({ 5 where: { 6 category: { in: ['marketing', 'sales'] }, 7 date: { gte: period.start, lte: period.end }, 8 }, 9 _sum: { amount: true }, 10 }); 11 12 // Get new customers in period 13 const newCustomers = await prisma.customer.count({ 14 where: { 15 firstPurchaseDate: { gte: period.start, lte: period.end }, 16 }, 17 }); 18 19 return { 20 totalSpend: spend._sum.amount || 0, 21 newCustomers, 22 cac: newCustomers > 0 23 ? (spend._sum.amount || 0) / newCustomers 24 : 0, 25 }; 26}

CAC by Channel:

1-- CAC broken down by acquisition channel 2SELECT 3 attribution_source, 4 SUM(marketing_cost) as total_spend, 5 COUNT(DISTINCT customer_id) as customers, 6 SUM(marketing_cost) / NULLIF(COUNT(DISTINCT customer_id), 0) as cac 7FROM ( 8 SELECT 9 c.id as customer_id, 10 c.attribution_source, 11 COALESCE(m.spend, 0) as marketing_cost 12 FROM customers c 13 LEFT JOIN marketing_spend m 14 ON c.attribution_source = m.channel 15 AND DATE_TRUNC('month', c.created_at) = m.month 16 WHERE c.created_at > NOW() - INTERVAL '3 months' 17) channel_data 18GROUP BY attribution_source 19ORDER BY cac;

Lifetime Value (LTV)#

Definition: Total revenue expected from a customer

1// Simple LTV calculation 2function calculateLTV(arpu: number, grossMargin: number, churnRate: number) { 3 // LTV = (ARPU × Gross Margin) / Churn Rate 4 return (arpu * grossMargin) / churnRate; 5} 6 7// Example: 8// ARPU: $100/month 9// Gross Margin: 80% 10// Monthly Churn: 5% 11// LTV = ($100 × 0.80) / 0.05 = $1,600

Cohort-based LTV:

1-- LTV by signup cohort 2WITH customer_value AS ( 3 SELECT 4 c.id, 5 DATE_TRUNC('month', c.created_at) as cohort_month, 6 SUM(p.amount) as total_revenue, 7 COUNT(DISTINCT DATE_TRUNC('month', p.created_at)) as months_active 8 FROM customers c 9 LEFT JOIN payments p ON c.id = p.customer_id 10 GROUP BY c.id, DATE_TRUNC('month', c.created_at) 11) 12SELECT 13 cohort_month, 14 COUNT(*) as customers, 15 AVG(total_revenue) as avg_ltv, 16 AVG(months_active) as avg_lifetime_months, 17 PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total_revenue) as median_ltv 18FROM customer_value 19GROUP BY cohort_month 20ORDER BY cohort_month;

LTV:CAC Ratio#

1// lib/metrics/ltv-cac.ts 2export async function getLTVCACRatio() { 3 const ltv = await calculateLTV(); 4 const cac = await calculateCAC(); 5 6 const ratio = cac > 0 ? ltv / cac : 0; 7 8 return { 9 ltv, 10 cac, 11 ratio, 12 health: ratio >= 3 ? 'healthy' : ratio >= 1 ? 'warning' : 'critical', 13 interpretation: getInterpretation(ratio), 14 }; 15} 16 17function getInterpretation(ratio: number): string { 18 if (ratio >= 5) return 'Excellent - may be under-investing in growth'; 19 if (ratio >= 3) return 'Healthy - sustainable unit economics'; 20 if (ratio >= 1) return 'Warning - improve retention or reduce CAC'; 21 return 'Critical - losing money on each customer'; 22}

LTV:CAC Benchmarks:

RatioRatingAction
>5:1Over-performingInvest more in growth
3-5:1HealthyMaintain and optimize
1-3:1WarningImprove before scaling
<1:1CriticalStop spending, fix fundamentals

CAC Payback Period#

1// Months to recover CAC 2function calculatePayback(cac: number, arpu: number, grossMargin: number) { 3 return cac / (arpu * grossMargin); 4} 5 6// Example: 7// CAC: $500 8// ARPU: $100/month 9// Gross Margin: 80% 10// Payback = $500 / ($100 × 0.80) = 6.25 months

Churn Metrics#

Logo Churn (Customer Churn)#

1-- Monthly customer churn rate 2WITH monthly_status AS ( 3 SELECT 4 DATE_TRUNC('month', d.date) as month, 5 COUNT(DISTINCT CASE WHEN s.status = 'active' THEN s.customer_id END) as active_start, 6 COUNT(DISTINCT CASE 7 WHEN s.status = 'cancelled' 8 AND s.cancelled_at BETWEEN d.date AND d.date + INTERVAL '1 month' 9 THEN s.customer_id 10 END) as churned 11 FROM generate_series( 12 DATE_TRUNC('month', NOW() - INTERVAL '12 months'), 13 DATE_TRUNC('month', NOW()), 14 INTERVAL '1 month' 15 ) d(date) 16 CROSS JOIN subscriptions s 17 GROUP BY DATE_TRUNC('month', d.date) 18) 19SELECT 20 month, 21 active_start, 22 churned, 23 ROUND(churned::numeric / NULLIF(active_start, 0) * 100, 2) as churn_rate 24FROM monthly_status 25ORDER BY month;

Revenue Churn#

1-- Monthly revenue churn rate 2SELECT 3 month, 4 starting_mrr, 5 churned_mrr, 6 ROUND(churned_mrr / NULLIF(starting_mrr, 0) * 100, 2) as revenue_churn_rate 7FROM mrr_monthly 8ORDER BY month;

Churn Benchmarks:

SegmentGood Monthly ChurnGreat Monthly Churn
SMB<5%<3%
Mid-Market<3%<2%
Enterprise<1%<0.5%

Growth Metrics#

Growth Rate#

1-- Month-over-month growth 2SELECT 3 month, 4 mrr, 5 LAG(mrr) OVER (ORDER BY month) as prev_mrr, 6 ROUND((mrr - LAG(mrr) OVER (ORDER BY month)) / 7 NULLIF(LAG(mrr) OVER (ORDER BY month), 0) * 100, 2) as mom_growth 8FROM mrr_monthly 9ORDER BY month;

Quick Ratio#

Definition: (New MRR + Expansion MRR) / (Churned MRR + Contraction MRR)

SELECT month, (new_mrr + expansion_mrr) / NULLIF(churned_mrr + contraction_mrr, 0) as quick_ratio FROM mrr_components ORDER BY month;

Quick Ratio Benchmarks:

RatioRating
>4Excellent growth
2-4Good growth
1-2Slow growth
<1Declining

Dashboard Implementation#

Metrics API#

1// app/api/metrics/route.ts 2import { NextRequest, NextResponse } from 'next/server'; 3 4export async function GET(request: NextRequest) { 5 const { searchParams } = new URL(request.url); 6 const period = searchParams.get('period') || '30d'; 7 8 const [ 9 mrr, 10 arr, 11 nrr, 12 cac, 13 ltv, 14 churn, 15 growth, 16 ] = await Promise.all([ 17 getMRR(), 18 getARR(), 19 getNRR(), 20 getCAC(period), 21 getLTV(), 22 getChurnRate(period), 23 getGrowthRate(period), 24 ]); 25 26 return NextResponse.json({ 27 revenue: { 28 mrr, 29 arr, 30 nrr, 31 mrrGrowth: growth.mrr, 32 }, 33 customers: { 34 total: await getCustomerCount(), 35 new: await getNewCustomers(period), 36 churned: await getChurnedCustomers(period), 37 churnRate: churn, 38 }, 39 unitEconomics: { 40 cac, 41 ltv, 42 ltvCacRatio: ltv / cac, 43 paybackMonths: cac / (mrr / await getCustomerCount() * 0.8), 44 }, 45 growth: { 46 momGrowth: growth.mom, 47 yoyGrowth: growth.yoy, 48 quickRatio: growth.quickRatio, 49 }, 50 }); 51}

Dashboard Component#

1// components/MetricsDashboard.tsx 2'use client'; 3 4import { useMetrics } from '@/hooks/useMetrics'; 5 6export function MetricsDashboard() { 7 const { data: metrics, isLoading } = useMetrics(); 8 9 if (isLoading) return <DashboardSkeleton />; 10 11 return ( 12 <div className="space-y-6"> 13 <div className="grid grid-cols-4 gap-4"> 14 <MetricCard 15 title="MRR" 16 value={formatCurrency(metrics.revenue.mrr)} 17 change={metrics.revenue.mrrGrowth} 18 trend={metrics.revenue.mrrGrowth > 0 ? 'up' : 'down'} 19 /> 20 <MetricCard 21 title="ARR" 22 value={formatCurrency(metrics.revenue.arr)} 23 subtitle="Annual Recurring Revenue" 24 /> 25 <MetricCard 26 title="NRR" 27 value={`${metrics.revenue.nrr}%`} 28 health={metrics.revenue.nrr >= 100 ? 'good' : 'warning'} 29 /> 30 <MetricCard 31 title="Churn Rate" 32 value={`${metrics.customers.churnRate}%`} 33 health={metrics.customers.churnRate < 5 ? 'good' : 'warning'} 34 /> 35 </div> 36 37 <div className="grid grid-cols-3 gap-4"> 38 <MetricCard 39 title="CAC" 40 value={formatCurrency(metrics.unitEconomics.cac)} 41 /> 42 <MetricCard 43 title="LTV" 44 value={formatCurrency(metrics.unitEconomics.ltv)} 45 /> 46 <MetricCard 47 title="LTV:CAC" 48 value={`${metrics.unitEconomics.ltvCacRatio.toFixed(1)}x`} 49 health={metrics.unitEconomics.ltvCacRatio >= 3 ? 'good' : 'warning'} 50 /> 51 </div> 52 53 <MRRChart data={metrics.charts.mrr} /> 54 <CohortTable data={metrics.charts.cohorts} /> 55 </div> 56 ); 57}
PhaseAgentPurpose
Databasedatabase-expertSQL queries optimization
Backendbackend-expertAPI implementation
Frontendfrontend-expertDashboard UI
Analyticsanalytics-expertMetric definitions

Deliverables#

DeliverableDescription
Metrics APIEndpoints for all key metrics
Dashboard UIVisual metrics display
SQL queriesOptimized queries for each metric
DocumentationMetric definitions and formulas
AlertsThreshold-based notifications

Best Practices#

  1. Automate everything - Manual calculations lead to errors
  2. Track trends, not just snapshots - Direction matters more than absolute
  3. Segment your data - Averages hide important patterns
  4. Benchmark against peers - Context matters
  5. Review regularly - Weekly at minimum
  6. Act on insights - Data without action is useless

Common Pitfalls#

  • Vanity metrics - Focus on actionable metrics
  • Wrong timeframes - Match to your sales cycle
  • Ignoring cohorts - New and old customers behave differently
  • Over-precision - Don't optimize for decimal places
  • Delayed data - Real-time > batch for critical metrics