Build This Now
Build This Now
O que é o Código Claude?Instalar o Claude CodeInstalador Nativo do Claude CodeO Teu Primeiro Projeto com Claude Code
Failed Payment RecoveryLocalization and i18nPDF InvoicesReferral ProgramTwo-Factor AuthNext.js DevTools MCPNext.js Agent SetupSubscription BillingMulti-Tenant SaaSAI Chat FeatureRate LimitingStripe WebhooksSemantic SearchRealtime UpdatesDrip Email Sequencesv2.1.122 Release NotesClaude Code Dynamic Workflows: Como Orquestrar 1.000 Subagentes Num Codebase RealMelhores Práticas do Claude CodeBoas Práticas para o Claude Opus 4.7Claude Code num VPSIntegração GitRevisão de Código com ClaudeWorktrees no Claude CodeControle Remoto do Claude CodeChannels do Claude CodeChannels, Routines, Teleport, DispatchTarefas Agendadas no Claude CodePermissões do Claude CodeModo Auto do Claude CodeAdicionar Pagamentos Stripe Com o Claude CodeFeedback LoopsFluxos de Trabalho com TodosTarefas no Claude CodeTemplates de ProjetoPreços e Consumo de Tokens no Claude CodePreços do Claude Code: O Que Vais Mesmo PagarClaude Code Ultra ReviewConstruir Uma App Next.js Com o Claude CodeSupabase DatabaseVercel DeepsecTest-Driven DevelopmentComo Construir um MVP de SaaS Com o Claude CodeAdicionar Autenticação Com o Claude Code (Supabase Auth)Adicionar Email Transacional Com o Claude Code (Resend + React Email)Construir Uma API Type-Safe Com o Claude Code (oRPC + Zod)File UploadsBackground Jobs (Inngest)Admin DashboardFull-Text SearchClaude Agent SDKKiro Migration GuideClaude Research AgentLeave Grok BuildRoute Subagent ModelsClaude Monorepo SetupCI Repair AgentVisual Regression TestsAgent Cost DashboardCursor Migration GuideComércio Agêntico: Como Construir uma App Que Agentes de IA Podem Pagar1M ContextUser API KeysAudit LogsCSV Import PipelineDatabase MigrationsProduction Error TrackingFeature FlagsGitHub ActionsHeadless ModeMax Plan vs APICaching and RevalidationIn-App NotificationsOutbound WebhooksPrompt CachingRoles & PermissionsMarketplace PaymentsUsage-Based BillingQuanto Custa Construir um SaaS com Claude Code em 2026Parallel AI AgentsCoding Agent Injection
speedy_devvkoen_salo
Blog/Handbook/Workflow/Referral Program

Referral Program

Build a refer-a-friend feature end to end in Next.js 16: unique referral codes, cookie plus server-side attribution, a Stripe coupon and account credit granted on the first paid invoice, self-referral and fraud guards, and a referrer dashboard.

Quer o framework por trás destes projetos?

Obtenha o sistema Claude Code que usamos para planejar, construir, testar e lançar software em produção.

Veja o que construímos para empresas →
speedy_devvkoen_salo
speedy_devvWritten by speedy_devvPublished Jul 30, 202611 min readHandbook hubWorkflow index

A referral program is four moving parts: a code that identifies the referrer, attribution that survives a page reload, a reward that only fires when real money moves, and guards that stop the whole thing from being farmed. This walks through all four in Next.js 16, with codes and referral rows in PostgreSQL via Supabase, attribution in a cookie plus a server-side write, and a Stripe credit granted on the first paid invoice. Claude Code does the typing while you decide the policy.

What You Are Actually Building

Five layers, each with one job.

LayerWhere it livesWhat it does
Codereferral_codes tableOne stable, unguessable code per user
Captureapp/r/[code]/route.ts and proxy.tsSets a first-touch cookie, then redirects
AttributionAuth callback, server sideWrites a referrals row the client cannot forge
RewardStripe webhook on invoice.paidCoupon for the friend, credit for the referrer
DashboardServer Component at /referralsShare link, pending count, credit earned

