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#
| Property | Value |
|---|---|
| Metrics | 15+ key SaaS metrics |
| Tier | Free |
| Typical Duration | 1-2 weeks to implement |
| Best For | SaaS 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:
| NRR | Rating | Interpretation |
|---|---|---|
| >120% | Excellent | Strong expansion |
| 100-120% | Good | Healthy growth |
| 90-100% | Fair | Maintaining |
| <90% | Poor | Revenue 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,600Cohort-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:
| Ratio | Rating | Action |
|---|---|---|
| >5:1 | Over-performing | Invest more in growth |
| 3-5:1 | Healthy | Maintain and optimize |
| 1-3:1 | Warning | Improve before scaling |
| <1:1 | Critical | Stop 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 monthsChurn 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:
| Segment | Good Monthly Churn | Great 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:
| Ratio | Rating |
|---|---|
| >4 | Excellent growth |
| 2-4 | Good growth |
| 1-2 | Slow growth |
| <1 | Declining |
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}Recommended Agents#
| Phase | Agent | Purpose |
|---|---|---|
| Database | database-expert | SQL queries optimization |
| Backend | backend-expert | API implementation |
| Frontend | frontend-expert | Dashboard UI |
| Analytics | analytics-expert | Metric definitions |
Deliverables#
| Deliverable | Description |
|---|---|
| Metrics API | Endpoints for all key metrics |
| Dashboard UI | Visual metrics display |
| SQL queries | Optimized queries for each metric |
| Documentation | Metric definitions and formulas |
| Alerts | Threshold-based notifications |
Best Practices#
- Automate everything - Manual calculations lead to errors
- Track trends, not just snapshots - Direction matters more than absolute
- Segment your data - Averages hide important patterns
- Benchmark against peers - Context matters
- Review regularly - Weekly at minimum
- 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
Related Workflows#
- Product-Market Fit - Validate PMF with metrics
- Retention - Improve retention metrics
- Acquisition - Optimize CAC
- Fundraising Readiness - Metrics investors want