Retention and Expansion

Build retention strategies including churn prevention, health scoring, cancellation flows, and expansion revenue playbooks

The Retention and Expansion workflow helps you keep users engaged and grow revenue from your existing customer base. From predicting churn to driving upsells, this guide provides actionable strategies for maximizing customer lifetime value.

Overview#

PropertyValue
Components4 (Churn Prevention, Health Scoring, Retention Tactics, Expansion)
TierFree
Typical DurationOngoing optimization
Best ForSaaS products with recurring revenue

Outcomes#

A successful retention program delivers:

  • Reduced churn rate
  • Increased Net Revenue Retention (NRR)
  • Predictive churn model
  • Automated intervention workflows
  • Expansion revenue playbook

Why Retention Matters#

THE RETENTION IMPACT 5% improvement in retention = 25-95% increase in profits Customer Acquisition Cost vs Retention Cost ┌─────────────────────────────────────────┐ │ Acquiring new customer: $100 │ │ Retaining existing: $20 │ │ Ratio: 5:1 │ └─────────────────────────────────────────┘ Revenue Math: ┌─────────────────────────────────────────┐ │ 1000 customers × $100/mo = $100K MRR │ │ │ │ 5% monthly churn = $5K lost │ │ 3% monthly churn = $3K lost │ │ Difference: $24K/year saved │ └─────────────────────────────────────────┘

Component 1: Churn Prediction#

Churn Signals#

Identify users at risk of churning:

1// lib/churn/signals.ts 2interface ChurnSignal { 3 signal: string; 4 weight: number; 5 threshold: number; 6 lookbackDays: number; 7} 8 9const churnSignals: ChurnSignal[] = [ 10 { 11 signal: 'login_frequency_drop', 12 weight: 0.25, 13 threshold: 0.5, // 50% reduction 14 lookbackDays: 14, 15 }, 16 { 17 signal: 'feature_usage_decline', 18 weight: 0.20, 19 threshold: 0.4, 20 lookbackDays: 30, 21 }, 22 { 23 signal: 'support_tickets_spike', 24 weight: 0.15, 25 threshold: 3, // 3+ tickets 26 lookbackDays: 7, 27 }, 28 { 29 signal: 'no_new_projects', 30 weight: 0.15, 31 threshold: 30, // days 32 lookbackDays: 30, 33 }, 34 { 35 signal: 'team_size_decrease', 36 weight: 0.10, 37 threshold: 1, // any reduction 38 lookbackDays: 30, 39 }, 40 { 41 signal: 'billing_failure', 42 weight: 0.15, 43 threshold: 1, 44 lookbackDays: 7, 45 }, 46];

Churn Risk Score#

1// lib/churn/score.ts 2export async function calculateChurnRisk(userId: string): Promise<{ 3 score: number; 4 signals: string[]; 5 recommendation: string; 6}> { 7 const user = await prisma.user.findUnique({ 8 where: { id: userId }, 9 include: { subscription: true, events: true }, 10 }); 11 12 let riskScore = 0; 13 const triggeredSignals: string[] = []; 14 15 // Check each signal 16 for (const signal of churnSignals) { 17 const isTriggered = await checkSignal(user, signal); 18 if (isTriggered) { 19 riskScore += signal.weight; 20 triggeredSignals.push(signal.signal); 21 } 22 } 23 24 // Normalize to 0-100 25 const normalizedScore = Math.min(Math.round(riskScore * 100), 100); 26 27 return { 28 score: normalizedScore, 29 signals: triggeredSignals, 30 recommendation: getRecommendation(normalizedScore, triggeredSignals), 31 }; 32} 33 34function getRecommendation(score: number, signals: string[]): string { 35 if (score >= 70) { 36 return 'High risk - immediate outreach required'; 37 } else if (score >= 40) { 38 return 'Medium risk - schedule check-in'; 39 } else { 40 return 'Low risk - continue monitoring'; 41 } 42}

Churn Prediction SQL#