The important design decision is where attribution is written. If the browser tells your API "user X referred me", anyone can mint referrals with curl. The cookie only carries a code. The row is written by server code that looks the code up, checks it is not the signer's own, and inserts with the service role.

The Tables in Supabase

Two tables. referral_codes holds one row per user, and referrals holds one row per referred account with a status that walks forward from pending to rewarded. The constraints do most of the fraud work before any application code runs.

create table public.referral_codes (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null unique references auth.users(id) on delete cascade,
  code text not null unique,
  created_at timestamptz not null default now()
);

create type referral_status as enum ('pending', 'qualified', 'rewarded', 'rejected');

create table public.referrals (
  id uuid primary key default gen_random_uuid(),
  referrer_id uuid not null references auth.users(id) on delete cascade,
  referred_user_id uuid not null unique references auth.users(id) on delete cascade,
  code text not null references public.referral_codes(code),
  status referral_status not null default 'pending',
  signup_ip_hash text,
  stripe_invoice_id text,
  reward_cents integer,
  created_at timestamptz not null default now(),
  qualified_at timestamptz,
  rewarded_at timestamptz,
  constraint referrals_no_self_referral check (referrer_id <> referred_user_id)
);

create index referrals_referrer_status_idx
  on public.referrals (referrer_id, status);

The unique on referred_user_id means an account can be referred exactly once, forever. The check constraint makes self-referral a database error rather than a policy you have to remember. Now lock reads down with RLS. Both sides of a referral can see the row, and nobody gets an insert or update policy, because every write goes through server code holding the service role.

alter table public.referral_codes enable row level security;
alter table public.referrals enable row level security;

create policy "read own referral code"
  on public.referral_codes for select
  using ((select auth.uid()) = user_id);

create policy "read referrals you are part of"
  on public.referrals for select
  using (
    (select auth.uid()) = referrer_id
    or (select auth.uid()) = referred_user_id
  );

Codes That Do Not Collide or Look Rude

Sequential IDs let anyone enumerate your customers. UUIDs are unguessable but nobody types them. What you want is a short code from a reduced alphabet with the ambiguous characters removed, so a code read off a podcast still works. Rejection sampling keeps the distribution uniform instead of biasing toward the front of the alphabet.

// lib/referral/code.ts
import { randomBytes } from "node:crypto";

// 30 characters: no 0, 1, I, L, O, or U (ambiguity and accidental words)
const ALPHABET = "23456789ABCDEFGHJKMNPQRSTVWXYZ";
const LIMIT = Math.floor(256 / ALPHABET.length) * ALPHABET.length;

export function generateReferralCode(length = 8): string {
  let out = "";

  while (out.length < length) {
    for (const byte of randomBytes(length)) {
      if (byte >= LIMIT) continue;
      out += ALPHABET[byte % ALPHABET.length];
      if (out.length === length) break;
    }
  }

  return out;
}

export const REFERRAL_CODE_PATTERN = /^[0-9A-Z]{6,12}$/;

Allocation has to survive two tabs hitting the dashboard at once. The unique index on user_id is the arbiter: if the insert loses the race it returns Postgres error 23505, and the loser re-reads the winner's row instead of creating a duplicate.

// lib/referral/ensure-code.ts
import "server-only";
import { createServiceClient } from "@/lib/supabase/service";
import { generateReferralCode } from "./code";

export async function ensureReferralCode(userId: string): Promise<string> {
  const supabase = createServiceClient();

  const read = async () => {
    const { data } = await supabase
      .from("referral_codes")
      .select("code")
      .eq("user_id", userId)
      .maybeSingle();
    return data?.code ?? null;
  };

  const existing = await read();
  if (existing) return existing;

  for (let attempt = 0; attempt < 5; attempt++) {
    const { data, error } = await supabase
      .from("referral_codes")
      .insert({ user_id: userId, code: generateReferralCode() })
      .select("code")
      .single();

    if (data) return data.code;
    if (error?.code !== "23505") throw new Error(error?.message ?? "insert failed");

    const raced = await read();
    if (raced) return raced;
  }

  throw new Error("Could not allocate a referral code");
}

