DEV Community

kanta13jp1
kanta13jp1

Posted on

Indie SaaS Metrics That Matter — Tracking MRR, Churn & LTV with Supabase

Indie SaaS Metrics That Matter — Tracking MRR, Churn & LTV with Supabase

Growing a SaaS business requires tracking the right metrics. This guide covers calculating MRR, Churn Rate, and LTV, then building a real-time dashboard with Supabase and Flutter.

The 4 Essential SaaS Metrics

1. MRR (Monthly Recurring Revenue)

MRR = Paying Users × Average Monthly Price
Enter fullscreen mode Exit fullscreen mode
  • New MRR: Revenue from new users
  • Expansion MRR: Upgrades/upsells
  • Churned MRR: Revenue lost to cancellations
  • Net New MRR = New MRR + Expansion MRR - Churned MRR

2. Churn Rate

Monthly Churn Rate = Cancellations this month / Users at month start × 100 (%)
Enter fullscreen mode Exit fullscreen mode

Targets: B2C SaaS < 3%/month, B2B < 1%/month

3. LTV (Lifetime Value)

LTV = Avg Monthly Revenue × (1 / Monthly Churn Rate)
e.g. $9.99 × (1 / 0.03) ≈ $333
Enter fullscreen mode Exit fullscreen mode

4. CAC (Customer Acquisition Cost)

CAC = Total Marketing Spend / New Users Acquired
Healthy ratio: LTV / CAC ≥ 3
Enter fullscreen mode Exit fullscreen mode

Calculating Metrics in Supabase

-- MRR view
CREATE OR REPLACE VIEW mrr_monthly AS
SELECT
  DATE_TRUNC('month', s.created_at) AS month,
  COUNT(DISTINCT s.user_id) AS active_subscriptions,
  SUM(p.amount_usd) AS mrr_usd,
  COUNT(DISTINCT CASE WHEN s.status = 'canceled' THEN s.user_id END) AS churned
FROM subscriptions s
JOIN plans p ON s.plan_id = p.id
WHERE s.status IN ('active', 'canceled')
GROUP BY DATE_TRUNC('month', s.created_at)
ORDER BY month DESC;

-- LTV function
CREATE OR REPLACE FUNCTION calculate_ltv()
RETURNS NUMERIC AS $$
DECLARE
  avg_revenue NUMERIC;
  churn_rate NUMERIC;
BEGIN
  SELECT AVG(p.amount_usd) INTO avg_revenue
  FROM subscriptions s JOIN plans p ON s.plan_id = p.id
  WHERE s.status = 'active';

  SELECT
    COUNT(CASE WHEN status = 'canceled' THEN 1 END)::NUMERIC /
    NULLIF(COUNT(*), 0)
  INTO churn_rate
  FROM subscriptions
  WHERE created_at >= NOW() - INTERVAL '30 days';

  RETURN CASE WHEN churn_rate > 0 THEN avg_revenue / churn_rate ELSE 0 END;
END;
$$ LANGUAGE plpgsql;
Enter fullscreen mode Exit fullscreen mode

Edge Function Metrics API

export async function getMetrics(supabase: SupabaseClient) {
  const [mrrResult, ltvResult] = await Promise.all([
    supabase.from('mrr_monthly').select('*').limit(12),
    supabase.rpc('calculate_ltv'),
  ]);

  const latest = mrrResult.data?.[0];
  return {
    mrr: latest?.mrr_usd ?? 0,
    activeSubscriptions: latest?.active_subscriptions ?? 0,
    churned: latest?.churned ?? 0,
    ltv: ltvResult.data ?? 0,
    churnRate: latest ? (latest.churned / latest.active_subscriptions) * 100 : 0,
  };
}
Enter fullscreen mode Exit fullscreen mode

Flutter Dashboard Widget

class MetricCard extends StatelessWidget {
  final String label;
  final String value;
  final String? trend;
  final Color? trendColor;

  const MetricCard({super.key, required this.label, required this.value,
      this.trend, this.trendColor});

  @override
  Widget build(BuildContext context) => Card(
    child: Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(label, style: Theme.of(context).textTheme.bodySmall),
          const SizedBox(height: 8),
          Text(value, style: Theme.of(context).textTheme.headlineMedium),
          if (trend != null) Text(trend!, style: TextStyle(color: trendColor)),
        ],
      ),
    ),
  );
}

GridView.count(
  crossAxisCount: 2,
  children: [
    MetricCard(label: 'MRR', value: '\$${metrics.mrr.toStringAsFixed(0)}',
        trend: '+\$120 vs last month', trendColor: Colors.green),
    MetricCard(label: 'Churn Rate', value: '${metrics.churnRate.toStringAsFixed(1)}%',
        trend: metrics.churnRate < 3 ? '✅ On target' : '⚠️ Needs attention',
        trendColor: metrics.churnRate < 3 ? Colors.green : Colors.orange),
    MetricCard(label: 'LTV', value: '\$${metrics.ltv.toStringAsFixed(0)}'),
    MetricCard(label: 'Active Users', value: '${metrics.activeSubscriptions}'),
  ],
)
Enter fullscreen mode Exit fullscreen mode

MRR Chart with fl_chart

import 'package:fl_chart/fl_chart.dart';

LineChart(LineChartData(
  lineBarsData: [
    LineChartBarData(
      spots: mrrHistory.asMap().entries.map((e) =>
        FlSpot(e.key.toDouble(), e.value.mrrUsd.toDouble())
      ).toList(),
      isCurved: true,
      color: Theme.of(context).colorScheme.primary,
      barWidth: 3,
      dotData: const FlDotData(show: false),
    ),
  ],
))
Enter fullscreen mode Exit fullscreen mode

Summary

Monitoring MRR, Churn, and LTV weekly gives you a clear picture of SaaS health. Aggregate calculations in Supabase views and Edge Functions, then visualize with Flutter for a real-time metrics dashboard.


Building an AI Life Management app with Flutter × Supabase at 自分株式会社. Sharing indie dev insights every week.

Top comments (0)