DEV Community

masato
masato

Posted on • Originally published at masatoman.net

Building a Referral Program for Indie SaaS 2026 — Stripe Coupons + Supabase + Resend

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());
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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>`,
  })
}
Enter fullscreen mode Exit fullscreen mode

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)