The service client is server-only. Importing it from a Client Component ships your service role key to the browser, so the server-only import is not decoration.

// lib/supabase/service.ts
import "server-only";
import { createClient } from "@supabase/supabase-js";

export function createServiceClient() {
  return createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!,
    { auth: { persistSession: false, autoRefreshToken: false } },
  );
}

Capturing the Click

The share link is a short route that sets a cookie and redirects. Setting it here rather than in client JavaScript means it survives ad blockers and works before React hydrates. Note the await params, required in Next.js 16, and the first-touch rule: if a ref cookie already exists, the original referrer keeps the credit.

// app/r/[code]/route.ts
import { NextResponse, type NextRequest } from "next/server";
import { REFERRAL_CODE_PATTERN } from "@/lib/referral/code";

const COOKIE = "ref";
const MAX_AGE = 60 * 60 * 24 * 60; // 60 days

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ code: string }> },
) {
  const { code } = await params;
  const clean = code.trim().toUpperCase();

  const response = NextResponse.redirect(new URL("/signup", request.url), 302);
  const alreadyAttributed = request.cookies.has(COOKIE);

  if (!alreadyAttributed && REFERRAL_CODE_PATTERN.test(clean)) {
    response.cookies.set({
      name: COOKIE,
      value: clean,
      httpOnly: true,
      sameSite: "lax",
      secure: process.env.NODE_ENV === "production",
      path: "/",
      maxAge: MAX_AGE,
    });
  }

  return response;
}

People paste links with a query string too, usually onto a blog post or a pricing page rather than the share URL. Catch those in proxy.ts, which is what Next.js 16 calls middleware, so any page on the site can carry attribution.

// proxy.ts
import { NextResponse, type NextRequest } from "next/server";

const COOKIE = "ref";
const MAX_AGE = 60 * 60 * 24 * 60;
const CODE_PATTERN = /^[0-9A-Z]{6,12}$/;

export default function proxy(request: NextRequest) {
  const raw = request.nextUrl.searchParams.get("ref");
  const response = NextResponse.next();

  if (!raw || request.cookies.has(COOKIE)) return response;

  const code = raw.trim().toUpperCase();
  if (!CODE_PATTERN.test(code)) return response;

  response.cookies.set({
    name: COOKIE,
    value: code,
    httpOnly: true,
    sameSite: "lax",
    secure: process.env.NODE_ENV === "production",
    path: "/",
    maxAge: MAX_AGE,
  });

  return response;
}

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

The pattern is duplicated here on purpose. proxy.ts should stay dependency-light, and a regex literal is cheaper than pulling in a module that imports Node crypto.

Attribution Happens on the Server

This function runs once, right after an account is created. It reads the cookie, resolves the code to its owner, refuses self-referral, hashes the signup IP for later fraud checks, and inserts with the service role. The client never sends a referrer ID, so there is nothing to tamper with.

// lib/referral/attribute.ts
import "server-only";
import { createHash } from "node:crypto";
import { headers } from "next/headers";
import { createServiceClient } from "@/lib/supabase/service";

const SAME_IP_LIMIT = 3;

function hashIp(ip: string): string {
  return createHash("sha256")
    .update(`${ip}:${process.env.REFERRAL_IP_SALT}`)
    .digest("hex");
}

export async function attributeSignup(userId: string, code: string) {
  const supabase = createServiceClient();

  const { data: owner } = await supabase
    .from("referral_codes")
    .select("user_id")
    .eq("code", code)
    .maybeSingle();

  if (!owner || owner.user_id === userId) return;

  const headerList = await headers();
  const ip = headerList.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "";
  const ipHash = ip ? hashIp(ip) : null;

  let status: "pending" | "rejected" = "pending";

  if (ipHash) {
    const { count } = await supabase
      .from("referrals")
      .select("id", { count: "exact", head: true })
      .eq("referrer_id", owner.user_id)
      .eq("signup_ip_hash", ipHash);

    if ((count ?? 0) >= SAME_IP_LIMIT) status = "rejected";
  }

  const { error } = await supabase.from("referrals").insert({
    referrer_id: owner.user_id,
    referred_user_id: userId,
    code,
    status,
    signup_ip_hash: ipHash,
  });

  // 23505 means this account was already referred. That is fine, first one wins.
  if (error && error.code !== "23505") throw new Error(error.message);
}