1-- Identify at-risk users 2WITH user_activity AS ( 3 SELECT 4 user_id, 5 COUNT(CASE 6 WHEN created_at > NOW() - INTERVAL '7 days' THEN 1 7 END) as events_last_7, 8 COUNT(CASE 9 WHEN created_at BETWEEN NOW() - INTERVAL '14 days' 10 AND NOW() - INTERVAL '7 days' THEN 1 11 END) as events_prev_7, 12 MAX(created_at) as last_active 13 FROM events 14 GROUP BY user_id 15), 16churn_risk AS ( 17 SELECT 18 u.id, 19 u.email, 20 u.company_name, 21 s.plan, 22 s.mrr, 23 ua.events_last_7, 24 ua.events_prev_7, 25 ua.last_active, 26 CASE 27 WHEN ua.events_last_7 = 0 THEN 80 28 WHEN ua.events_last_7 < ua.events_prev_7 * 0.5 THEN 60 29 WHEN ua.events_last_7 < ua.events_prev_7 * 0.75 THEN 40 30 ELSE 20 31 END as risk_score 32 FROM users u 33 JOIN subscriptions s ON u.id = s.user_id 34 LEFT JOIN user_activity ua ON u.id = ua.user_id 35 WHERE s.status = 'active' 36) 37SELECT * 38FROM churn_risk 39WHERE risk_score >= 40 40ORDER BY risk_score DESC, mrr DESC;

Component 2: Health Scoring#

Customer Health Score#

1// lib/health/score.ts 2interface HealthMetrics { 3 productAdoption: number; // Feature usage breadth 4 engagement: number; // Frequency and depth 5 growth: number; // Team/usage expansion 6 sentiment: number; // NPS, support interactions 7 relationship: number; // Executive engagement, renewals 8} 9 10export async function calculateHealthScore(accountId: string): Promise<{ 11 score: number; 12 metrics: HealthMetrics; 13 status: 'healthy' | 'at_risk' | 'critical'; 14}> { 15 const metrics: HealthMetrics = { 16 productAdoption: await calculateAdoptionScore(accountId), 17 engagement: await calculateEngagementScore(accountId), 18 growth: await calculateGrowthScore(accountId), 19 sentiment: await calculateSentimentScore(accountId), 20 relationship: await calculateRelationshipScore(accountId), 21 }; 22 23 // Weighted average 24 const weights = { 25 productAdoption: 0.30, 26 engagement: 0.25, 27 growth: 0.15, 28 sentiment: 0.15, 29 relationship: 0.15, 30 }; 31 32 const score = Object.entries(metrics).reduce( 33 (sum, [key, value]) => sum + value * weights[key as keyof HealthMetrics], 34 0 35 ); 36 37 return { 38 score: Math.round(score), 39 metrics, 40 status: score >= 70 ? 'healthy' : score >= 40 ? 'at_risk' : 'critical', 41 }; 42}

Health Score Components#

ComponentWeightMetrics
Product Adoption30%Features used, depth of usage
Engagement25%DAU, session length, frequency
Growth15%Seat expansion, usage growth
Sentiment15%NPS score, support satisfaction
Relationship15%Renewals, executive engagement

Health Dashboard#

1// components/HealthDashboard.tsx 2export function HealthDashboard({ accounts }: { accounts: Account[] }) { 3 const healthyCount = accounts.filter(a => a.healthStatus === 'healthy').length; 4 const atRiskCount = accounts.filter(a => a.healthStatus === 'at_risk').length; 5 const criticalCount = accounts.filter(a => a.healthStatus === 'critical').length; 6 7 return ( 8 <div className="grid grid-cols-3 gap-4"> 9 <MetricCard 10 title="Healthy" 11 value={healthyCount} 12 color="green" 13 percentage={Math.round(healthyCount / accounts.length * 100)} 14 /> 15 <MetricCard 16 title="At Risk" 17 value={atRiskCount} 18 color="yellow" 19 percentage={Math.round(atRiskCount / accounts.length * 100)} 20 /> 21 <MetricCard 22 title="Critical" 23 value={criticalCount} 24 color="red" 25 percentage={Math.round(criticalCount / accounts.length * 100)} 26 /> 27 </div> 28 ); 29}

Component 3: Retention Tactics#

Automated Interventions#

1// lib/retention/automations.ts 2interface RetentionAutomation { 3 trigger: string; 4 condition: (user: User) => boolean; 5 action: (user: User) => Promise<void>; 6 priority: 'high' | 'medium' | 'low'; 7} 8 9const automations: RetentionAutomation[] = [ 10 { 11 trigger: 'no_login_7_days', 12 condition: (user) => daysSinceLastLogin(user) >= 7, 13 action: async (user) => { 14 await sendEmail(user.email, 'we_miss_you', { 15 name: user.name, 16 lastFeature: user.lastFeatureUsed, 17 }); 18 }, 19 priority: 'medium', 20 }, 21 { 22 trigger: 'billing_failure', 23 condition: (user) => user.subscription.status === 'past_due', 24 action: async (user) => { 25 await sendEmail(user.email, 'update_payment', { 26 name: user.name, 27 updateUrl: generatePaymentUpdateLink(user), 28 }); 29 await createTask('CS', `Follow up on billing: ${user.email}`); 30 }, 31 priority: 'high', 32 }, 33 { 34 trigger: 'low_adoption', 35 condition: (user) => user.featuresUsed < 3 && user.daysActive > 14, 36 action: async (user) => { 37 await sendEmail(user.email, 'feature_discovery', { 38 name: user.name, 39 suggestedFeatures: getUnusedFeatures(user), 40 }); 41 }, 42 priority: 'medium', 43 }, 44]; 45 46// Run automations daily 47export async function runRetentionAutomations() { 48 const users = await prisma.user.findMany({ 49 where: { subscription: { status: 'active' } }, 50 include: { subscription: true, events: true }, 51 }); 52 53 for (const user of users) { 54 for (const automation of automations) { 55 if (automation.condition(user)) { 56 await automation.action(user); 57 await logAutomation(user.id, automation.trigger); 58 } 59 } 60 } 61}

