Build an affiliate tracking system in Lovable where referral links append a ?ref= parameter, clicks are deduplicated by IP hash to prevent inflation, a 30-day cookie window attributes conversions, and an Edge Function calculates commissions atomically — with a real-time affiliate dashboard showing clicks, conversions, and earned commissions.
What you're building
Affiliate tracking has two hard problems: attribution accuracy and fraud prevention. This build addresses both.
Click deduplication works by hashing the visitor's IP address plus User-Agent string. The hash is stored alongside the click record. Before inserting a new click, the Edge Function checks if a click with the same ip_hash and affiliate_id exists within the last 24 hours. If it does, the click is acknowledged but not counted. This prevents automated refresh attacks from inflating metrics without blocking legitimate repeat visitors who come back days later.
The 30-day attribution window uses a two-layer approach. When a visitor lands via a ?ref= link, the referral code is stored in both a Supabase click record and the visitor's localStorage. When a conversion event fires (checkout complete), the conversion Edge Function reads the stored ref code from the request body, looks up the most recent click from that affiliate within the last 30 days, and creates a commission record.
Commission records are created in 'pending' status. An admin can review and approve them, changing status to 'approved', which makes them eligible for payout. Paid commissions move to 'paid' status with a payout reference ID.
Final result
A complete affiliate tracking system with fraud-resistant click attribution, commission management, and a dashboard that gives affiliates full visibility into their performance.
Tech stack
Prerequisites
- Lovable Pro account for Edge Function generation
- Supabase project with SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in Cloud tab → Secrets
- A working checkout or conversion event in your app that you can trigger the commission calculation from
- Decision on commission rate structure (flat fee vs percentage, per product vs site-wide)
- Optional: Stripe account for handling payouts via Stripe Connect
Build steps
Create the affiliate tracking schema
Ask Lovable to set up all the tables for affiliates, clicks, conversions, and commissions. The schema is designed to answer every performance question with simple indexed queries.
1Create an affiliate tracking schema in Supabase.23Tables:4- affiliates: id, user_id (references auth.users, unique), code (text unique, generated 8-char alphanumeric), name, email, website_url, commission_rate (decimal, e.g. 0.10 for 10%), status ('pending' | 'active' | 'suspended'), payout_method (text), payout_details (jsonb, encrypted), created_at5- affiliate_clicks: id, affiliate_id (references affiliates), ip_hash (text), user_agent_hash (text), landing_url (text), referrer_url (text), visitor_id (text, uuid stored in visitor's localStorage), created_at6- affiliate_conversions: id, affiliate_id (references affiliates), click_id (references affiliate_clicks, nullable), order_id (text unique), order_amount (decimal), commission_amount (decimal), status ('pending' | 'approved' | 'paid'), payout_id (text), created_at7- affiliate_payouts: id, affiliate_id (references affiliates), total_amount (decimal), commission_ids (uuid array), status ('processing' | 'completed' | 'failed'), payout_reference (text), processed_at, created_at89RLS:10- affiliates: users can SELECT and UPDATE their own row11- affiliate_clicks and affiliate_conversions: affiliates SELECT their own rows (affiliate_id = sub-select of their affiliate row)12- affiliate_payouts: affiliates SELECT their own13- All INSERT via service role (Edge Functions only)1415Add indexes: affiliate_clicks(affiliate_id, created_at DESC), affiliate_clicks(ip_hash, affiliate_id, created_at), affiliate_conversions(affiliate_id, status), affiliate_conversions(order_id).Pro tip: Hash the visitor's IP and User-Agent server-side in the Edge Function, never client-side. Receiving the hash from the client would allow fraud — the attacker could just change the hash. The Edge Function reads the real IP from the CF-Connecting-IP header and hashes it before storing.
Expected result: All tables are created with correct RLS. Indexes are in place. The affiliates table has a unique code column. TypeScript types are generated.
Build the click tracking Edge Function
Every visit via a ?ref= link calls this Edge Function. It deduplicates by IP hash and stores the click for later attribution. This must be fast — it runs on page load.
1// supabase/functions/track-click/index.ts2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'45const corsHeaders = {6 'Access-Control-Allow-Origin': '*',7 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',8 'Content-Type': 'application/json',9}1011async function hashString(input: string): Promise<string> {12 const data = new TextEncoder().encode(input)13 const hash = await crypto.subtle.digest('SHA-256', data)14 return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 16)15}1617serve(async (req: Request) => {18 if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })1920 try {21 const supabase = createClient(22 Deno.env.get('SUPABASE_URL') ?? '',23 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''24 )2526 const { ref_code, landing_url, referrer_url, visitor_id } = await req.json()2728 if (!ref_code) return new Response(JSON.stringify({ tracked: false }), { headers: corsHeaders })2930 const { data: affiliate } = await supabase31 .from('affiliates')32 .select('id')33 .eq('code', ref_code)34 .eq('status', 'active')35 .single()3637 if (!affiliate) return new Response(JSON.stringify({ tracked: false }), { headers: corsHeaders })3839 const ip = req.headers.get('CF-Connecting-IP') ?? req.headers.get('X-Forwarded-For') ?? 'unknown'40 const ua = req.headers.get('User-Agent') ?? ''41 const ip_hash = await hashString(ip + affiliate.id)42 const user_agent_hash = await hashString(ua)4344 // Dedup: check for existing click from same IP for this affiliate in last 24h45 const since = new Date(Date.now() - 86_400_000).toISOString()46 const { count } = await supabase47 .from('affiliate_clicks')48 .select('*', { count: 'exact', head: true })49 .eq('affiliate_id', affiliate.id)50 .eq('ip_hash', ip_hash)51 .gte('created_at', since)5253 if ((count ?? 0) > 0) {54 return new Response(JSON.stringify({ tracked: false, reason: 'duplicate' }), { headers: corsHeaders })55 }5657 const { data: click } = await supabase58 .from('affiliate_clicks')59 .insert({ affiliate_id: affiliate.id, ip_hash, user_agent_hash, landing_url, referrer_url, visitor_id })60 .select('id')61 .single()6263 return new Response(JSON.stringify({ tracked: true, click_id: click?.id }), { headers: corsHeaders })64 } catch (err) {65 return new Response(JSON.stringify({ error: 'Internal error' }), { status: 500, headers: corsHeaders })66 }67})Pro tip: Include the affiliate_id in the IP hash (not just the raw IP). This means the same visitor can legitimately click links from two different affiliates within 24 hours — both clicks count, because the hashes differ.
Expected result: Calling the Edge Function with a valid ref_code creates a click record. Calling again from the same IP within 24 hours returns { tracked: false, reason: 'duplicate' }. The click record includes the correct affiliate_id.
Build the conversion and commission Edge Function
This fires when a purchase is completed. It looks up the attribution from the last 30 days, calculates commission, and creates a pending commission record.
1Create a Supabase Edge Function at supabase/functions/track-conversion/index.ts.23The function accepts POST with body: { order_id: string, order_amount: number, visitor_id: string }.45Logic:61. Check if a conversion for this order_id already exists in affiliate_conversions. If so, return { duplicate: true } to prevent double commission on retries.72. Look up the most recent affiliate_clicks row WHERE visitor_id = p_visitor_id AND created_at > now() - interval '30 days', ordered by created_at DESC, LIMIT 1.83. If no click found, return { attributed: false } — organic sale, no commission.94. Fetch the affiliate's commission_rate from the affiliates table.105. Calculate commission_amount = order_amount * commission_rate, rounded to 2 decimal places.116. INSERT into affiliate_conversions: affiliate_id, click_id, order_id, order_amount, commission_amount, status='pending'.127. Return { attributed: true, affiliate_id, commission_amount }.1314This function is called from your existing checkout completion webhook or server action. It uses the visitor_id that the client stores in localStorage and sends at checkout.Expected result: Completing a purchase with a visitor_id that has a click within 30 days creates a pending commission record. Duplicate order_ids are rejected. Purchases with no click history return attributed: false.
Build the affiliate dashboard
Affiliates need a clear view of their performance: their referral link, click stats, conversion rate, and earnings. Ask Lovable to build the dashboard page.
1Build an affiliate dashboard at src/pages/AffiliateDashboard.tsx.23Requirements:4- Header Card: affiliate's unique referral link displayed with a Copy button. Show affiliate status Badge.5- Four stat Cards: Total Clicks (30 days), Conversions (30 days), Conversion Rate (conversions/clicks as percentage), Pending Earnings (sum of commission_amount WHERE status='pending')6- Recharts LineChart below stats: two lines, clicks and conversions per day for the last 30 days, with dual Y-axes. X-axis = date, formatted as 'Jan 15'.7- Conversions DataTable: columns Date, Order Amount (currency), Commission (currency), Status Badge (pending=yellow, approved=green, paid=blue). Paginated, 20 rows.8- A shareable link section: show the referral URL and allow the affiliate to add a custom UTM campaign parameter to it.910All data fetches are scoped to the currently authenticated affiliate's rows via RLS. Use Promise.all for parallel fetches of stats.Expected result: The dashboard shows real-time affiliate performance data. The referral link copy button works. The chart shows 30 days of data. The conversions table shows all commissions with correct status badges.
Build the admin payout management page
Admins need to review pending commissions, approve them, and process payouts. Ask Lovable to build the admin area.
1Build an admin page at src/pages/AffiliateAdmin.tsx. Protect with role='admin' check.23Two Tabs: 'Pending Approvals' and 'Affiliates'.45Pending Approvals tab:6- DataTable of all affiliate_conversions WHERE status='pending'7- Columns: Affiliate Name, Order ID, Order Amount, Commission Amount, Date, Approve Button, Reject Button8- Bulk select checkboxes. 'Approve Selected' Button updates status='approved' for all checked rows.9- Filter by affiliate Select and date range.1011Affiliates tab:12- DataTable of all affiliates with columns: Name, Email, Code, Commission Rate, Status Badge, Total Earned (sum approved+paid commissions), Pending Balance (sum pending commissions), Actions13- 'Process Payout' Button per affiliate: opens a Dialog showing all approved commissions as line items, total payout amount, and a payout reference Input. Submitting inserts into affiliate_payouts and updates all included commission_ids status to 'paid'.14- 'Edit Commission Rate' inline — clicking the rate opens an editable Input that saves on blur.15- Add Affiliate Button opens a Sheet with registration form.Expected result: Admins can approve pending commissions individually or in bulk. The payout dialog shows correct totals. Processing a payout updates all selected commission statuses to paid and creates an affiliate_payouts record.
Complete code
1import { useEffect } from 'react'2import { supabase } from '@/integrations/supabase/client'34const VISITOR_ID_KEY = 'aff_visitor_id'5const REF_CODE_KEY = 'aff_ref_code'6const REF_EXPIRY_KEY = 'aff_ref_expiry'7const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 100089function getOrCreateVisitorId(): string {10 let id = localStorage.getItem(VISITOR_ID_KEY)11 if (!id) {12 id = crypto.randomUUID()13 localStorage.setItem(VISITOR_ID_KEY, id)14 }15 return id16}1718export function useAffiliateTracking() {19 useEffect(() => {20 const params = new URLSearchParams(window.location.search)21 const refCode = params.get('ref')2223 if (refCode) {24 // Store attribution with 30-day expiry25 localStorage.setItem(REF_CODE_KEY, refCode)26 localStorage.setItem(REF_EXPIRY_KEY, String(Date.now() + THIRTY_DAYS_MS))27 }2829 const storedRef = localStorage.getItem(REF_CODE_KEY)30 const expiry = localStorage.getItem(REF_EXPIRY_KEY)31 const isExpired = expiry && Date.now() > parseInt(expiry)3233 if (isExpired) {34 localStorage.removeItem(REF_CODE_KEY)35 localStorage.removeItem(REF_EXPIRY_KEY)36 return37 }3839 const activeRef = refCode ?? storedRef40 if (!activeRef) return4142 const visitorId = getOrCreateVisitorId()4344 // Fire-and-forget click tracking45 supabase.functions.invoke('track-click', {46 body: {47 ref_code: activeRef,48 landing_url: window.location.href,49 referrer_url: document.referrer,50 visitor_id: visitorId,51 },52 }).catch(() => { /* non-critical, do not block the page */ })53 }, [])5455 const getAttributionData = () => {56 const refCode = localStorage.getItem(REF_CODE_KEY)57 const expiry = localStorage.getItem(REF_EXPIRY_KEY)58 if (!refCode || (expiry && Date.now() > parseInt(expiry))) return null59 return {60 ref_code: refCode,61 visitor_id: getOrCreateVisitorId(),62 }63 }6465 return { getAttributionData }66}Customization ideas
Multi-tier affiliate commissions
Add a referred_by column to affiliates so affiliates can recruit sub-affiliates. When a sub-affiliate earns a commission, calculate a smaller percentage (e.g. 5%) for the parent affiliate and create two commission records. This is called a two-tier affiliate program and is common in SaaS businesses.
Coupon code tracking
Add a coupon_code column to affiliates. Allow affiliates to share a discount code instead of (or in addition to) a link. In the checkout flow, when a user applies a coupon code, look up the affiliated affiliate and create a commission record. This handles influencers who share codes in video content where link tracking is impossible.
Affiliate portal with resources
Add a resources section to the affiliate dashboard with approved marketing materials: banner images, email templates, social media copy, and brand guidelines. Store files in Supabase Storage with a public bucket. Affiliates download what they need without contacting you.
Automated payout via Stripe Connect
Add a Stripe Connect onboarding flow to the affiliate registration. Store the connected account ID. Replace the manual payout process with an Edge Function that calls Stripe's Transfer API to send funds directly to the affiliate's bank account when an admin clicks 'Process Payout'.
Conversion funnel analytics
Add a funnel view to the admin page: total unique visitors via affiliate links, percent that started checkout, percent that completed checkout. This data comes from combining click records with conversion records. Show per-affiliate funnel metrics to identify top performers.
Common pitfalls
Pitfall: Tracking the raw IP address instead of a hash
How to avoid: Hash the IP address (salted with the affiliate_id) using SHA-256 before storing. The hash is sufficient for deduplication without storing the raw personal data. This also means if the hash database is ever breached, IPs cannot be reconstructed.
Pitfall: Attributing conversions using only a query parameter, not a cookie window
How to avoid: Store the ref code and expiry in localStorage when the visitor first lands (the useAffiliateTracking hook does this). Read it from localStorage at checkout. This persists the attribution for the full 30-day window across tabs and page refreshes.
Pitfall: Creating commission records directly from client-side code
How to avoid: All commission creation must happen in a server-side Edge Function called from your checkout completion webhook or server action. The Edge Function verifies the order_id against your orders table to confirm the purchase is real before creating the commission.
Pitfall: Not handling the case where a visitor clicks multiple affiliate links
How to avoid: The track-conversion function uses 'last click wins': it takes the most recent click within 30 days. Document this attribution model clearly in your affiliate terms of service so affiliates understand the rules.
Best practices
- Use 'last click wins' attribution as the default and document it in your affiliate agreement. It is the simplest model to explain and implement, and it is the industry standard.
- Never store raw IP addresses. Hash them server-side in the Edge Function. Include the affiliate_id as a salt so the same visitor's hash differs per affiliate.
- Process commissions through a pending → approved → paid workflow so you have time to reverse fraudulent conversions before they are paid out.
- Add a minimum payout threshold (e.g. $50) to reduce payment processing overhead. Store this in the affiliates table as min_payout_amount.
- Log every call to track-conversion regardless of attribution outcome. Log attributed=false cases too. This audit trail is invaluable for dispute resolution when affiliates claim they should have received credit for a sale.
- Rate-limit the track-click Edge Function to prevent bots from generating artificial clicks. Return 429 if the same IP sends more than 10 click tracking requests per minute.
- Send a welcome email to new affiliates with their referral link, commission rate, and a link to the affiliate portal. Use an Edge Function triggered by a Postgres trigger on the affiliates table INSERT to call Resend.
AI prompts to try
Copy these prompts to build this project faster.
I'm building an affiliate tracking system where conversions are attributed to the most recent affiliate click within 30 days using a visitor_id stored in localStorage. I have affiliate_clicks and affiliate_conversions tables in Supabase. Help me write the Deno Edge Function track-conversion that: 1) checks for duplicate order_id, 2) finds the most recent click for the visitor_id within 30 days, 3) calculates commission as order_amount times affiliate commission_rate, and 4) inserts a pending commission record. Include CORS headers and proper error handling.
Add a performance leaderboard to the affiliate admin page. Show the top 10 affiliates by conversion count for the current month, formatted as a ranked DataTable with columns: Rank, Affiliate Name, Clicks, Conversions, Conversion Rate, Revenue Generated, Commissions Earned. Add a month selector to change the reporting period. Highlight the top 3 with gold/silver/bronze badge icons next to their rank number.
In Supabase, write a SQL query for an affiliate performance report. It should return per affiliate: affiliate name, code, total_clicks (last 30 days), total_conversions (last 30 days), conversion_rate (conversions/clicks), total_revenue (sum order_amount for approved+paid conversions last 30 days), total_commission (sum commission_amount for approved+paid last 30 days). Join affiliates with affiliate_clicks and affiliate_conversions. Include affiliates with zero clicks (LEFT JOINs). Order by total_revenue DESC.
Frequently asked questions
What happens if a user clears their localStorage before converting?
The attribution is lost. This is a known limitation of localStorage-based tracking. For higher attribution accuracy, you can supplement with a server-side cookie (set via your domain's own server) that persists the ref code. However, this requires a backend that can set HTTP cookies, which goes beyond Lovable's Edge Functions alone. For most use cases, localStorage with a 30-day window has acceptable attribution rates.
Can an affiliate inflate their own click count by visiting their link repeatedly?
No. The 24-hour IP hash deduplication in the track-click Edge Function prevents this. Repeated visits from the same IP hash within 24 hours return tracked: false and no new click record is inserted. The hash includes the affiliate_id, so visiting your own link and other pages do not interfere with each other.
How do I integrate the conversion tracking with Stripe payments?
In your Stripe checkout.session.completed webhook handler, read the client_reference_id (set this to the visitor_id when creating the Checkout Session) and the amount_total. Call the track-conversion Edge Function with these values. The visitor_id links the purchase to the affiliate click record, completing the attribution chain.
What commission rate structure should I use?
Percentage commissions (e.g. 20% of sale value) are common for SaaS products and scale naturally with order value. Flat-fee commissions (e.g. $25 per signup) work better for free-trial-to-paid funnels where order amounts vary. You can support both by adding a commission_type ('percentage' | 'flat') column to the affiliates table and adjusting the calculation in the Edge Function accordingly.
How do I prevent the same user from being counted as both a click and a conversion from two different affiliate links?
The track-conversion function uses 'last click wins' — it attributes the sale to the most recent click from the visitor_id within 30 days. If the user clicked affiliate A three weeks ago and affiliate B yesterday, affiliate B gets the credit. Only one commission record is created per order_id (enforced by the unique constraint).
Can I track affiliate performance for non-purchase conversions like signups or free trials?
Yes. The track-conversion Edge Function does not care what type of conversion it is — it just records an order_id, amount, and visitor_id. For a free trial signup, pass order_amount=0 and use a flat commission. The affiliate still gets credit for the signup. You can add a conversion_type column to distinguish purchase vs signup conversions in your reports.
How do I handle chargebacks or refunds affecting affiliate commissions?
Add a webhook for charge.dispute.created or refund Stripe events. When received, find the affiliate_conversion with the matching order_id and update its status to 'reversed'. If the commission was already paid, insert a negative adjustment record in a new affiliate_adjustments table that reduces the next payout. Always communicate refund policies clearly in your affiliate agreement.
Is there help available to build a more complex affiliate program?
RapidDev builds Lovable apps with multi-tier commissions, Stripe Connect payouts, and custom attribution models. Reach out if your affiliate program requires features beyond this guide.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation