TL;DR
No ad budget. Small audience. But you have a few happy users. A referral program turns those users into your sales team.
Here's the 4-file setup:
-
supabase/migrations/referrals.sql— referrals table + RLS -
lib/referral.ts— code generation + signup tracking -
lib/referral-reward.ts— Stripe coupon reward on conversion -
lib/referral-emails.ts— Resend notification email
Why Referral Works for Indie SaaS
Early indie SaaS users share communities. If your user is a developer building side projects, their network is full of other developers building side projects — which is exactly your target.
This means referral quality is naturally high. You're not buying random clicks.
Industry reports (ReferralHero, Viral Loops) suggest that referral programs in early-stage tools-type SaaS can contribute 10–30% of new signups. Treat these as benchmark ranges, not promises — actual results depend heavily on your community engagement and product enthusiasm.
Database Schema
ALTER TABLE profiles ADD COLUMN referral_code TEXT UNIQUE;
ALTER TABLE profiles ADD COLUMN referred_by TEXT;
CREATE TABLE referrals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
referrer_id UUID REFERENCES auth.users(id) NOT NULL,
referee_id UUID REFERENCES auth.users(id),
referral_code TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'signed_up', 'converted', 'rewarded')),
stripe_coupon_id TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
converted_at TIMESTAMPTZ,
rewarded_at TIMESTAMPTZ
);
ALTER TABLE referrals ENABLE ROW LEVEL SECURITY;
CREATE POLICY "users can see own referrals"
ON referrals FOR SELECT
USING (referrer_id = auth.uid() OR referee_id = auth.uid());
Referral Code Generation
// lib/referral.ts
import { customAlphabet } from 'nanoid'
const nanoid = customAlphabet('ABCDEFGHJKLMNPQRSTUVWXYZ23456789', 8)
export async function ensureReferralCode(userId: string): Promise<string> {
const supabase = createClient()
const { data: profile } = await supabase
.from('profiles').select('referral_code').eq('id', userId).single()
if (profile?.referral_code) return profile.referral_code
const code = nanoid()
await supabase.from('profiles').update({ referral_code: code }).eq('id', userId)
return code
}
Using a custom alphabet without I, O, 0, 1 prevents misreading.
Tracking Referral Signups
export async function trackReferralSignup(refereeId: string, referralCode: string) {
const supabase = createClient()
const { data: referrer } = await supabase
.from('profiles').select('id').eq('referral_code', referralCode).single()
if (!referrer || referrer.id === refereeId) return // invalid or self-referral
await supabase.from('referrals').insert({
referrer_id: referrer.id,
referee_id: refereeId,
referral_code: referralCode,
status: 'signed_up',
})
await supabase.from('profiles')
.update({ referred_by: referralCode }).eq('id', refereeId)
}
Call this in your Auth callback when ?ref= param is present.
Stripe Coupon Reward on Conversion
Listen for customer.subscription.created in your existing Stripe Webhook handler:
// lib/referral-reward.ts
export async function handleReferralConversion(stripeCustomerId: string) {
const supabase = createClient()
const { data: profile } = await supabase
.from('profiles').select('id, referred_by')
.eq('stripe_customer_id', stripeCustomerId).single()
if (!profile?.referred_by) return
const { data: referral } = await supabase
.from('referrals').select('id, referrer_id')
.eq('referral_code', profile.referred_by)
.eq('referee_id', profile.id)
.eq('status', 'signed_up').single()
if (!referral) return
const { data: referrerProfile } = await supabase
.from('profiles').select('stripe_customer_id')
.eq('id', referral.referrer_id).single()
if (!referrerProfile?.stripe_customer_id) return
const coupon = await stripe.coupons.create({
duration: 'once',
percent_off: 100,
name: 'Referral Reward — 1 Month Free',
metadata: { referral_id: referral.id },
})
await stripe.customers.update(referrerProfile.stripe_customer_id, { coupon: coupon.id })
await supabase.from('referrals').update({
status: 'rewarded',
stripe_coupon_id: coupon.id,
converted_at: new Date().toISOString(),
rewarded_at: new Date().toISOString(),
}).eq('id', referral.id)
return { referrerId: referral.referrer_id }
}
Resend Notification Email
// lib/referral-emails.ts
export async function sendReferralRewardEmail(referrerId: string) {
const supabase = createClient()
const { data: profile } = await supabase
.from('profiles').select('email, display_name').eq('id', referrerId).single()
if (!profile?.email) return
await resend.emails.send({
from: 'masato@masatoman.net',
to: profile.email,
subject: '🎉 Referral reward — your next month is free',
html: `<p>The person you referred just upgraded. Your next billing cycle is on us.</p>`,
})
}
Implementation Cost
| Step | Time estimate |
|---|---|
| DB schema + code gen | 1–2h |
| Signup tracking | 1–2h |
| Stripe coupon reward | 2–3h |
| Resend email | 30min–1h |
Total: 6–10 hours for a zero-ad-budget acquisition channel.
Full article with the "how does this translate to revenue?" breakdown:
👉 masatoman.net/articles/indie-dev-referral-program-2026
Top comments (0)