Call it from the auth callback, then clear the cookie on the response you return so a second signup in the same browser does not reuse a stale code.

// app/auth/callback/route.ts
import { NextResponse, type NextRequest } from "next/server";
import { createServerClient } from "@/lib/supabase/server";
import { attributeSignup } from "@/lib/referral/attribute";
import { REFERRAL_CODE_PATTERN } from "@/lib/referral/code";

export async function GET(request: NextRequest) {
  const authCode = request.nextUrl.searchParams.get("code");
  if (!authCode) {
    return NextResponse.redirect(new URL("/login?error=missing_code", request.url));
  }

  const supabase = await createServerClient();
  const { data, error } = await supabase.auth.exchangeCodeForSession(authCode);

  if (error || !data.user) {
    return NextResponse.redirect(new URL("/login?error=auth", request.url));
  }

  const response = NextResponse.redirect(new URL("/dashboard", request.url));
  const ref = request.cookies.get("ref")?.value;

  if (ref && REFERRAL_CODE_PATTERN.test(ref)) {
    await attributeSignup(data.user.id, ref);
    response.cookies.delete("ref");
  }

  return response;
}

Guards, Ranked by What They Actually Stop

Fraud guards fail when they are one clever heuristic instead of several boring layers. Every layer here is cheap, and each one catches a different kind of abuse.

GuardWhere it livesWhat it stops
Check constraintDatabaseReferring your own account ID
Unique on referred_user_idDatabaseRe-attributing an existing customer
Server-side attributionAuth callbackForged referrer IDs from the client
Signup IP hash capattributeSignupOne person farming with three burner emails
Reward on paid invoice onlyStripe webhookDisposable-email signup loops
Monthly cap per referrergrantReferralRewardA viral post costing more than it earns
Clawback on refund or disputeStripe webhookPay, collect credit, charge back

The two that carry real weight are the last four. Everything before them is hygiene. Paying only on a paid invoice is the structural fix, because it makes the cheapest form of abuse cost more than the reward, and the clawback closes the obvious loophole that opens once someone works out that a card can be disputed after a credit is granted.

Store a hash of the signup IP, never the address itself. You get the duplicate detection you need without holding a raw identifier you would then have to explain in a privacy policy, and the salt means a leaked table is not a reverse lookup. Do not block on the IP match, either. Shared offices, university networks, and carrier NAT put thousands of unrelated people behind one address, so a rejected status that a human can review beats a hard failure at signup.

One policy decision has no technically correct answer: first touch or last touch. This build gives credit to the first code that lands, checked with request.cookies.has before every write. It rewards discovery over whoever happened to send the final link, and it is the version people argue with you about least.

The Discount the Friend Gets

Create the coupon once. Percent off, duration once, with a fixed ID so your code can reference it without a lookup.

stripe coupons create \
  --id=REFERRED_FRIEND_20 \
  --percent-off=20 \
  --duration=once \
  --name="Referred friend"

Then attach it at checkout, but only after confirming server side that a referral row exists for this user. Reading the cookie here would let anyone hand themselves a discount. Checkout Sessions accept either discounts or allow_promotion_codes, never both, so pick one path per session.

// app/(app)/billing/actions.ts
"use server";

import { redirect } from "next/navigation";
import { stripe } from "@/lib/stripe";
import { createServerClient } from "@/lib/supabase/server";

const REFERRED_COUPON_ID = "REFERRED_FRIEND_20";

export async function startCheckout(priceId: string) {
  const supabase = await createServerClient();
  const { data: claims } = await supabase.auth.getClaims();
  const userId = claims?.claims.sub;
  if (!userId) redirect("/login");

  const { data: profile } = await supabase
    .from("profiles")
    .select("stripe_customer_id")
    .eq("id", userId)
    .single();

  if (!profile?.stripe_customer_id) throw new Error("No Stripe customer on file");

  const { data: referral } = await supabase
    .from("referrals")
    .select("id")
    .eq("referred_user_id", userId)
    .maybeSingle();

  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    customer: profile.stripe_customer_id,
    line_items: [{ price: priceId, quantity: 1 }],
    discounts: referral ? [{ coupon: REFERRED_COUPON_ID }] : undefined,
    success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/billing?status=success`,
    cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing`,
  });

  if (!session.url) throw new Error("Stripe returned no checkout URL");
  redirect(session.url);
}

