Why SaaS metrics truly matter
You've launched your SaaS, the first customers are rolling in, and your MRR is growing. But do you know why it's growing? And more importantly: can you spot the warning signs before it's too late?
Most SaaS founders build their product on gut feeling. They check Stripe occasionally, look at the number of users in the database, and hope for the best. But data-driven decisions are what separate a SaaS that scales from one that quietly bleeds out.
In this article, we'll build an analytics system for your SaaS step by step: from the right metrics to a working dashboard.
The 7 metrics every SaaS must track
1. Monthly Recurring Revenue (MRR)
MRR is the heartbeat of your SaaS. It's the predictable, monthly recurring revenue from all your active subscriptions.
-- Calculate MRR from your subscriptions table
SELECT
DATE_TRUNC('month', current_period_start) AS month,
SUM(
CASE
WHEN interval = 'year' THEN price / 12
ELSE price
END
) AS mrr
FROM subscriptions
WHERE status = 'active'
GROUP BY month
ORDER BY month DESC;
Important: break down your MRR into components:
- New MRR — new customers
- Expansion MRR — upgrades and add-ons
- Contraction MRR — downgrades
- Churned MRR — cancelled subscriptions
2. Churn Rate
Churn is the silent killer of SaaS businesses. There are two types:
// Customer churn rate
const customerChurnRate = (lostCustomers / startCustomers) * 100;
// Revenue churn rate (more important!)
const revenueChurnRate = (lostMRR / startMRR) * 100;
// Net revenue churn (including expansion)
const netRevenueChurn = ((lostMRR - expansionMRR) / startMRR) * 100;
A negative net revenue churn is the holy grail: your existing customers are growing faster than you're losing them.
3. Customer Lifetime Value (LTV)
// Simple LTV calculation
const avgRevenuePerAccount = totalMRR / totalCustomers;
const avgLifetimeMonths = 1 / (customerChurnRate / 100);
const ltv = avgRevenuePerAccount * avgLifetimeMonths;
// LTV:CAC ratio should be at least 3:1
const ltvCacRatio = ltv / customerAcquisitionCost;
4. Customer Acquisition Cost (CAC)
Add up all your sales and marketing costs and divide by the number of new customers. Don't forget: salaries, tooling, ads, and content production all count.
5. Activation Rate
What percentage of new sign-ups reach the "aha moment"? This is often the most underestimated metric.
-- Activation rate: users who complete a core action within 7 days
SELECT
DATE_TRUNC('week', u.created_at) AS cohort_week,
COUNT(DISTINCT u.id) AS signups,
COUNT(DISTINCT e.user_id) AS activated,
ROUND(
COUNT(DISTINCT e.user_id)::numeric / COUNT(DISTINCT u.id) * 100, 1
) AS activation_rate
FROM users u
LEFT JOIN events e ON e.user_id = u.id
AND e.name = 'core_action_completed'
AND e.created_at <= u.created_at + INTERVAL '7 days'
WHERE u.created_at >= NOW() - INTERVAL '3 months'
GROUP BY cohort_week
ORDER BY cohort_week DESC;
6. Daily/Weekly/Monthly Active Users (DAU/WAU/MAU)
-- DAU/MAU ratio (stickiness)
WITH daily AS (
SELECT COUNT(DISTINCT user_id) AS dau
FROM events
WHERE created_at >= CURRENT_DATE
),
monthly AS (
SELECT COUNT(DISTINCT user_id) AS mau
FROM events
WHERE created_at >= CURRENT_DATE - INTERVAL '30 days'
)
SELECT
dau,
mau,
ROUND(dau::numeric / NULLIF(mau, 0) * 100, 1) AS stickiness
FROM daily, monthly;
A DAU/MAU ratio above 20% is good; above 50% is exceptional.
7. Time to Value (TTV)
How long does it take for a new user to experience value? Measure this per cohort and optimize your onboarding to reduce TTV.
Architecture: an event-driven analytics pipeline
The best way to build SaaS analytics is with an event-based system. Every user action becomes an event.
Implementing event tracking
// lib/analytics.ts
interface AnalyticsEvent {
name: string;
userId: string;
tenantId: string;
properties?: Record<string, unknown>;
timestamp?: Date;
}
class Analytics {
private queue: AnalyticsEvent[] = [];
private flushInterval: NodeJS.Timeout;
constructor(private batchSize = 50, private intervalMs = 5000) {
this.flushInterval = setInterval(() => this.flush(), intervalMs);
}
track(event: AnalyticsEvent) {
this.queue.push({
...event,
timestamp: event.timestamp ?? new Date(),
});
if (this.queue.length >= this.batchSize) {
this.flush();
}
}
private async flush() {
if (this.queue.length === 0) return;
const batch = this.queue.splice(0, this.batchSize);
try {
await db.analyticsEvent.createMany({
data: batch.map(e => ({
name: e.name,
userId: e.userId,
tenantId: e.tenantId,
properties: e.properties ?? {},
timestamp: e.timestamp,
})),
});
} catch (error) {
// On failure: put events back in queue
this.queue.unshift(...batch);
console.error('Analytics flush failed:', error);
}
}
}
export const analytics = new Analytics();
Tracking events in your application
// In your API routes or server actions
import { analytics } from '@/lib/analytics';
// User creates a project
analytics.track({
name: 'project_created',
userId: user.id,
tenantId: user.tenantId,
properties: {
projectType: 'kanban',
teamSize: team.members.length,
},
});
// User upgrades
analytics.track({
name: 'subscription_upgraded',
userId: user.id,
tenantId: user.tenantId,
properties: {
fromPlan: 'starter',
toPlan: 'professional',
mrrDelta: 50,
},
});
Building the dashboard
Option 1: Build it yourself with SQL + charting library
For an internal dashboard, a combination of SQL views and a chart library like Recharts or Tremor is ideal.
// app/api/analytics/mrr/route.ts
import { db } from '@/lib/db';
export async function GET() {
const mrrData = await db.$queryRaw`
WITH monthly_mrr AS (
SELECT
DATE_TRUNC('month', s."currentPeriodStart") AS month,
SUM(CASE
WHEN s.interval = 'year' THEN s.price / 12
ELSE s.price
END) AS mrr
FROM subscriptions s
WHERE s.status = 'active'
GROUP BY month
)
SELECT
month,
mrr,
mrr - LAG(mrr) OVER (ORDER BY month) AS mrr_change,
ROUND(
(mrr - LAG(mrr) OVER (ORDER BY month))::numeric
/ NULLIF(LAG(mrr) OVER (ORDER BY month), 0) * 100,
1
) AS growth_pct
FROM monthly_mrr
ORDER BY month DESC
LIMIT 12
`;
return Response.json(mrrData);
}
// components/MRRChart.tsx
'use client';
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
export function MRRChart({ data }: { data: MRRDataPoint[] }) {
return (
<div className="rounded-lg border bg-card p-6">
<h3 className="text-lg font-semibold mb-4">Monthly Recurring Revenue</h3>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data}>
<XAxis
dataKey="month"
tickFormatter={(v) => new Date(v).toLocaleDateString('en-US', {
month: 'short', year: '2-digit'
})}
/>
<YAxis tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} />
<Tooltip
formatter={(v: number) => [`$${v.toLocaleString()}`, 'MRR']}
/>
<Area
type="monotone"
dataKey="mrr"
stroke="#6366f1"
fill="#6366f1"
fillOpacity={0.1}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}
Option 2: Integrate external tools
For faster implementation, you can choose from:
| Tool | Best for | Cost |
|---|---|---|
| PostHog | Product analytics + feature flags | Free up to 1M events/month |
| Mixpanel | User behavior tracking | Free up to 20M events/month |
| Metabase | SQL-based dashboards | Open source |
| Grafana | Technical metrics | Open source |
PostHog is a popular choice because it can be self-hosted and combines analytics with feature flags and session replay:
// lib/posthog.ts
import PostHog from 'posthog-node';
const posthog = new PostHog(process.env.POSTHOG_API_KEY!, {
host: process.env.POSTHOG_HOST ?? 'https://eu.posthog.com',
});
export function trackServerEvent(
userId: string,
event: string,
properties?: Record<string, unknown>
) {
posthog.capture({
distinctId: userId,
event,
properties: {
...properties,
$set: { lastSeen: new Date().toISOString() },
},
});
}
Cohort analysis: the key to growth
Cohort analysis shows you how groups of users behave over time. This is crucial for understanding whether your product is actually improving.
-- Retention cohort analysis
WITH cohorts AS (
SELECT
id AS user_id,
DATE_TRUNC('month', created_at) AS cohort_month
FROM users
),
activity AS (
SELECT DISTINCT
user_id,
DATE_TRUNC('month', created_at) AS activity_month
FROM events
)
SELECT
c.cohort_month,
COUNT(DISTINCT c.user_id) AS cohort_size,
COUNT(DISTINCT CASE
WHEN a.activity_month = c.cohort_month + INTERVAL '1 month'
THEN c.user_id
END) AS month_1,
COUNT(DISTINCT CASE
WHEN a.activity_month = c.cohort_month + INTERVAL '2 months'
THEN c.user_id
END) AS month_2,
COUNT(DISTINCT CASE
WHEN a.activity_month = c.cohort_month + INTERVAL '3 months'
THEN c.user_id
END) AS month_3
FROM cohorts c
LEFT JOIN activity a ON a.user_id = c.user_id
GROUP BY c.cohort_month
ORDER BY c.cohort_month DESC;
If your retention curve flattens out after a few months (instead of declining to zero), you've found product-market fit.
Alerting: react before it's too late
A dashboard is useless if nobody looks at it. Set up automated alerts for critical thresholds:
// lib/alerts.ts
interface AlertConfig {
metric: string;
threshold: number;
direction: 'above' | 'below';
channel: 'slack' | 'email';
}
const alerts: AlertConfig[] = [
{ metric: 'daily_churn_rate', threshold: 2, direction: 'above', channel: 'slack' },
{ metric: 'activation_rate', threshold: 30, direction: 'below', channel: 'slack' },
{ metric: 'error_rate_5xx', threshold: 1, direction: 'above', channel: 'slack' },
{ metric: 'mrr_growth', threshold: 0, direction: 'below', channel: 'email' },
];
async function checkAlerts() {
for (const alert of alerts) {
const currentValue = await getMetricValue(alert.metric);
const triggered =
alert.direction === 'above'
? currentValue > alert.threshold
: currentValue < alert.threshold;
if (triggered) {
await notify(alert.channel, {
text: `⚠️ Alert: ${alert.metric} is ${currentValue} (${alert.direction} threshold of ${alert.threshold})`,
});
}
}
}
// Run every 6 hours via a cron job
Privacy and GDPR
Analytics and privacy don't always go hand in hand. Key rules of thumb:
- Anonymize where possible — use tenant-level metrics instead of user-level tracking for external reports
- Respect Do Not Track — offer an opt-out for non-essential tracking
- Don't retain data too long — set a retention policy (e.g., 24 months for raw events)
- Document your legal basis — analytics often falls under "legitimate interest," but substantiate this
// Middleware: respect tracking preferences
function shouldTrack(userId: string, eventType: string): boolean {
// Essential metrics (billing, security) always tracked
if (['subscription_changed', 'login_failed'].includes(eventType)) {
return true;
}
// Check opt-out preference
return !userPreferences.get(userId)?.analyticsOptOut;
}
Practical tips for implementation
- Start small — Track only MRR, churn, and activation rate first. Add more metrics as you grow.
- Automate reports — Send a weekly summary to Slack every Monday with the key metrics.
- Make metrics visible — Put a TV dashboard in the office or pin it in your team channel.
- Compare with benchmarks — A 5% monthly churn rate is normal for SMB SaaS but terrible for enterprise.
- Separate operational and analytical queries — Use a read replica or a separate analytics schema to avoid loading your production database.
Conclusion
A solid analytics system isn't a luxury — it's a necessity for every serious SaaS. Start with the basics (MRR, churn, activation), build an event-driven pipeline, and expand into cohort analysis and automated alerts.
The investment pays for itself many times over: you make better decisions, spot problems earlier, and can show investors and stakeholders exactly how your SaaS is performing.
Need help building an analytics dashboard for your SaaS? Get in touch — we'd love to help.