RefearnApp RefearnApp
Tutorial React Stripe Next.js

How to Build a Referral System in React: Technical Implementation + Business Strategy

How to Build a Referral System in React: Technical Implementation + Business Strategy

How to Build a Referral System in React: Technical Implementation + Business Strategy

Every SaaS product eventually reaches the same inflection point: paid acquisition costs are climbing, organic growth is plateauing, and someone in a meeting says “we should do a referral program.” The idea gets added to the roadmap. Six months later it’s still not shipped because nobody agreed on the incentive structure, the engineering scope ballooned, and there was always something more urgent.

This guide eliminates both problems. The business decisions and the technical implementation, in one place, in the order you actually need to make them.


The Business Foundation: Decisions to Make Before Writing Code

Building the technical system before settling the business model is the most common mistake. You end up with infrastructure that doesn’t match your incentive structure, or worse, a referral program nobody uses because the rewards aren’t compelling enough to motivate sharing.

Decision 1: One-Sided vs. Double-Sided Incentives

One-sided — only the referrer gets rewarded. Common in B2B where the referrer is a trusted advisor and their recommendation carries enough weight on its own.

Double-sided — both the referrer and the new customer get rewarded. More friction to set up, but consistently outperforms one-sided programs in B2C and early-stage B2B SaaS. The new customer has a tangible reason to act on the recommendation instead of filing it away as “something to look at later.”

Rule of thumb: If your product is self-serve and the buying decision takes less than a week, use double-sided. If you have a longer sales cycle or the referrer’s credibility is the primary driver, one-sided works.

Decision 2: Cash Commission vs. Credits vs. Discount

Reward TypeBest ForRisk
Cash commission (recurring)B2B SaaS, higher ACV productsHigher cost, attracts professional affiliates who need management
Account creditsUsage-based, freemium productsLow perceived value if credits don’t translate to real savings
Discount for referred userSelf-serve, price-sensitive marketsTrains customers to expect discounts, can cheapen brand perception
Flat fee per conversionSimple programs, one-time purchasesNo ongoing motivation for referrers once they’ve earned the flat fee

For most SaaS products the highest-converting structure is: referrer earns 20–30% recurring commission + referred user gets 10–20% off their first invoice. Both parties win immediately and the referrer stays motivated to send quality leads because their income depends on referred users staying.

Decision 3: Who Can Refer?

  • All users: Maximum reach, minimum friction. Every signup gets a referral link automatically. Works well for self-serve products with low fraud risk.
  • Opted-in users only: Users apply or opt in to become referrers. Higher quality referrals, lower fraud risk, but significantly lower referral volume. Better for high-ACV products where one bad referral creates a support burden.
  • Curated affiliates: You invite specific users, influencers, or partners. Highest quality, lowest volume, most management overhead. Appropriate for enterprise products or specific partnership programs.

For early-stage SaaS: all users, opt-in at dashboard. Every user gets a referral link they can activate with one click. You’re not curating at this stage — you’re maximizing reach with minimal overhead.

Decision 4: Attribution Window

How long after clicking a referral link should a conversion still be credited to the referrer?

  • 7 days: Too short for SaaS. Most users evaluate products for longer than a week before paying.
  • 30 days: The standard. Covers most evaluation cycles.
  • 90 days: Better for high-ACV products with longer sales cycles.
  • Lifetime: The referred customer is permanently attributed to the referrer. Every future payment generates a commission. High referrer motivation, complex to manage at scale.

The Technical Architecture

A React referral system has four distinct layers:

  1. Link generation — creating unique referral links per user
  2. Attribution — capturing the referral param and persisting it through to checkout
  3. Conversion — recording the conversion server-side via webhook
  4. Reporting — surfacing referral performance back to the referrer
A four-layer architecture diagram stacked vertically. Layer 1 'Link Generation': User dashboard → unique slug → shareable URL. Layer 2 'Attribution': Visitor clicks link → React captures ?ref= param → cookie set with 30-day expiry. Layer 3 'Conversion': Stripe checkout → ref in metadata → webhook confirmed → commission recorded in DB. Layer 4 'Reporting': DB query → referrer dashboard → clicks, conversions, earnings. Dark slate background, each layer in a distinct indigo-bordered card, connecting arrows between layers.


Layer 2: Attribution — Capturing the Referral in React

When a visitor arrives via a referral link, capture the ?ref= parameter immediately and persist it in a cookie. This needs to happen before anything else — before the user reads the landing page, before they navigate to a different section, before the session has any chance to lose the parameter.

components/ReferralTracker.tsx
tsx
// components/ReferralTracker.tsx
'use client';

  import { useEffect } from 'react';
  import { useSearchParams } from 'next/navigation';

  const COOKIE_NAME = 'affiliate_ref';
  const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days

  function isValidSlug(slug: string): boolean {
    return /^[a-zA-Z0-9][a-zA-Z0-9-]{1,39}$/.test(slug);
  }

  function getCookie(name: string): string | null {
    const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`));
    return match ? decodeURIComponent(match[2]) : null;
  }

  function setCookie(name: string, value: string, maxAge: number): void {
    document.cookie = [
      `${name}=${encodeURIComponent(value)}`,
      `max-age=${maxAge}`,
      'path=/',
      'SameSite=Lax',
      process.env.NODE_ENV === 'production' ? 'Secure' : '',
    ]
      .filter(Boolean)
      .join('; ');
  }

  export function ReferralTracker() {
    const searchParams = useSearchParams();

    useEffect(() => {
      const ref = searchParams.get('ref');
      if (!ref || !isValidSlug(ref)) return;

      // First-click attribution — don't overwrite if already set
      if (getCookie(COOKIE_NAME)) return;

      setCookie(COOKIE_NAME, ref, COOKIE_MAX_AGE);
    }, [searchParams]);

    return null;
  }

Mount this in your root layout, wrapped in Suspense:

app/layout.tsx
tsx
// app/layout.tsx
import { Suspense } from 'react';
import { ReferralTracker } from '@/components/ReferralTracker';

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
  <html lang="en">
    <body>
      <Suspense fallback={null}>
        <ReferralTracker />
      </Suspense>
      {children}
    </body>
  </html>
);
}

useSearchParams() in the App Router requires the component to be wrapped in Suspense or it will throw during static rendering. The fallback= means no UI change — the tracker is invisible to the user.

Handling the OAuth Signup Gap

The most common attribution failure in React referral systems is the OAuth flow. When a user clicks a referral link then signs up via Google OAuth:

  1. They land on your page — cookie set ✅
  2. They click “Sign up with Google” — redirect to Google
  3. Google redirects back to your callback URL — the ?ref= param is gone
  4. Cookie is still there ✅ — attribution survives

The cookie persists through OAuth redirects as long as the callback URL is on your domain. The only failure case is if your OAuth callback clears cookies or redirects to a subdomain — check this specifically in your auth implementation.


Layer 3: Conversion — Stripe Integration

When a user initiates checkout, read the referral cookie server-side and attach it to the Stripe session as metadata. The conversion is then confirmed via webhook, not the client-side success page.

app/api/checkout/route.ts
typescript
// app/api/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-11-20',
});

  export async function POST(request: NextRequest): Promise<NextResponse> {
    const affiliateRef = request.cookies.get('affiliate_ref')?.value ?? null;

    const session = await stripe.checkout.sessions.create({
      mode: 'subscription',
      line_items: [{ price: process.env.STRIPE_PRICE_ID!, quantity: 1 }],
      success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
      metadata: {
        affiliate_ref: affiliateRef ?? '',
      },
      // Attach to subscription for recurring commission tracking
      ...(affiliateRef && {
        subscription_data: {
          metadata: { affiliate_ref: affiliateRef },
        },
      }),
    });

    return NextResponse.json({ url: session.url });
  }
app/api/webhooks/stripe/route.ts
typescript
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { db } from '@/lib/db';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-11-20',
});

  export async function POST(request: NextRequest): Promise<NextResponse> {
    const body = await request.text();
    const sig = request.headers.get('stripe-signature');

    if (!sig) return NextResponse.json({ error: 'No signature' }, { status: 400 });

    let event: Stripe.Event;
    try {
      event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
    } catch {
      return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
    }

    if (event.type === 'checkout.session.completed') {
      const session = event.data.object as Stripe.Checkout.Session;
      const ref = session.metadata?.affiliate_ref;

      if (ref && session.payment_status === 'paid') {
        // Idempotency guard
        const exists = await db.referralConversion.findUnique({
          where: { stripeSessionId: session.id },
        });
        if (!exists) {
          await db.referralConversion.create({
            data: {
              referralSlug: ref,
              stripeSessionId: session.id,
              stripeCustomerId: session.customer as string,
              amountCents: session.amount_total ?? 0,
              commissionCents: Math.floor((session.amount_total ?? 0) * 0.2),
              status: 'pending',
              convertedAt: new Date(),
            },
          });
        }
      }
    }

    // Recurring commissions on subscription renewals
    if (event.type === 'invoice.payment_succeeded') {
      const invoice = event.data.object as Stripe.Invoice;
      if (invoice.billing_reason !== 'subscription_cycle') {
        return NextResponse.json({ received: true });
      }

      const sub = await stripe.subscriptions.retrieve(invoice.subscription as string);
      const ref = sub.metadata?.affiliate_ref;

      if (ref && invoice.amount_paid) {
        await db.referralCommission.create({
          data: {
            referralSlug: ref,
            stripeInvoiceId: invoice.id,
            amountCents: invoice.amount_paid,
            commissionCents: Math.floor(invoice.amount_paid * 0.2),
            status: 'pending',
            paidAt: new Date(),
          },
        });
      }
    }

    return NextResponse.json({ received: true });
  }
A linear flow diagram showing the conversion path. Step 1: 'User clicks checkout button in React app'. Step 2: 'API route reads affiliate_ref cookie → attaches to Stripe session metadata'. Step 3: 'User completes Stripe checkout'. Step 4: 'Stripe fires checkout.session.completed webhook'. Step 5: 'Server reads affiliate_ref from metadata → writes commission record to Postgres'. Dark slate background, numbered steps in indigo circles, connecting arrows with subtle glow.

Layer 4: The Referrer Dashboard in React

The dashboard is what keeps referrers motivated. Without visibility into their performance, sharing drops off within weeks. This is the feedback loop that sustains the referral program long-term.

components/ReferralDashboard.tsx
tsx
// components/ReferralDashboard.tsx
'use client';

  import { useQuery } from '@tanstack/react-query';

  interface ReferralStats {
    totalClicks: number;
    totalSignups: number;
    totalConversions: number;
    pendingCommissionCents: number;
    paidCommissionCents: number;
    recentConversions: {
      id: string;
      amountCents: number;
      commissionCents: number;
      status: string;
      convertedAt: string;
    }[];
  }

  async function fetchReferralStats(): Promise<ReferralStats> {
    const res = await fetch('/api/referral/stats');
    if (!res.ok) throw new Error('Failed to fetch referral stats');
    return res.json();
  }

  function formatCents(cents: number): string {
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'USD',
    }).format(cents / 100);
  }

  export function ReferralDashboard() {
    const { data, isLoading, error } = useQuery({
      queryKey: ['referral-stats'],
      queryFn: fetchReferralStats,
      staleTime: 60_000,
    });

    if (isLoading) {
      return (
        <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
          {Array.from({ length: 4 }).map((_, i) => (
            <div key={i} className="h-24 animate-pulse rounded-xl bg-slate-100" />
          ))}
        </div>
      );
    }

    if (error || !data) {
      return <p className="text-sm text-slate-500">Could not load referral stats. Try refreshing.</p>;
    }

    const stats = [
      { label: 'Clicks', value: data.totalClicks.toLocaleString() },
      { label: 'Signups', value: data.totalSignups.toLocaleString() },
      { label: 'Conversions', value: data.totalConversions.toLocaleString() },
      { label: 'Pending Earnings', value: formatCents(data.pendingCommissionCents), highlight: true },
    ];

    return (
      <div className="space-y-6">
        <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
          {stats.map((stat) => (
            <div
              key={stat.label}
              className={`rounded-xl border p-4 ${stat.highlight ? 'border-indigo-200 bg-indigo-50' : 'border-slate-200 bg-white'}`}
            >
              <p className="text-xs font-medium text-slate-500">{stat.label}</p>
              <p className={`mt-1 text-2xl font-black ${stat.highlight ? 'text-indigo-700' : 'text-slate-900'}`}>
                {stat.value}
              </p>
            </div>
          ))}
        </div>

        {data.recentConversions.length > 0 && (
          <div className="rounded-xl border border-slate-200 bg-white">
            <div className="border-b border-slate-100 px-4 py-3">
              <h4 className="text-sm font-bold text-slate-900">Recent Conversions</h4>
            </div>
            <div className="divide-y divide-slate-100">
              {data.recentConversions.map((conversion) => (
                <div key={conversion.id} className="flex items-center justify-between px-4 py-3">
                  <div>
                    <p className="text-xs font-medium text-slate-700">
                      {formatCents(conversion.amountCents)} sale
                    </p>
                    <p className="text-xs text-slate-400">
                      {new Date(conversion.convertedAt).toLocaleDateString()}
                    </p>
                  </div>
                  <div className="text-right">
                    <p className="text-xs font-bold text-indigo-600">
                      +{formatCents(conversion.commissionCents)}
                    </p>
                    <span className={`text-xs font-medium capitalize ${conversion.status === 'paid' ? 'text-emerald-600' : 'text-amber-600'}`}>
                      {conversion.status}
                    </span>
                  </div>
                </div>
              ))}
            </div>
          </div>
        )}
      </div>
    );
  }

The Business Metrics That Tell You If It’s Working

Referral Rate

Referral Rate = Customers acquired via referral / Total new customers

Healthy range: 15–25% for a well-run SaaS referral program. Below 10% — the incentive is too weak or the program isn’t visible enough. Above 30% early on — you’re in a tight-knit community, lean into it hard.

Referrer Activation Rate

Activation Rate = Users who shared at least once / Users with a referral link

If less than 20% of users with referral links ever share them, the reward isn’t compelling or the link is too hard to find. Add the referral link to your onboarding flow, post-payment confirmation page, and a persistent dashboard widget.

Viral Coefficient (K-Factor)

K = Invites sent per user × Conversion rate of those invites

  • K < 0.1: Referral program has minimal impact on growth
  • K = 0.3–0.5: Meaningful contribution, cuts CAC materially
  • K > 1.0: Viral growth — user base grows without external acquisition

Most SaaS products land between 0.1–0.3. Moving from 0.1 to 0.2 cuts your effective CAC in half. That’s the goal — not virality, but meaningful CAC reduction.

Referred User LTV vs. Organic User LTV

Referred users consistently show 15–25% higher LTV in SaaS products. They arrive with social proof from someone they trust, lower skepticism, and higher intent. If your referred users are churning faster than organic users, the referral targeting is wrong — your referrers are sending unqualified traffic.


The Infrastructure Gap

The React components and API routes above handle attribution and conversion recording cleanly. What they don’t give you:

  • A referrer-facing portal with their full history, not just the in-app widget
  • Admin payout management — approving commissions, marking them as paid, handling disputes
  • Email notifications when commissions are earned or paid
  • Multi-campaign support — different commission rates for different referral programs
  • Coupon code tracking alongside link-based tracking

Building all of that adds 8–12 weeks of engineering to what started as “a referral program.” That’s the real scope. The attribution logic is 2 days. The surrounding product is months.

RefearnApp is an open-source, self-hosted affiliate tracking platform that ships the affiliate portal, commission engine, payout tracking, and coupon code tracking so you don’t build it from scratch. For a deeper look at how coupon-based tracking works alongside link tracking, see our guide on tracking Stripe coupons for affiliates.


Conclusion & Next Steps

A production referral system in React has four layers — link generation, cookie attribution, Stripe webhook conversion, and a referrer dashboard. The code above covers all four. The business decisions — incentive structure, attribution window, who can refer — determine whether anyone actually uses it.

  • Use double-sided incentives for self-serve SaaS — both parties win immediately
  • Set a 30-day cookie window minimum — SaaS evaluations take time
  • Use first-click attribution — the first referrer gets the credit, never overwrite the cookie
  • Confirm conversions via Stripe webhook only — never trust the client-side success page
  • Track your K-factor monthly — even moving from 0.1 to 0.2 cuts CAC in half

Ready to Build Without Subscription Limits?

RefearnApp is fully open-source, self-hostable, and AGPL-3.0 licensed. You can deploy it completely free on your own infrastructure using Coolify or follow our step-by-step setup walkthrough.

Developer Choice

Self-Hosted Open Source

Deploy directly on your own VPS via Coolify. Enjoy 100% data ownership.

Production Ready

Managed RefearnApp Cloud

Get up and running instantly. Skip the manual environment configuration, provisioning, and setup tasks with a fully turnkey, zero-config deployment.

Try Managed Cloud

Launch your affiliate program today

Create an affiliate program for your SaaS or digital product in minutes.

LIFETIME DEALBuy Once, Own Forever
or
Start for Free

No credit card required