The Stripe client stays boring. Leaving apiVersion off pins you to whatever version the installed SDK ships with, which is one fewer string to keep in sync.

// lib/stripe.ts
import "server-only";
import Stripe from "stripe";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

The Credit the Referrer Gets

The reward fires on invoice.paid with a billing_reason of subscription_create, which is the first real payment and nothing else. A negative customer balance transaction is a cash credit that Stripe applies automatically to the next invoice, and it accumulates across referrals instead of fighting over one coupon slot.

Two details carry the weight. The status filter on the update makes the whole handler idempotent, because a webhook replay updates zero rows and returns early. The idempotency key on the Stripe call means even a crash between the two writes cannot double-credit anyone.

// lib/referral/reward.ts
import "server-only";
import type Stripe from "stripe";
import { stripe } from "@/lib/stripe";
import { createServiceClient } from "@/lib/supabase/service";

const REWARD_CENTS = 2000;
const MONTHLY_CAP_CENTS = 20000;

export async function grantReferralReward(invoice: Stripe.Invoice) {
  if (invoice.billing_reason !== "subscription_create") return;
  if (invoice.amount_paid <= 0) return;

  const customerId =
    typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id;
  if (!customerId) return;

  const supabase = createServiceClient();

  const { data: payer } = await supabase
    .from("profiles")
    .select("id")
    .eq("stripe_customer_id", customerId)
    .maybeSingle();
  if (!payer) return;

  // Claiming the row is the lock. A replay matches nothing and stops here.
  const { data: referral } = await supabase
    .from("referrals")
    .update({
      status: "qualified",
      qualified_at: new Date().toISOString(),
      stripe_invoice_id: invoice.id,
    })
    .eq("referred_user_id", payer.id)
    .eq("status", "pending")
    .select("id, referrer_id")
    .maybeSingle();
  if (!referral) return;

  const monthStart = new Date();
  monthStart.setUTCDate(1);
  monthStart.setUTCHours(0, 0, 0, 0);

  const { data: paidThisMonth } = await supabase
    .from("referrals")
    .select("reward_cents")
    .eq("referrer_id", referral.referrer_id)
    .eq("status", "rewarded")
    .gte("rewarded_at", monthStart.toISOString());

  const spent = (paidThisMonth ?? []).reduce((sum, row) => sum + (row.reward_cents ?? 0), 0);
  // Over the cap the row stays qualified and a monthly sweep pays it later.
  if (spent + REWARD_CENTS > MONTHLY_CAP_CENTS) return;

  const { data: referrer } = await supabase
    .from("profiles")
    .select("stripe_customer_id")
    .eq("id", referral.referrer_id)
    .maybeSingle();
  if (!referrer?.stripe_customer_id) return;

  await stripe.customers.createBalanceTransaction(
    referrer.stripe_customer_id,
    {
      amount: -REWARD_CENTS, // negative is a credit against future invoices
      currency: "usd",
      description: `Referral credit ${referral.id}`,
      metadata: { referral_id: referral.id },
    },
    { idempotencyKey: `referral-credit-${referral.id}` },
  );

  await supabase
    .from("referrals")
    .update({
      status: "rewarded",
      rewarded_at: new Date().toISOString(),
      reward_cents: REWARD_CENTS,
    })
    .eq("id", referral.id);
}

Refunds and chargebacks need the mirror image. A positive balance transaction reverses the credit, and the same status-filtered update stops a duplicate dispute event from clawing back twice.

Getting from a charge back to an invoice takes one extra hop now. Recent Stripe API versions dropped the invoice pointer from the Charge object, so the route is charge to payment intent to invoice payment. If you copied a snippet that reads charge.invoice, it stopped compiling a while ago.

