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.
Property Value Components 4 (Churn Prevention, Health Scoring, Retention Tactics, Expansion) Tier Free Typical Duration Ongoing optimization Best For SaaS products with recurring revenue
A successful retention program delivers:
Reduced churn rate
Increased Net Revenue Retention (NRR)
Predictive churn model
Automated intervention workflows
Expansion revenue playbook
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 │
└─────────────────────────────────────────┘
Identify users at risk of churning:
1 // lib/churn/signals.ts
2 interface ChurnSignal {
3 signal : string ;
4 weight : number ;
5 threshold : number ;
6 lookbackDays : number ;
7 }
8
9 const 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 ] ;
1 // lib/churn/score.ts
2 export 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
34 function 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 }
1 -- Identify at-risk users
2 WITH 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 ) ,
16 churn_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 )
37 SELECT *
38 FROM churn_risk
39 WHERE risk_score >= 40
40 ORDER BY risk_score DESC , mrr DESC ;
1 // lib/health/score.ts
2 interface 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
10 export 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 }
Component Weight Metrics Product Adoption 30% Features used, depth of usage Engagement 25% DAU, session length, frequency Growth 15% Seat expansion, usage growth Sentiment 15% NPS score, support satisfaction Relationship 15% Renewals, executive engagement
1 // components/HealthDashboard.tsx
2 export 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 }
1 // lib/retention/automations.ts
2 interface RetentionAutomation {
3 trigger : string ;
4 condition : ( user : User ) => boolean ;
5 action : ( user : User ) => Promise < void > ;
6 priority : 'high' | 'medium' | 'low' ;
7 }
8
9 const 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
47 export 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 }
Day Email Purpose 7 "We miss you" Gentle reminder with value prop 14 Feature highlight Show underused features 21 Success story Social proof from similar users 28 Special offer Discount or extended trial
1 // components/CancellationFlow.tsx
2 'use client' ;
3
4 import { useState } from 'react' ;
5
6 export 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 }
Reason Offer Too expensive 20% discount for 3 months Not using enough Pause subscription (up to 3 months) Missing features Roadmap preview + feedback call Competitor Comparison guide + success call
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%+
1 // lib/expansion/triggers.ts
2 interface UpsellTrigger {
3 condition : string ;
4 check : ( account : Account ) => boolean ;
5 offer : string ;
6 priority : number ;
7 }
8
9 const 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 ] ;
1 // Triggered when upsell opportunity detected
2 const 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 ] ;
1 -- Calculate NRR
2 WITH 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 )
24 SELECT
25 cohort_month ,
26 starting_mrr ,
27 ending_mrr ,
28 ROUND ( ending_mrr / NULLIF ( starting_mrr , 0 ) * 100 , 1 ) as nrr_percent
29 FROM cohort_revenue
30 WHERE cohort_month < NOW ( ) - INTERVAL '12 months'
31 ORDER BY cohort_month ;
Phase Agent Purpose Analytics analytics-expert Churn analysis, health scoring Development backend-expert Automation implementation Copy copywriting-expert Retention emails Strategy strategy-expert Expansion playbook
Metric Definition Target Monthly Churn % customers lost < 3% Net Revenue Retention Revenue from existing > 110% Gross Churn Revenue lost < 5% Expansion Rate Revenue gained from existing > 5% Health Score Avg customer health > 70
Deliverable Description Churn model Predictive scoring system Health dashboard Customer health tracking Automation playbook Trigger-based interventions Cancellation flow Optimized offboarding Expansion playbook Upsell/cross-sell strategies
Act early - Intervene at first sign of disengagement
Personalize - Generic outreach doesn't work
Make it easy to stay - Reduce friction in retention offers
Listen to churned users - Exit interviews are gold
Celebrate success - Recognize and reward engaged users
Measure everything - Track what actually prevents churn
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
Product-Market Fit - Strong PMF = better retention
Metrics Dashboard - Track retention KPIs
Acquisition Channels - Acquire the right users
Post-Launch - Early retention optimization