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 (1)

Collapse
 
riya_sharma_0c20080ba090c profile image
Riya Sharma

Solid breakdown
The way you explained MRR churn and LTV is really clear

One thing I’ve noticed while tracking these metrics is they show what is happening but not always why
You can see churn increasing but the actual reason behind it often stays hidden

That’s where tools like Flidget.com become useful
They capture user intent right before someone leaves and turn those numbers into real insights

Curious have you tried combining dashboards with real time user feedback?