// lib/referral/clawback.ts
import "server-only";
import { stripe } from "@/lib/stripe";
import { createServiceClient } from "@/lib/supabase/service";

export async function clawbackReferralReward(chargeId: string) {
  const charge = await stripe.charges.retrieve(chargeId);
  const paymentIntentId =
    typeof charge.payment_intent === "string"
      ? charge.payment_intent
      : charge.payment_intent?.id;
  if (!paymentIntentId) return;

  // The Charge object no longer carries an invoice pointer, so walk back
  // through the invoice payment that recorded this payment intent.
  const { data: payments } = await stripe.invoicePayments.list({
    payment: { type: "payment_intent", payment_intent: paymentIntentId },
    limit: 1,
  });

  const [payment] = payments;
  if (!payment) return;

  const invoiceId =
    typeof payment.invoice === "string" ? payment.invoice : payment.invoice.id;

  const supabase = createServiceClient();

  const { data: referral } = await supabase
    .from("referrals")
    .update({ status: "rejected" })
    .eq("stripe_invoice_id", invoiceId)
    .eq("status", "rewarded")
    .select("id, referrer_id, reward_cents")
    .maybeSingle();
  if (!referral?.reward_cents) return;

  const { data: referrer } = await supabase
    .from("profiles")
    .select("stripe_customer_id")
    .eq("id", referral.referrer_id)
    .maybeSingle();
  if (!referrer?.stripe_customer_id) return;

  await stripe.customers.createBalanceTransaction(
    referrer.stripe_customer_id,
    {
      amount: referral.reward_cents, // positive removes the credit
      currency: "usd",
      description: `Referral clawback ${referral.id}`,
      metadata: { referral_id: referral.id },
    },
    { idempotencyKey: `referral-clawback-${referral.id}` },
  );
}

The webhook route wires all three events together. Verify the signature against the raw body text before parsing anything, and always return 200 once you have accepted the event so Stripe stops retrying.

// app/api/stripe/webhook/route.ts
import { NextResponse, type NextRequest } from "next/server";
import type Stripe from "stripe";
import { stripe } from "@/lib/stripe";
import { grantReferralReward } from "@/lib/referral/reward";
import { clawbackReferralReward } from "@/lib/referral/clawback";

