What goes in a membership growth dashboard?
A membership dashboard tracks one balance: members joining versus members leaving, and what each side is worth. Patronage and recurring-community businesses live or die on the second derivative — net member change — because gross signups can look healthy while the base quietly shrinks. Tier mix and tenure cohorts explain why.
Which cards belong on this dashboard?
- Active members today and net change vs. last month
- New members and churned members per month, side by side
- Membership MRR and average revenue per member
- Tier mix: members and MRR by tier
- Upgrades and downgrades between tiers per month
- Member lifetime support: cumulative amount paid per member
- Churn rate by tenure cohort (month 1, months 2-3, 4-12, 12+)
- Members at risk: failed payments and expiring cards
What data does this dashboard need?
- A member table with member ID, tier, start date, cancellation date, and monthly amount
- A daily snapshot of active membership state — point-in-time counts cannot be rebuilt from a mutable table
- Tier change events with the old tier, new tier, and effective date
- Payment attempts including failures, so involuntary churn is separable
- Cumulative payments per member for lifetime value views
How do you build it?
- Land the source data in a database — via a managed connector, scheduled API pulls, or a CSV loaded with
mb upload csv— keeping raw IDs, timestamps, currencies, and fee lines intact. - Build models at the grain this dashboard needs, with gross, fee, tax, and net amounts as explicit columns rather than per-card arithmetic.
- Create one saved question per card and certify the shared models so every dashboard downstream inherits the same definitions.
- Add dashboard filters for Month, tier, acquisition source, voluntary vs. involuntary churn, country.
- Show data freshness on the dashboard — these sources lag, and every viewer should see by how much.
Example card SQL
WITH months AS (
SELECT generate_series(
date_trunc('month', CURRENT_DATE) - INTERVAL '11 months',
date_trunc('month', CURRENT_DATE),
INTERVAL '1 month'
)::date AS month
), state AS (
SELECT
m.month,
-- "Active" is evaluated at month end: started before the month
-- closed and not yet cancelled at that point.
COUNT(*) FILTER (
WHERE mem.started_at < m.month + INTERVAL '1 month'
AND (mem.cancelled_at IS NULL
OR mem.cancelled_at >= m.month + INTERVAL '1 month')
) AS active_members,
COUNT(*) FILTER (
WHERE date_trunc('month', mem.started_at)::date = m.month
) AS new_members,
COUNT(*) FILTER (
WHERE date_trunc('month', mem.cancelled_at)::date = m.month
) AS churned_members,
SUM(mem.monthly_amount_usd) FILTER (
WHERE mem.started_at < m.month + INTERVAL '1 month'
AND (mem.cancelled_at IS NULL
OR mem.cancelled_at >= m.month + INTERVAL '1 month')
) AS membership_mrr
FROM months m
JOIN creator_members mem
ON mem.started_at < m.month + INTERVAL '1 month'
GROUP BY m.month
)
SELECT
month,
active_members,
new_members,
churned_members,
new_members - churned_members AS net_member_change,
ROUND(membership_mrr, 2) AS membership_mrr,
ROUND(membership_mrr / NULLIF(active_members, 0), 2) AS arpu,
ROUND(
100.0 * churned_members
/ NULLIF(LAG(active_members) OVER (ORDER BY month), 0), 1
) AS monthly_churn_pct
FROM state
ORDER BY month;