# How to Build Affiliate tracking app with V0

- Tool: v0
- Difficulty: Intermediate
- Compatibility: V0 Premium or higher
- Last updated: April 2026

## TL;DR

Build an affiliate tracking app with V0 using Next.js, Supabase, and Stripe. You'll get click-through attribution, real-time conversion tracking, tiered commission calculations, and an affiliate dashboard with automated payout management — all in about 1-2 hours without any local setup.

## Before you start

- A V0 account (Premium plan recommended for multi-feature builds)
- A Supabase project (free tier works — connect via V0's Connect panel)
- A Stripe account with webhook access (test mode works for development)
- An existing product or checkout flow where you want to track affiliate referrals

## Step-by-step guide

### 1. Set up the database schema for affiliate tracking

Start a new V0 project and connect Supabase via the Connect panel. Then prompt V0 to create the tables for affiliates, clicks, conversions, and payouts. The conversion table includes a generated column that auto-calculates commission based on the affiliate's rate.

```
// Paste this prompt into V0's AI chat:
// Build an affiliate tracking system with Supabase. Create these tables:
// 1. affiliates: id (uuid PK), user_id (uuid FK to auth.users), code (text unique), commission_rate (numeric default 0.10), status (text default 'active'), created_at (timestamptz)
// 2. clicks: id (uuid PK), affiliate_id (uuid FK), ip_address (inet), user_agent (text), referrer_url (text), landing_page (text), created_at (timestamptz)
// 3. conversions: id (uuid PK), click_id (uuid FK), affiliate_id (uuid FK), order_id (text unique), amount (numeric), commission (numeric), status (text default 'pending'), created_at (timestamptz)
// 4. payouts: id (uuid PK), affiliate_id (uuid FK), amount (numeric), period_start (date), period_end (date), status (text default 'pending'), paid_at (timestamptz)
// Add RLS policies so affiliates can only see their own data.
// Generate the SQL migration.
```

> Pro tip: Use the Connect panel to add Supabase in two clicks — it auto-provisions NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in the Vars tab.

**Expected result:** Supabase is connected and all four tables are created with RLS policies. The affiliates table has a unique code column for referral links.

### 2. Build the click tracking API route with cookie attribution

Create an API route that records affiliate clicks and sets a first-party cookie with the affiliate code. When someone clicks a referral link like /api/track?ref=ABC123, it logs the click and redirects to the landing page with the cookie set for 30-day attribution.

```
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url)
  const refCode = searchParams.get('ref')
  const landingPage = searchParams.get('url') || '/'

  if (!refCode) {
    return NextResponse.redirect(new URL(landingPage, req.url))
  }

  const { data: affiliate } = await supabase
    .from('affiliates')
    .select('id')
    .eq('code', refCode)
    .eq('status', 'active')
    .single()

  if (affiliate) {
    await supabase.from('clicks').insert({
      affiliate_id: affiliate.id,
      ip_address: req.headers.get('x-forwarded-for') || 'unknown',
      user_agent: req.headers.get('user-agent') || '',
      referrer_url: req.headers.get('referer') || '',
      landing_page: landingPage,
    })
  }

  const response = NextResponse.redirect(new URL(landingPage, req.url))
  response.cookies.set('affiliate_ref', refCode, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 30 * 24 * 60 * 60,
    path: '/',
  })

  return response
}
```

**Expected result:** Visiting /api/track?ref=ABC123&url=/pricing logs a click in Supabase, sets a 30-day cookie, and redirects to /pricing.

### 3. Create the Stripe webhook for conversion attribution

Build the webhook handler that listens for Stripe checkout.session.completed events. It reads the affiliate cookie from the checkout metadata, attributes the sale to the correct affiliate, and calculates the commission automatically.

```
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { createClient } from '@supabase/supabase-js'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const body = await req.text()
  const sig = req.headers.get('stripe-signature')!

  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 refCode = session.metadata?.affiliate_ref

    if (refCode) {
      const { data: affiliate } = await supabase
        .from('affiliates')
        .select('id, commission_rate')
        .eq('code', refCode)
        .single()

      if (affiliate && session.amount_total) {
        const amount = session.amount_total / 100
        await supabase.from('conversions').upsert({
          affiliate_id: affiliate.id,
          order_id: session.id,
          amount,
          commission: amount * affiliate.commission_rate,
          status: 'confirmed',
        }, { onConflict: 'order_id' })
      }
    }
  }

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

> Pro tip: Always use request.text() instead of request.json() for Stripe webhooks — Stripe signature verification requires the raw request body, and parsing it as JSON first corrupts the signature check.

**Expected result:** When a Stripe checkout completes with affiliate metadata, a conversion row is created in Supabase with the correct commission amount.

### 4. Build the affiliate dashboard with earnings and link management

Create the affiliate-facing dashboard where they can view their referral link, see click and conversion stats, and track earnings over time. Use Server Components for data fetching and Recharts for the trends chart.

```
// Paste this prompt into V0's AI chat:
// Build an affiliate dashboard at app/dashboard/page.tsx.
// Requirements:
// - Show the affiliate's referral link in a shadcn/ui Input with a copy-to-clipboard Button
// - Display summary Cards: Total Clicks, Total Conversions, Conversion Rate, Total Earnings, Pending Payout
// - Add an AreaChart (Recharts) showing clicks and conversions per day for the last 30 days
// - Show a Table of recent conversions with columns: Date, Order ID, Amount, Commission, Status Badge
// - Add Tabs to switch between Overview, Conversions, and Payouts views
// - Payouts tab shows a Table of payout history with status Badge (pending/paid)
// - Use Server Components for data fetching from Supabase
// - The referral link format should be: {origin}/api/track?ref={code}
```

**Expected result:** The dashboard shows the affiliate's referral link, summary stats in Cards, a 30-day trend chart, and tables for conversions and payouts.

### 5. Create the admin view for managing affiliates and approving payouts

Build an admin page where you can view all affiliates, their performance metrics, and approve pending payouts. This uses Server Actions for payout approval with proper authorization checks.

```
'use server'

import { createClient } from '@supabase/supabase-js'
import { auth } from '@clerk/nextjs/server'
import { revalidatePath } from 'next/cache'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function approvePayout(payoutId: string) {
  const { userId } = await auth()
  if (!userId) throw new Error('Unauthorized')

  const { error } = await supabase
    .from('payouts')
    .update({
      status: 'paid',
      paid_at: new Date().toISOString(),
    })
    .eq('id', payoutId)
    .eq('status', 'pending')

  if (error) throw new Error(error.message)
  revalidatePath('/admin/affiliates')
}

export async function generatePayout(affiliateId: string, periodStart: string, periodEnd: string) {
  const { data: conversions } = await supabase
    .from('conversions')
    .select('commission')
    .eq('affiliate_id', affiliateId)
    .eq('status', 'confirmed')
    .gte('created_at', periodStart)
    .lte('created_at', periodEnd)

  const totalCommission = conversions?.reduce((sum, c) => sum + c.commission, 0) ?? 0

  if (totalCommission > 0) {
    await supabase.from('payouts').insert({
      affiliate_id: affiliateId,
      amount: totalCommission,
      period_start: periodStart,
      period_end: periodEnd,
      status: 'pending',
    })
  }

  revalidatePath('/admin/affiliates')
}
```

**Expected result:** Admins can view all affiliates and their metrics. Payouts can be generated for a date range and approved with a single click.

## Complete code example

File: `app/api/track/route.ts`

```typescript
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url)
  const refCode = searchParams.get('ref')
  const landingPage = searchParams.get('url') || '/'

  if (!refCode) {
    return NextResponse.redirect(new URL(landingPage, req.url))
  }

  const { data: affiliate } = await supabase
    .from('affiliates')
    .select('id')
    .eq('code', refCode)
    .eq('status', 'active')
    .single()

  if (affiliate) {
    await supabase.from('clicks').insert({
      affiliate_id: affiliate.id,
      ip_address: req.headers.get('x-forwarded-for') || 'unknown',
      user_agent: req.headers.get('user-agent') || '',
      referrer_url: req.headers.get('referer') || '',
      landing_page: landingPage,
    })
  }

  const response = NextResponse.redirect(new URL(landingPage, req.url))
  response.cookies.set('affiliate_ref', refCode, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 30 * 24 * 60 * 60,
    path: '/',
  })

  return response
}
```

## Common mistakes

- **Using third-party cookies for affiliate attribution** — Modern browsers block third-party cookies by default. Affiliate codes stored in third-party cookies will not persist, causing lost attribution and angry affiliates. Fix: Use first-party cookies set from your own domain's API route. The /api/track route sets a first-party cookie that persists for 30 days and is readable during checkout.
- **Not preventing duplicate conversion records** — Stripe may send the same webhook event multiple times during retries. Without deduplication, a single purchase could create multiple conversion records and double-count commissions. Fix: Add a unique constraint on the order_id column in the conversions table and use ON CONFLICT DO NOTHING or upsert when inserting conversions from the webhook.
- **Exposing STRIPE_WEBHOOK_SECRET with NEXT_PUBLIC_ prefix** — The webhook secret is used to verify that incoming webhook requests actually come from Stripe. Exposing it in the browser bundle defeats this security check entirely. Fix: Store STRIPE_WEBHOOK_SECRET in V0's Vars tab without any prefix. It should only be accessible in the server-side webhook API route.

## Best practices

- Use first-party cookies for attribution since third-party cookies are blocked by most modern browsers
- Always verify Stripe webhook signatures using request.text() for the raw body — never request.json()
- Store all secret keys (STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, SUPABASE_SERVICE_ROLE_KEY) in V0's Vars tab without NEXT_PUBLIC_ prefix
- Use Supabase RLS policies so affiliates can only view their own clicks, conversions, and payouts
- Add unique constraints on order_id to prevent duplicate conversion records from webhook retries
- Use Server Components for the dashboard pages to keep database queries server-side and avoid exposing business logic
- Use Design Mode (Option+D) to adjust dashboard card layouts, chart colors, and table styling without spending credits
- Set the affiliate cookie with httpOnly and secure flags to prevent client-side JavaScript from reading or tampering with it

## Frequently asked questions

### How does cookie-based affiliate attribution work?

When someone clicks an affiliate link, the /api/track route sets a first-party cookie with the affiliate code. This cookie persists for 30 days. When the user completes a purchase, the checkout flow reads this cookie and passes it as metadata to Stripe, which then includes it in the webhook event for conversion attribution.

### Can I track affiliate conversions without Stripe?

Yes. Instead of using a Stripe webhook, you can call the conversion API route directly from your order confirmation logic. The key is to read the affiliate cookie at the point of conversion and attribute it to the correct affiliate in Supabase.

### What V0 plan do I need for an affiliate tracking app?

V0 Premium is recommended because the affiliate system requires multiple pages and API routes. The free tier gives limited credits which may not cover the full build. Premium provides enough credits for the tracking routes, dashboards, and admin pages.

### How do I prevent click fraud from affiliates?

Implement IP-based deduplication by adding a unique constraint on (affiliate_id, ip_address, date) in the clicks table. You can also add rate limiting per affiliate code and flag accounts with abnormally high click-to-conversion ratios for manual review.

### How do I deploy the affiliate tracking app?

Click Share then Publish to Production in V0 for instant Vercel deployment. After publishing, copy your production URL and register the Stripe webhook endpoint at https://yourdomain.vercel.app/api/webhooks/stripe in the Stripe Dashboard.

### Can I add PayPal as an alternative to Stripe?

Yes. Add a PayPal webhook handler at a separate API route that listens for PayPal IPN (Instant Payment Notification) events and follows the same conversion attribution logic — read the affiliate code from the order metadata and insert a conversion row.

### Can RapidDev help build a custom affiliate tracking system?

Yes. RapidDev has built 600+ apps including complex affiliate and referral systems with multi-tier commissions, fraud detection, and automated payouts. Book a free consultation to discuss your specific tracking requirements.

---

Source: https://www.rapidevelopers.com/how-to-build-v0/affiliate-tracking-app
© RapidDev — https://www.rapidevelopers.com/how-to-build-v0/affiliate-tracking-app