Re-engagement Email Sequences#

DayEmailPurpose
7"We miss you"Gentle reminder with value prop
14Feature highlightShow underused features
21Success storySocial proof from similar users
28Special offerDiscount or extended trial

Cancellation Flow#

1// components/CancellationFlow.tsx 2'use client'; 3 4import { useState } from 'react'; 5 6export function CancellationFlow({ subscription }: { subscription: Subscription }) { 7 const [step, setStep] = useState(1); 8 const [reason, setReason] = useState(''); 9 const [feedback, setFeedback] = useState(''); 10 11 const reasons = [ 12 { value: 'too_expensive', label: 'Too expensive', offer: 'discount' }, 13 { value: 'missing_features', label: 'Missing features I need', offer: 'roadmap' }, 14 { value: 'not_using', label: 'Not using it enough', offer: 'pause' }, 15 { value: 'competitor', label: 'Switching to competitor', offer: 'comparison' }, 16 { value: 'business_closed', label: 'Business closed', offer: null }, 17 { value: 'other', label: 'Other', offer: 'support' }, 18 ]; 19 20 async function handleCancel() { 21 await fetch('/api/subscriptions/cancel', { 22 method: 'POST', 23 body: JSON.stringify({ reason, feedback }), 24 }); 25 // Redirect to confirmation 26 } 27 28 // Step 1: Ask why 29 if (step === 1) { 30 return ( 31 <div className="max-w-md mx-auto p-6"> 32 <h2 className="text-xl font-bold mb-4">We're sorry to see you go</h2> 33 <p className="text-muted-foreground mb-6"> 34 Before you cancel, please tell us why: 35 </p> 36 <div className="space-y-3"> 37 {reasons.map((r) => ( 38 <label 39 key={r.value} 40 className={`flex items-center p-3 rounded border cursor-pointer ${ 41 reason === r.value ? 'border-primary' : 'border-border' 42 }`} 43 > 44 <input 45 type="radio" 46 name="reason" 47 value={r.value} 48 checked={reason === r.value} 49 onChange={(e) => setReason(e.target.value)} 50 className="mr-3" 51 /> 52 {r.label} 53 </label> 54 ))} 55 </div> 56 <button 57 onClick={() => setStep(2)} 58 disabled={!reason} 59 className="mt-6 w-full py-2 bg-primary text-primary-foreground rounded" 60 > 61 Continue 62 </button> 63 </div> 64 ); 65 } 66 67 // Step 2: Make an offer based on reason 68 const selectedReason = reasons.find(r => r.value === reason); 69 70 if (step === 2 && selectedReason?.offer) { 71 return <RetentionOffer type={selectedReason.offer} onDecline={() => setStep(3)} />; 72 } 73 74 // Step 3: Final confirmation 75 return ( 76 <div className="max-w-md mx-auto p-6"> 77 <h2 className="text-xl font-bold mb-4">Final step</h2> 78 <p className="text-muted-foreground mb-4"> 79 Any feedback to help us improve? 80 </p> 81 <textarea 82 value={feedback} 83 onChange={(e) => setFeedback(e.target.value)} 84 placeholder="Optional feedback..." 85 className="w-full p-3 border rounded min-h-[100px]" 86 /> 87 <button 88 onClick={handleCancel} 89 className="mt-6 w-full py-2 bg-destructive text-destructive-foreground rounded" 90 > 91 Cancel Subscription 92 </button> 93 </div> 94 ); 95}

Retention Offers#

ReasonOffer
Too expensive20% discount for 3 months
Not using enoughPause subscription (up to 3 months)
Missing featuresRoadmap preview + feedback call
CompetitorComparison guide + success call

Component 4: Expansion Revenue#

Expansion Playbook#