export async function POST(request: NextRequest) {
  const signature = request.headers.get("stripe-signature");
  if (!signature) return new NextResponse("Missing signature", { status: 400 });

  const payload = await request.text();
  let event: Stripe.Event;

  try {
    event = await stripe.webhooks.constructEventAsync(
      payload,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch {
    return new NextResponse("Invalid signature", { status: 400 });
  }

  switch (event.type) {
    case "invoice.paid":
      await grantReferralReward(event.data.object);
      break;
    case "charge.refunded":
      await clawbackReferralReward(event.data.object.id);
      break;
    case "charge.dispute.created": {
      const dispute = event.data.object;
      const chargeId =
        typeof dispute.charge === "string" ? dispute.charge : dispute.charge.id;
      await clawbackReferralReward(chargeId);
      break;
    }
  }

  return NextResponse.json({ received: true });
}

The Referrer Dashboard

Program terms are the same for everyone, so they get "use cache" with a tag you can bust from a Server Action when marketing changes the offer. Per-user referral stats do not get cached, ever, because a cache entry keyed on nothing would serve one customer's numbers to another.

// lib/referral/terms.ts
import { cacheLife, cacheTag } from "next/cache";
import { createServiceClient } from "@/lib/supabase/service";

export async function getReferralTerms() {
  "use cache";
  cacheLife("hours");
  cacheTag("referral-terms");

  const supabase = createServiceClient();
  const { data } = await supabase
    .from("referral_settings")
    .select("reward_cents, friend_percent_off")
    .eq("id", "default")
    .single();

  return data ?? { reward_cents: 2000, friend_percent_off: 20 };
}

The page itself is a plain Server Component. RLS already scopes the rows to the signed-in user, and the explicit referrer_id filter keeps referrals where this user was the friend out of the totals.

// app/(app)/referrals/page.tsx
import { redirect } from "next/navigation";
import { createServerClient } from "@/lib/supabase/server";
import { ensureReferralCode } from "@/lib/referral/ensure-code";
import { getReferralTerms } from "@/lib/referral/terms";
import { ShareLink } from "@/components/referrals/share-link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";

export default async function ReferralsPage() {
  const supabase = await createServerClient();
  const { data: claims } = await supabase.auth.getClaims();
  const userId = claims?.claims.sub;
  if (!userId) redirect("/login");

  const [code, terms] = await Promise.all([
    ensureReferralCode(userId),
    getReferralTerms(),
  ]);

  const { data: rows } = await supabase
    .from("referrals")
    .select("id, status, reward_cents, created_at")
    .eq("referrer_id", userId)
    .order("created_at", { ascending: false });

  const referrals = rows ?? [];
  const pending = referrals.filter((r) => r.status === "pending").length;
  const earned = referrals.reduce((sum, r) => sum + (r.reward_cents ?? 0), 0);

  const stats = [
    { label: "Signed up", value: String(referrals.length) },
    { label: "Not yet paid", value: String(pending) },
    { label: "Credit earned", value: `$${(earned / 100).toFixed(2)}` },
  ];

  return (
    <main className="mx-auto max-w-2xl space-y-6 py-12">
      <div>
        <h1 className="text-2xl font-bold">Refer a friend</h1>
        <p className="text-muted-foreground mt-1 text-sm">
          They get {terms.friend_percent_off}% off their first month. You get $
          {(terms.reward_cents / 100).toFixed(0)} in account credit once they pay.
        </p>
      </div>

      <ShareLink url={`${process.env.NEXT_PUBLIC_SITE_URL}/r/${code}`} />

      <div className="grid gap-4 sm:grid-cols-3">
        {stats.map((stat) => (
          <Card key={stat.label}>
            <CardHeader className="pb-2">
              <CardTitle className="text-muted-foreground text-sm font-medium">
                {stat.label}
              </CardTitle>
            </CardHeader>
            <CardContent className="text-2xl font-semibold">{stat.value}</CardContent>
          </Card>
        ))}
      </div>
    </main>
  );
}

Only the copy button needs the browser, so it is the only Client Component in the tree.

// components/referrals/share-link.tsx
"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

export function ShareLink({ url }: { url: string }) {
  const [copied, setCopied] = useState(false);

  async function handleCopy() {
    await navigator.clipboard.writeText(url);
    setCopied(true);
    window.setTimeout(() => setCopied(false), 2000);
  }

  return (
    <div className="flex gap-2">
      <Input readOnly value={url} className="font-mono text-sm" />
      <Button type="button" variant="secondary" onClick={handleCopy}>
        {copied ? "Copied" : "Copy"}
      </Button>
    </div>
  );
}

Testing the Whole Loop

Forward webhooks to your dev server first, and keep that terminal visible while you click through.

stripe listen --forward-to localhost:3000/api/stripe/webhook

Do not reach for stripe trigger invoice.paid here. The fixture invoice belongs to a throwaway customer with no row in your profiles table and the wrong billing_reason, so your handler correctly does nothing and you learn nothing. Run the real path instead: open your share link in a private window, sign up as a second account, check out with test card 4242 4242 4242 4242, then confirm the referral row moved from pending to rewarded and the credit landed on the referrer.

Claude Code is useful for the boring half of this. Ask it to write the tests rather than to guess at the policy.

claude "write integration tests for lib/referral/reward.ts: a replayed invoice.paid must not
double-credit, a subscription_cycle invoice must be ignored, and a referral over the monthly
cap must stay qualified without a Stripe call"

Quality Gates Before You Ship

Money code gets all three gates, every time.

npx tsc --noEmit
npx eslint .
npm run build

The type check is the one that catches real bugs here. invoice.customer is a string, an object, or null depending on expansion, and dispute.charge has the same shape. Guessing wrong compiles fine in JavaScript and silently stops paying anyone.

Where the Code Kit Fits

Everything above is a feature Claude Code can write in an afternoon if someone holds it to a plan and then checks the output. That harness is what the $29 Code Kit is ($29 one time, no subscription): a pipeline on top of Claude Code that plans the feature before any file is touched, builds it, evaluates what came back, tests it against a real browser, and refuses to call it done until type check, lint, and build are all clean. It does not replace Claude Code and it is not a model, so you still need a paid Anthropic plan to run it. What it replaces is you noticing, three days after launch, that the webhook was crediting the same referral twice.


Posted by @speedy_devv

Continue in Workflow

  • Comércio Agêntico: Como Construir uma App Que Agentes de IA Podem Pagar
    Um guia em português simples sobre comércio agêntico em 2026: o que fazem o x402, o ACP e o Machine Payments Protocol, mais um passo a passo de fim de semana para lançar uma API paga que agentes de IA podem comprar.
  • Melhores Práticas do Claude Code
    Cinco hábitos separam os engenheiros que entregam com Claude Code: PRDs, regras modulares em CLAUDE.md, slash commands personalizados, resets com /clear e uma mentalidade de evolução do sistema.
  • Modo Auto do Claude Code
    Um segundo modelo Sonnet revê cada chamada de ferramenta do Claude Code antes de ser executada. O que o modo auto bloqueia, o que permite e as regras de permissão que cria nas tuas definições.
  • Channels, Routines, Teleport, Dispatch
    As quatro funcionalidades de Claude Code que a Anthropic lançou em março e abril de 2026 e que transformam a CLI numa camada de coordenação orientada a eventos entre telemóvel, web e desktop.
  • Claude Code 1M Context in Practice: When Bigger Isn't Better
    The 1M-token context window is GA at flat pricing, but bigger isn't always better. A decision framework, token-cost math, and when to use /compact, subagents, and dynamic workflows instead.
  • How to Build an Admin Dashboard With Claude Code
    Ship an internal admin panel with Claude Code: role-gated routes, a searchable users and orders table with pagination, impersonation-safe RLS, and metrics tiles pulled through a type-safe API.

More from Handbook

  • Fundamentos do agente
    Cinco maneiras de criar agentes especializados no Código Claude: Sub-agentes de tarefas, .claude/agents YAML, comandos de barra personalizados, personas CLAUDE.md e prompts de perspetiva.
  • Engenharia de Harness para Agentes
    O harness é cada camada ao redor do seu agente de IA, exceto o modelo em si. Aprenda os cinco pontos de controle, o paradoxo das restrições, e por que o design do harness determina o desempenho do agente mais do que o modelo.
  • Padrões de Agentes
    Orchestrator, fan-out, cadeia de validação, routing especializado, refinamento progressivo e watchdog. Seis formas de orquestração para ligar sub-agentes no Claude Code.
  • Boas Práticas para Equipas de Agentes
    Padrões testados em produção para Equipas de Agentes Claude Code. Prompts de criação ricos em contexto, tarefas bem dimensionadas, posse de ficheiros, modo delegado, e correções das versões v2.1.33-v2.1.45.

Quer o framework por trás destes projetos?

Obtenha o sistema Claude Code que usamos para planejar, construir, testar e lançar software em produção.

Veja o que construímos para empresas →
speedy_devvkoen_salo

PDF Invoices

Generate a branded PDF invoice in Next.js the moment Stripe fires invoice.paid: render with React-PDF inside an Inngest step, store it in a private Supabase bucket, email it through Resend, and serve billing history behind signed URLs.

Two-Factor Auth

Add TOTP two factor authentication to a Next.js 16 app with Supabase MFA: QR enrollment, verified factors, hashed 2FA recovery codes, AAL2 step up authentication before billing, and RLS policies that read the assurance level.

On this page

What You Are Actually Building
The Tables in Supabase
Codes That Do Not Collide or Look Rude
Capturing the Click
Attribution Happens on the Server
Guards, Ranked by What They Actually Stop
The Discount the Friend Gets
The Credit the Referrer Gets
The Referrer Dashboard
Testing the Whole Loop
Quality Gates Before You Ship
Where the Code Kit Fits

Quer o framework por trás destes projetos?

Obtenha o sistema Claude Code que usamos para planejar, construir, testar e lançar software em produção.

Veja o que construímos para empresas →