Files
Portfolio/blog-posts/88-saas-analytics-metrics.md
damjan_savicandClaude Opus 4.5 e1bbe5455d Initial commit: Portfolio Website
Vollständige Next.js 15 Portfolio-Website mit:
- Blog-System mit 100+ Artikeln
- Supabase-Integration
- Responsive Design mit Tailwind CSS
- TypeScript-Konfiguration
- Testing-Setup mit Vitest und Playwright

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 15:07:20 +01:00

662 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SaaS Analytics & Metrics
**Meta-Description:** SaaS Metriken verstehen und tracken. MRR, Churn, LTV, CAC und Product Analytics für datengetriebenes Wachstum.
**Keywords:** SaaS Metrics, MRR, ARR, Churn Rate, LTV, CAC, Product Analytics, Cohort Analysis, Retention
---
## Einführung
**SaaS Metriken** sind das Cockpit für Wachstum. Von **MRR** bis **Churn Rate** die richtigen KPIs messen und verstehen ist entscheidend für fundierte Entscheidungen. Dieser Guide zeigt die wichtigsten Metriken und deren Implementation.
---
## SaaS Metrics Overview
```
┌─────────────────────────────────────────────────────────────┐
│ SAAS METRICS FRAMEWORK │
├─────────────────────────────────────────────────────────────┤
│ │
│ Revenue Metrics: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ MRR (Monthly Recurring Revenue): │ │
│ │ ├── New MRR (New customers) │ │
│ │ ├── Expansion MRR (Upgrades, add-ons) │ │
│ │ ├── Contraction MRR (Downgrades) │ │
│ │ └── Churned MRR (Cancellations) │ │
│ │ │ │
│ │ Net MRR = New + Expansion - Contraction - Churn │ │
│ │ │ │
│ │ ARR = MRR × 12 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Growth Metrics: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ CAC (Customer Acquisition Cost): │ │
│ │ = (Sales + Marketing Costs) / New Customers │ │
│ │ │ │
│ │ LTV (Lifetime Value): │ │
│ │ = ARPU / Churn Rate │ │
│ │ │ │
│ │ LTV:CAC Ratio: │ │
│ │ ├── < 1:1 = Losing money │ │
│ │ ├── 3:1 = Healthy │ │
│ │ └── > 5:1 = Under-investing in growth │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Retention Metrics: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Churn Rate: │ │
│ │ = Customers Lost / Total Customers × 100 │ │
│ │ │ │
│ │ Net Revenue Retention (NRR): │ │
│ │ = (MRR + Expansion - Contraction - Churn) / MRR │ │
│ │ ├── < 100% = Net negative (shrinking) │ │
│ │ ├── 100% = Flat │ │
│ │ └── > 100% = Net positive (growing) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Product Metrics: │
│ ├── DAU/MAU (Daily/Monthly Active Users) │
│ ├── Feature Adoption Rate │
│ ├── Time to Value │
│ └── NPS (Net Promoter Score) │
│ │
└─────────────────────────────────────────────────────────────┘
```
---
## Metrics Implementation
```typescript
// lib/analytics/metrics.ts
import { db } from '@/lib/db';
import { startOfMonth, endOfMonth, subMonths, differenceInDays } from 'date-fns';
interface MRRBreakdown {
newMRR: number;
expansionMRR: number;
contractionMRR: number;
churnedMRR: number;
netMRR: number;
totalMRR: number;
}
export async function calculateMRR(date: Date = new Date()): Promise<MRRBreakdown> {
const monthStart = startOfMonth(date);
const monthEnd = endOfMonth(date);
const prevMonthStart = startOfMonth(subMonths(date, 1));
const prevMonthEnd = endOfMonth(subMonths(date, 1));
// Get active subscriptions at end of month
const activeSubscriptions = await db.subscription.findMany({
where: {
status: 'active',
startDate: { lte: monthEnd }
},
include: { plan: true }
});
const totalMRR = activeSubscriptions.reduce(
(sum, sub) => sum + sub.plan.monthlyPrice,
0
);
// New subscriptions this month
const newSubscriptions = await db.subscription.findMany({
where: {
startDate: { gte: monthStart, lte: monthEnd },
previousSubscriptionId: null
},
include: { plan: true }
});
const newMRR = newSubscriptions.reduce(
(sum, sub) => sum + sub.plan.monthlyPrice,
0
);
// Upgrades this month
const upgrades = await db.subscriptionChange.findMany({
where: {
type: 'upgrade',
changedAt: { gte: monthStart, lte: monthEnd }
}
});
const expansionMRR = upgrades.reduce(
(sum, change) => sum + change.mrrDelta,
0
);
// Downgrades this month
const downgrades = await db.subscriptionChange.findMany({
where: {
type: 'downgrade',
changedAt: { gte: monthStart, lte: monthEnd }
}
});
const contractionMRR = Math.abs(downgrades.reduce(
(sum, change) => sum + change.mrrDelta,
0
));
// Churned this month
const churned = await db.subscription.findMany({
where: {
status: 'canceled',
canceledAt: { gte: monthStart, lte: monthEnd }
},
include: { plan: true }
});
const churnedMRR = churned.reduce(
(sum, sub) => sum + sub.plan.monthlyPrice,
0
);
const netMRR = newMRR + expansionMRR - contractionMRR - churnedMRR;
return {
newMRR,
expansionMRR,
contractionMRR,
churnedMRR,
netMRR,
totalMRR
};
}
export async function calculateChurnRate(date: Date = new Date()): Promise<{
customerChurn: number;
revenueChurn: number;
netRevenueRetention: number;
}> {
const monthStart = startOfMonth(date);
const prevMonthStart = startOfMonth(subMonths(date, 1));
const prevMonthEnd = endOfMonth(subMonths(date, 1));
// Customers at start of month
const customersAtStart = await db.subscription.count({
where: {
status: 'active',
startDate: { lt: monthStart }
}
});
// Customers churned this month
const customersChurned = await db.subscription.count({
where: {
status: 'canceled',
canceledAt: { gte: monthStart }
}
});
const customerChurn = customersAtStart > 0
? (customersChurned / customersAtStart) * 100
: 0;
// MRR calculations for revenue churn
const mrrAtStart = await db.subscription.aggregate({
where: {
status: 'active',
startDate: { lt: monthStart }
},
_sum: { monthlyAmount: true }
});
const startMRR = mrrAtStart._sum.monthlyAmount || 0;
const { churnedMRR, expansionMRR, contractionMRR } = await calculateMRR(date);
const revenueChurn = startMRR > 0
? ((churnedMRR + contractionMRR) / startMRR) * 100
: 0;
const netRevenueRetention = startMRR > 0
? ((startMRR + expansionMRR - contractionMRR - churnedMRR) / startMRR) * 100
: 100;
return {
customerChurn,
revenueChurn,
netRevenueRetention
};
}
export async function calculateLTV(): Promise<{
averageLTV: number;
ltv_by_plan: Record<string, number>;
}> {
// Average customer lifespan
const canceledSubscriptions = await db.subscription.findMany({
where: { status: 'canceled' },
select: {
startDate: true,
canceledAt: true,
monthlyAmount: true,
plan: { select: { name: true } }
}
});
if (canceledSubscriptions.length === 0) {
return { averageLTV: 0, ltv_by_plan: {} };
}
// Calculate average lifespan and ARPU
let totalLifespanMonths = 0;
let totalRevenue = 0;
const planData: Record<string, { revenue: number; months: number; count: number }> = {};
canceledSubscriptions.forEach(sub => {
const months = differenceInDays(sub.canceledAt!, sub.startDate) / 30;
const revenue = months * sub.monthlyAmount;
totalLifespanMonths += months;
totalRevenue += revenue;
const planName = sub.plan.name;
if (!planData[planName]) {
planData[planName] = { revenue: 0, months: 0, count: 0 };
}
planData[planName].revenue += revenue;
planData[planName].months += months;
planData[planName].count += 1;
});
const averageLTV = totalRevenue / canceledSubscriptions.length;
const ltv_by_plan: Record<string, number> = {};
Object.entries(planData).forEach(([plan, data]) => {
ltv_by_plan[plan] = data.revenue / data.count;
});
return { averageLTV, ltv_by_plan };
}
export async function calculateCAC(
startDate: Date,
endDate: Date
): Promise<{
totalCAC: number;
cacByChannel: Record<string, number>;
}> {
// Get marketing and sales costs
const costs = await db.marketingCost.aggregate({
where: {
date: { gte: startDate, lte: endDate }
},
_sum: { amount: true }
});
// Get new customers in period
const newCustomers = await db.subscription.count({
where: {
startDate: { gte: startDate, lte: endDate },
previousSubscriptionId: null
}
});
const totalCosts = costs._sum.amount || 0;
const totalCAC = newCustomers > 0 ? totalCosts / newCustomers : 0;
// CAC by channel
const costsByChannel = await db.marketingCost.groupBy({
by: ['channel'],
where: { date: { gte: startDate, lte: endDate } },
_sum: { amount: true }
});
const customersByChannel = await db.subscription.groupBy({
by: ['acquisitionChannel'],
where: {
startDate: { gte: startDate, lte: endDate },
previousSubscriptionId: null
},
_count: true
});
const cacByChannel: Record<string, number> = {};
costsByChannel.forEach(cost => {
const customers = customersByChannel.find(
c => c.acquisitionChannel === cost.channel
)?._count || 0;
cacByChannel[cost.channel] = customers > 0
? (cost._sum.amount || 0) / customers
: 0;
});
return { totalCAC, cacByChannel };
}
```
---
## Cohort Analysis
```typescript
// lib/analytics/cohorts.ts
import { db } from '@/lib/db';
import { startOfMonth, format, differenceInMonths } from 'date-fns';
interface CohortData {
cohort: string; // "2024-01"
size: number;
retention: number[]; // Retention % for each month
revenue: number[]; // Revenue for each month
}
export async function generateRetentionCohorts(
months: number = 12
): Promise<CohortData[]> {
const cohorts: CohortData[] = [];
const today = new Date();
for (let i = months - 1; i >= 0; i--) {
const cohortDate = startOfMonth(subMonths(today, i));
const cohortKey = format(cohortDate, 'yyyy-MM');
// Get users who signed up in this cohort
const cohortUsers = await db.user.findMany({
where: {
createdAt: {
gte: cohortDate,
lt: startOfMonth(subMonths(today, i - 1))
}
},
select: { id: true }
});
const cohortSize = cohortUsers.length;
if (cohortSize === 0) continue;
const userIds = cohortUsers.map(u => u.id);
const retention: number[] = [];
const revenue: number[] = [];
// Calculate retention for each subsequent month
const monthsSinceCohor = differenceInMonths(today, cohortDate);
for (let m = 0; m <= monthsSinceCohor; m++) {
const checkMonth = startOfMonth(subMonths(today, monthsSinceCohor - m));
// Count active users in this month
const activeUsers = await db.userActivity.groupBy({
by: ['userId'],
where: {
userId: { in: userIds },
activityDate: {
gte: checkMonth,
lt: startOfMonth(subMonths(today, monthsSinceCohor - m - 1))
}
}
});
const retentionRate = (activeUsers.length / cohortSize) * 100;
retention.push(Math.round(retentionRate * 10) / 10);
// Calculate revenue from this cohort in this month
const monthRevenue = await db.payment.aggregate({
where: {
userId: { in: userIds },
paidAt: {
gte: checkMonth,
lt: startOfMonth(subMonths(today, monthsSinceCohor - m - 1))
}
},
_sum: { amount: true }
});
revenue.push(monthRevenue._sum.amount || 0);
}
cohorts.push({
cohort: cohortKey,
size: cohortSize,
retention,
revenue
});
}
return cohorts;
}
```
---
## Analytics Dashboard
```tsx
// app/admin/analytics/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { Line, Bar } from 'react-chartjs-2';
interface DashboardMetrics {
mrr: {
total: number;
growth: number;
breakdown: {
new: number;
expansion: number;
contraction: number;
churn: number;
};
};
customers: {
total: number;
new: number;
churned: number;
churnRate: number;
};
revenue: {
ltv: number;
cac: number;
ltvCacRatio: number;
nrr: number;
};
mrrHistory: Array<{ month: string; mrr: number }>;
}
export default function AnalyticsDashboard() {
const [metrics, setMetrics] = useState<DashboardMetrics | null>(null);
const [dateRange, setDateRange] = useState('12m');
useEffect(() => {
fetch(`/api/analytics/dashboard?range=${dateRange}`)
.then(res => res.json())
.then(setMetrics);
}, [dateRange]);
if (!metrics) return <div>Loading...</div>;
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold">Analytics Dashboard</h1>
<select
value={dateRange}
onChange={(e) => setDateRange(e.target.value)}
className="border rounded px-3 py-2"
>
<option value="3m">Last 3 months</option>
<option value="6m">Last 6 months</option>
<option value="12m">Last 12 months</option>
</select>
</div>
{/* Key Metrics */}
<div className="grid grid-cols-4 gap-4">
<MetricCard
title="MRR"
value={`$${metrics.mrr.total.toLocaleString()}`}
change={metrics.mrr.growth}
changeLabel="vs last month"
/>
<MetricCard
title="Customers"
value={metrics.customers.total.toLocaleString()}
subtitle={`+${metrics.customers.new} new this month`}
/>
<MetricCard
title="Churn Rate"
value={`${metrics.customers.churnRate.toFixed(1)}%`}
status={metrics.customers.churnRate < 5 ? 'good' : 'warning'}
/>
<MetricCard
title="LTV:CAC"
value={`${metrics.revenue.ltvCacRatio.toFixed(1)}:1`}
status={metrics.revenue.ltvCacRatio >= 3 ? 'good' : 'warning'}
/>
</div>
{/* MRR Chart */}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-semibold mb-4">MRR Growth</h2>
<Line
data={{
labels: metrics.mrrHistory.map(h => h.month),
datasets: [{
label: 'MRR',
data: metrics.mrrHistory.map(h => h.mrr),
borderColor: '#3b82f6',
tension: 0.3
}]
}}
options={{
scales: {
y: {
beginAtZero: true,
ticks: {
callback: (value) => `$${value.toLocaleString()}`
}
}
}
}}
/>
</div>
{/* MRR Breakdown */}
<div className="grid grid-cols-2 gap-6">
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-semibold mb-4">MRR Breakdown</h2>
<MRRWaterfall data={metrics.mrr.breakdown} />
</div>
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-semibold mb-4">Unit Economics</h2>
<div className="space-y-4">
<div className="flex justify-between">
<span>Customer Lifetime Value (LTV)</span>
<span className="font-semibold">
${metrics.revenue.ltv.toLocaleString()}
</span>
</div>
<div className="flex justify-between">
<span>Customer Acquisition Cost (CAC)</span>
<span className="font-semibold">
${metrics.revenue.cac.toLocaleString()}
</span>
</div>
<div className="flex justify-between">
<span>Net Revenue Retention (NRR)</span>
<span className={`font-semibold ${
metrics.revenue.nrr >= 100 ? 'text-green-600' : 'text-red-600'
}`}>
{metrics.revenue.nrr.toFixed(0)}%
</span>
</div>
</div>
</div>
</div>
</div>
);
}
function MetricCard({
title,
value,
change,
changeLabel,
subtitle,
status
}: {
title: string;
value: string;
change?: number;
changeLabel?: string;
subtitle?: string;
status?: 'good' | 'warning' | 'bad';
}) {
return (
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-sm text-gray-500 mb-1">{title}</h3>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">{value}</span>
{status && (
<span className={`w-3 h-3 rounded-full ${
status === 'good' ? 'bg-green-500' :
status === 'warning' ? 'bg-yellow-500' : 'bg-red-500'
}`} />
)}
</div>
{change !== undefined && (
<p className={`text-sm mt-1 ${change >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{change >= 0 ? '+' : ''}{change.toFixed(1)}% {changeLabel}
</p>
)}
{subtitle && (
<p className="text-sm text-gray-500 mt-1">{subtitle}</p>
)}
</div>
);
}
```
---
## Metrics Benchmarks
| Metric | Seed/Early | Series A | Scale |
|--------|------------|----------|-------|
| **MRR Growth** | 15-20%/mo | 10-15%/mo | 5-10%/mo |
| **Churn Rate** | < 8% | < 5% | < 3% |
| **NRR** | > 90% | > 100% | > 120% |
| **LTV:CAC** | > 1:1 | > 3:1 | > 5:1 |
| **CAC Payback** | < 18mo | < 12mo | < 8mo |
---
## Fazit
SaaS Analytics erfordert:
1. **Revenue Metrics**: MRR, ARR, NRR verstehen
2. **Cohort Analysis**: Retention über Zeit tracken
3. **Unit Economics**: LTV:CAC optimieren
4. **Automation**: Dashboards automatisch aktualisieren
Datengetriebene Entscheidungen führen zu nachhaltigem Wachstum.
---
## Bildprompts
1. "SaaS metrics dashboard, MRR chart and key performance indicators"
2. "Cohort retention heatmap, color-coded percentages"
3. "LTV:CAC ratio visualization, balance scale metaphor"
---
## Quellen
- [SaaS Metrics 2.0](https://www.forentrepreneurs.com/saas-metrics-2/)
- [ChartMogul SaaS Metrics](https://chartmogul.com/blog/saas-metrics/)
- [Stripe Atlas Guides](https://stripe.com/atlas/guides)
- [OpenView SaaS Benchmarks](https://openviewpartners.com/saas-benchmarks/)