EXPANSION OPPORTUNITIES Upsell (More of same) ├── Higher tier plan ├── More seats/users └── Higher usage limits Cross-sell (Different products) ├── Add-on features ├── Integrations └── Professional services Net Revenue Retention Target: 110%+

Upsell Triggers#

1// lib/expansion/triggers.ts 2interface UpsellTrigger { 3 condition: string; 4 check: (account: Account) => boolean; 5 offer: string; 6 priority: number; 7} 8 9const upsellTriggers: UpsellTrigger[] = [ 10 { 11 condition: 'approaching_seat_limit', 12 check: (a) => a.seatsUsed >= a.seatsAllowed * 0.8, 13 offer: 'Upgrade to add more team members', 14 priority: 1, 15 }, 16 { 17 condition: 'hitting_usage_limit', 18 check: (a) => a.apiCalls >= a.apiLimit * 0.9, 19 offer: 'Upgrade for unlimited API calls', 20 priority: 1, 21 }, 22 { 23 condition: 'using_all_features', 24 check: (a) => a.featuresUsed >= a.featuresAvailable * 0.8, 25 offer: 'Unlock advanced features with Pro', 26 priority: 2, 27 }, 28 { 29 condition: 'high_engagement', 30 check: (a) => a.dau / a.totalUsers > 0.6 && a.plan === 'starter', 31 offer: 'Your team loves us - upgrade for more!', 32 priority: 3, 33 }, 34];

Expansion Email Sequence#

1// Triggered when upsell opportunity detected 2const expansionEmails = [ 3 { 4 day: 0, 5 subject: "You're getting close to your limit", 6 template: 'limit_approaching', 7 cta: 'View upgrade options', 8 }, 9 { 10 day: 3, 11 subject: 'Unlock more with our Pro plan', 12 template: 'feature_comparison', 13 cta: 'Compare plans', 14 }, 15 { 16 day: 7, 17 subject: 'Special offer for growing teams', 18 template: 'limited_discount', 19 cta: 'Get 20% off upgrade', 20 }, 21];

Net Revenue Retention Calculation#

1-- Calculate NRR 2WITH cohort_revenue AS ( 3 SELECT 4 DATE_TRUNC('month', first_payment_date) as cohort_month, 5 SUM(CASE 6 WHEN payment_month = cohort_month THEN mrr 7 ELSE 0 8 END) as starting_mrr, 9 SUM(CASE 10 WHEN payment_month = cohort_month + INTERVAL '12 months' THEN mrr 11 ELSE 0 12 END) as ending_mrr 13 FROM ( 14 SELECT 15 customer_id, 16 DATE_TRUNC('month', created_at) as payment_month, 17 MIN(DATE_TRUNC('month', created_at)) OVER (PARTITION BY customer_id) as first_payment_date, 18 SUM(amount) as mrr 19 FROM payments 20 GROUP BY customer_id, DATE_TRUNC('month', created_at) 21 ) payments_by_month 22 GROUP BY DATE_TRUNC('month', first_payment_date) 23) 24SELECT 25 cohort_month, 26 starting_mrr, 27 ending_mrr, 28 ROUND(ending_mrr / NULLIF(starting_mrr, 0) * 100, 1) as nrr_percent 29FROM cohort_revenue 30WHERE cohort_month < NOW() - INTERVAL '12 months' 31ORDER BY cohort_month;
PhaseAgentPurpose
Analyticsanalytics-expertChurn analysis, health scoring
Developmentbackend-expertAutomation implementation
Copycopywriting-expertRetention emails
Strategystrategy-expertExpansion playbook

Metrics Dashboard#

MetricDefinitionTarget
Monthly Churn% customers lost< 3%
Net Revenue RetentionRevenue from existing> 110%
Gross ChurnRevenue lost< 5%
Expansion RateRevenue gained from existing> 5%
Health ScoreAvg customer health> 70

Deliverables#

DeliverableDescription
Churn modelPredictive scoring system
Health dashboardCustomer health tracking
Automation playbookTrigger-based interventions
Cancellation flowOptimized offboarding
Expansion playbookUpsell/cross-sell strategies

Best Practices#

  1. Act early - Intervene at first sign of disengagement
  2. Personalize - Generic outreach doesn't work
  3. Make it easy to stay - Reduce friction in retention offers
  4. Listen to churned users - Exit interviews are gold
  5. Celebrate success - Recognize and reward engaged users
  6. Measure everything - Track what actually prevents churn

Common Pitfalls#

  • Ignoring early signals - Don't wait until they cancel
  • Annoying interventions - Quality over quantity
  • One-size-fits-all - Segment your approach
  • Ignoring feedback - If many churn for same reason, fix it
  • Aggressive upselling - Bad timing destroys trust