Build a lead generation tool in Lovable with a Kanban pipeline, landing page builder, lead scoring engine, and embeddable form snippet. Uses Supabase to store leads, landing pages, and activity history. A database function calculates lead scores automatically. A Supabase Edge Function serves embeddable form HTML for external websites.
What you're building
A lead generation and management platform that captures leads from custom landing pages, scores them automatically, and tracks their journey through a sales pipeline.
Leads enter the system through embeddable forms hosted on any external website. Each form submission creates a lead record and logs the first activity. A Supabase database function calculates a lead score (0-100) based on activities: form submission (+10), email opened (+5), link clicked (+10), call completed (+20), demo booked (+30). The score updates automatically after each activity insert.
The Kanban board displays leads grouped by pipeline stage with score badges color-coded green/yellow/red. Drag-and-drop moves leads between stages and logs a stage_change activity. The DataTable gives a sortable, filterable view for bulk management. A funnel Chart shows the conversion rate between each pipeline stage, making bottlenecks immediately visible.
Final result
A fully functional lead generation system where leads flow from embeddable landing page forms through a scored Kanban pipeline, with activity tracking, funnel analytics, and an Edge Function serving embeddable form HTML for external sites.
Tech stack
Prerequisites
- Supabase project with Auth enabled
- Lovable Pro account for Edge Function deployment
- SUPABASE_URL and SUPABASE_ANON_KEY added to Lovable Cloud tab → Secrets
- A domain where you plan to embed the form (for testing the embed snippet)
Build steps
Create the Supabase schema with lead scoring function
Set up leads, landing_pages, and lead_activities tables. The lead scoring function recalculates a lead's score every time a new activity is inserted, using weighted point values per activity type.
1-- Run in Supabase SQL Editor2create table landing_pages (3 id uuid primary key default gen_random_uuid(),4 title text not null,5 slug text unique not null,6 headline text,7 subheadline text,8 fields jsonb default '[{"id":"name","label":"Full Name","type":"text","required":true},{"id":"email","label":"Email","type":"email","required":true}]',9 owner_id uuid references auth.users(id),10 is_published boolean default false,11 created_at timestamptz default now()12);1314create table leads (15 id uuid primary key default gen_random_uuid(),16 landing_page_id uuid references landing_pages(id),17 full_name text not null,18 email text not null,19 phone text,20 company text,21 source text default 'form',22 stage text default 'new' check (stage in ('new','contacted','qualified','proposal','negotiation','closed_won','closed_lost')),23 score int default 10,24 owner_id uuid references auth.users(id),25 created_at timestamptz default now(),26 updated_at timestamptz default now()27);2829create table lead_activities (30 id uuid primary key default gen_random_uuid(),31 lead_id uuid references leads(id) on delete cascade,32 activity_type text not null check (activity_type in ('form_submitted','email_opened','link_clicked','call_completed','demo_booked','stage_changed','note_added')),33 metadata jsonb default '{}',34 created_by uuid references auth.users(id),35 created_at timestamptz default now()36);3738create index idx_leads_stage_score on leads(stage, score desc);39create index idx_activities_lead_created on lead_activities(lead_id, created_at);4041-- Lead scoring function42create or replace function calculate_lead_score(p_lead_id uuid)43returns int language sql as $$44 select least(100,45 coalesce(sum(46 case activity_type47 when 'form_submitted' then 1048 when 'email_opened' then 549 when 'link_clicked' then 1050 when 'call_completed' then 2051 when 'demo_booked' then 3052 when 'stage_changed' then 553 else 254 end55 ), 0)56 )::int57 from lead_activities58 where lead_id = p_lead_id59$$;6061create or replace function trigger_update_lead_score()62returns trigger language plpgsql as $$63begin64 update leads set score = calculate_lead_score(NEW.lead_id),65 updated_at = now()66 where id = NEW.lead_id;67 return NEW;68end;69$$;7071create trigger lead_score_trigger72after insert on lead_activities73for each row execute function trigger_update_lead_score();7475-- RLS76alter table landing_pages enable row level security;77alter table leads enable row level security;78alter table lead_activities enable row level security;7980create policy "Owners manage pages" on landing_pages for all using (owner_id = auth.uid());81create policy "Public read published pages" on landing_pages for select using (is_published = true);82create policy "Owners manage leads" on leads for all using (owner_id = auth.uid());83create policy "Public insert leads" on leads for insert with check (true);84create policy "Owners manage activities" on lead_activities for all using (85 lead_id in (select id from leads where owner_id = auth.uid())86);87create policy "System insert activities" on lead_activities for insert with check (true);Pro tip: The least(100, ...) cap in calculate_lead_score prevents scores from exceeding 100 no matter how many activities a lead accumulates. This keeps the score scale predictable for visual color coding.
Expected result: Three tables created. The calculate_lead_score function and trigger appear in Supabase under Database → Functions. Every new lead_activities row now automatically updates the parent lead's score.
Build the lead Kanban pipeline
Prompt Lovable to create the main pipeline view with stage columns and lead cards showing name, company, score badge, and source. Dragging a card between columns logs a stage_changed activity.
1Build a /pipeline page showing leads as a Kanban board.23Stage columns (in order): New, Contacted, Qualified, Proposal, Negotiation, Closed Won, Closed Lost4Fetch leads from Supabase grouped by stage.56Render each stage as a vertical column:7- Column header: stage name + lead count Badge8- Total potential value (count) in smaller text below910Each lead card (shadcn Card) shows:11- full_name (bold)12- company (small text, gray)13- source Badge (form=blue, import=gray, referral=green, manual=orange)14- score Badge: score >= 70 = green, 40-69 = yellow, < 40 = red15- Updated_at relative time1617Drag-and-drop between columns:181. Update leads.stage in Supabase192. Insert a lead_activities row: activity_type='stage_changed', metadata: {from_stage, to_stage}203. Use optimistic updates — update UI immediately, sync in background2122Clicking a card navigates to /leads/:id (detail page).23Add a floating 'Add Lead' Button that opens a Sheet form.Expected result: The Kanban shows leads grouped by stage. Dragging a card to a new column updates the stage in Supabase and inserts a stage_changed activity. The score badge color updates reflect the lead's current score.
Build the lead detail page with activity timeline
The lead detail page shows full contact info, score, stage selector, and a chronological activity timeline. Logging a new activity from this page triggers the scoring function.
1import { useState, useEffect } from 'react'2import { useParams } from 'react-router-dom'3import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'4import { Badge } from '@/components/ui/badge'5import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'6import { Button } from '@/components/ui/button'7import { Progress } from '@/components/ui/progress'8import { supabase } from '@/lib/supabase'9import { toast } from 'sonner'10import { formatDistanceToNow } from 'date-fns'1112const STAGES = ['new','contacted','qualified','proposal','negotiation','closed_won','closed_lost']13const ACTIVITY_POINTS: Record<string, number> = {14 form_submitted: 10, email_opened: 5, link_clicked: 10,15 call_completed: 20, demo_booked: 30, stage_changed: 5, note_added: 216}1718const ACTIVITY_ICONS: Record<string, string> = {19 form_submitted: '📋', email_opened: '✉️', link_clicked: '🔗',20 call_completed: '📞', demo_booked: '🗓️', stage_changed: '🔄', note_added: '📝'21}2223interface Lead {24 id: string; full_name: string; email: string; phone: string | null25 company: string | null; source: string; stage: string; score: number26 created_at: string27}2829interface Activity {30 id: string; activity_type: string; metadata: Record<string, string>31 created_at: string; created_by: string | null32}3334export function LeadDetailPage() {35 const { id } = useParams<{ id: string }>()36 const [lead, setLead] = useState<Lead | null>(null)37 const [activities, setActivities] = useState<Activity[]>([])3839 useEffect(() => {40 if (!id) return41 Promise.all([42 supabase.from('leads').select('*').eq('id', id).single(),43 supabase.from('lead_activities').select('*').eq('lead_id', id).order('created_at', { ascending: false })44 ]).then(([{ data: l }, { data: a }]) => {45 if (l) setLead(l)46 if (a) setActivities(a)47 })48 }, [id])4950 const logActivity = async (type: string) => {51 if (!id) return52 const { error } = await supabase.from('lead_activities').insert({ lead_id: id, activity_type: type })53 if (error) { toast.error('Failed to log activity'); return }54 toast.success(`Logged: ${type.replace('_', ' ')}`)55 const { data } = await supabase.from('leads').select('score').eq('id', id).single()56 if (data) setLead(prev => prev ? { ...prev, score: data.score } : null)57 }5859 const updateStage = async (stage: string) => {60 if (!id || !lead) return61 await supabase.from('leads').update({ stage }).eq('id', id)62 await supabase.from('lead_activities').insert({63 lead_id: id, activity_type: 'stage_changed',64 metadata: { from_stage: lead.stage, to_stage: stage }65 })66 setLead(prev => prev ? { ...prev, stage } : null)67 }6869 const scoreColor = (s: number) => s >= 70 ? 'text-green-600' : s >= 40 ? 'text-yellow-600' : 'text-red-600'7071 if (!lead) return <div className="p-8">Loading...</div>7273 return (74 <div className="max-w-4xl mx-auto p-8 space-y-6">75 <div className="flex justify-between items-start">76 <div>77 <h1 className="text-2xl font-bold">{lead.full_name}</h1>78 <p className="text-muted-foreground">{lead.email} {lead.phone && `· ${lead.phone}`}</p>79 {lead.company && <p className="text-sm">{lead.company}</p>}80 </div>81 <div className="text-right">82 <p className={`text-3xl font-bold ${scoreColor(lead.score)}`}>{lead.score}</p>83 <p className="text-xs text-muted-foreground">Lead Score</p>84 <Progress value={lead.score} className="mt-1 w-24" />85 </div>86 </div>87 <div className="flex gap-3 items-center">88 <Select value={lead.stage} onValueChange={updateStage}>89 <SelectTrigger className="w-48"><SelectValue /></SelectTrigger>90 <SelectContent>{STAGES.map(s => <SelectItem key={s} value={s}>{s.replace('_', ' ')}</SelectItem>)}</SelectContent>91 </Select>92 {['email_opened','call_completed','demo_booked','note_added'].map(t => (93 <Button key={t} variant="outline" size="sm" onClick={() => logActivity(t)}>94 {ACTIVITY_ICONS[t]} {t.replace('_', ' ')}95 </Button>96 ))}97 </div>98 <div className="space-y-3">99 <h2 className="font-semibold">Activity Timeline</h2>100 {activities.map(a => (101 <div key={a.id} className="flex gap-3 items-start">102 <span className="text-lg">{ACTIVITY_ICONS[a.activity_type]}</span>103 <div>104 <p className="text-sm font-medium">{a.activity_type.replace(/_/g, ' ')}105 <span className="text-green-600 ml-2">+{ACTIVITY_POINTS[a.activity_type] ?? 2} pts</span>106 </p>107 <p className="text-xs text-muted-foreground">{formatDistanceToNow(new Date(a.created_at))} ago</p>108 </div>109 </div>110 ))}111 </div>112 </div>113 )114}Pro tip: After logging an activity, re-fetch the lead's score from Supabase rather than calculating it in JavaScript. The database trigger already computed the correct value — fetching it keeps the UI in sync with the database state.
Expected result: The lead detail page shows full contact info, a score progress bar, activity log buttons, and a full activity timeline. Clicking 'call_completed' adds the activity and updates the score badge immediately.
Build the embeddable form Edge Function
A Supabase Edge Function responds to GET /embed/:slug and returns a self-contained HTML page with a form that posts submissions back to the Supabase API. This lets external sites embed the form with a single iframe tag.
1// supabase/functions/embed-form/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 = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'content-type' }67serve(async (req) => {8 if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })910 const url = new URL(req.url)11 const slug = url.pathname.split('/').pop()12 if (!slug) return new Response('Missing slug', { status: 400 })1314 const supabase = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_ANON_KEY')!)15 const { data: page } = await supabase.from('landing_pages').select('*').eq('slug', slug).eq('is_published', true).single()1617 if (!page) return new Response('Form not found', { status: 404 })1819 const fields = (page.fields as Array<{ id: string; label: string; type: string; required: boolean }>) ?? []20 const fieldsHtml = fields.map(f => `21 <div style="margin-bottom:16px">22 <label style="display:block;font-size:14px;font-weight:500;margin-bottom:4px">${f.label}${f.required ? ' *' : ''}</label>23 <input name="${f.id}" type="${f.type}" ${f.required ? 'required' : ''}24 style="width:100%;padding:8px 12px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box" />25 </div>`).join('')2627 const html = `<!DOCTYPE html>28<html><head><meta charset="utf-8"><style>body{font-family:system-ui,sans-serif;margin:0;padding:24px;background:#fff}h1{font-size:22px;margin-bottom:8px}p{color:#666;font-size:14px;margin-bottom:24px}button{background:#2563eb;color:#fff;border:none;padding:10px 24px;border-radius:6px;cursor:pointer;font-size:14px}button:hover{background:#1d4ed8}#success{display:none;color:#16a34a;font-weight:500}</style></head>29<body>30 <h1>${page.headline ?? page.title}</h1>31 ${page.subheadline ? `<p>${page.subheadline}</p>` : ''}32 <form id="lf">${fieldsHtml}<button type="submit">Submit</button></form>33 <p id="success">Thank you! We will be in touch.</p>34 <script>35 document.getElementById('lf').onsubmit = async function(e) {36 e.preventDefault();37 const fd = new FormData(e.target);38 const data = {}; fd.forEach((v,k)=>data[k]=v);39 await fetch('${Deno.env.get('SUPABASE_URL')}/rest/v1/leads', {40 method: 'POST',41 headers: {'Content-Type':'application/json','apikey':'${Deno.env.get('SUPABASE_ANON_KEY')}','Prefer':'return=minimal'},42 body: JSON.stringify({full_name:data.name||data.full_name||'',email:data.email||'',landing_page_id:'${page.id}',source:'embed'})43 });44 e.target.style.display='none';45 document.getElementById('success').style.display='block';46 }47 </script>48</body></html>`4950 return new Response(html, { headers: { ...corsHeaders, 'Content-Type': 'text/html' } })51})Pro tip: After deploying, add a 'Get Embed Code' Button in the landing page builder that shows: <iframe src='[EDGE_FUNCTION_URL]/embed-form/[slug]' width='100%' height='400' frameborder='0'></iframe>. Include a copy-to-clipboard action.
Expected result: The Edge Function URL returns a fully styled standalone HTML form. Pasting the iframe snippet into any external website shows the form and submits leads to Supabase.
Build the funnel Chart and lead DataTable
The analytics page shows a conversion funnel chart and a leads DataTable with score sorting and stage filtering. The funnel shows how many leads are in each stage and the conversion rate between consecutive stages.
1Build an /analytics page with two sections:231. Conversion Funnel Chart:4 Fetch lead counts grouped by stage from Supabase.5 Stage order: new → contacted → qualified → proposal → negotiation → closed_won6 Render a vertical funnel using a shadcn BarChart (recharts) in horizontal mode:7 - Each bar is a stage, bar width proportional to lead count8 - Show conversion % between bars: 'contacted / new = X%'9 - Color: green for closed_won, red for closed_lost, blue for all others10 - Show closed_lost as a separate donut chart below the funnel11122. Leads DataTable at /leads:13 Fetch all leads with columns:14 - full_name (link to /leads/:id)15 - email16 - company17 - source Badge18 - stage Badge (color per stage)19 - score (sortable, with color: green >= 70, yellow >= 40, red < 40)20 - created_at (formatted)21 - Actions: DropdownMenu with 'View', 'Move to Qualified', 'Mark Closed Won', 'Mark Closed Lost'2223 Above table filters:24 - Stage Select filter25 - Source Select filter26 - Score range: Slider with min/max handles (0 to 100)27 - Search Input for name or email2829 Sort by score descending by default.Expected result: The analytics page shows a horizontal funnel chart with per-stage counts and conversion percentages. The DataTable sorts leads by score with color-coded badges and all filter combinations work correctly.
Complete code
1import { useState, useEffect, useCallback } from 'react'2import { supabase } from '@/lib/supabase'3import { toast } from 'sonner'45export const PIPELINE_STAGES = ['new', 'contacted', 'qualified', 'proposal', 'negotiation', 'closed_won', 'closed_lost'] as const6export type PipelineStage = typeof PIPELINE_STAGES[number]78export interface Lead {9 id: string10 full_name: string11 email: string12 company: string | null13 source: string14 stage: PipelineStage15 score: number16 updated_at: string17}1819export function useLeadPipeline() {20 const [leads, setLeads] = useState<Record<PipelineStage, Lead[]>>(21 Object.fromEntries(PIPELINE_STAGES.map(s => [s, []])) as Record<PipelineStage, Lead[]>22 )23 const [loading, setLoading] = useState(true)2425 const load = useCallback(async () => {26 setLoading(true)27 const { data, error } = await supabase28 .from('leads')29 .select('id, full_name, email, company, source, stage, score, updated_at')30 .order('score', { ascending: false })31 if (error) { toast.error('Failed to load leads'); setLoading(false); return }32 const grouped = Object.fromEntries(PIPELINE_STAGES.map(s => [s, []])) as Record<PipelineStage, Lead[]>33 data?.forEach(l => { if (grouped[l.stage as PipelineStage]) grouped[l.stage as PipelineStage].push(l as Lead) })34 setLeads(grouped)35 setLoading(false)36 }, [])3738 const moveLead = async (leadId: string, fromStage: PipelineStage, toStage: PipelineStage) => {39 setLeads(prev => {40 const next = { ...prev }41 const idx = next[fromStage].findIndex(l => l.id === leadId)42 if (idx === -1) return prev43 const [lead] = next[fromStage].splice(idx, 1)44 next[toStage] = [...next[toStage], { ...lead, stage: toStage }]45 return next46 })47 await supabase.from('leads').update({ stage: toStage }).eq('id', leadId)48 await supabase.from('lead_activities').insert({49 lead_id: leadId, activity_type: 'stage_changed',50 metadata: { from_stage: fromStage, to_stage: toStage }51 })52 }5354 useEffect(() => { load() }, [load])55 return { leads, loading, moveLead, reload: load }56}Customization ideas
Email sequence automation
When a lead enters the Contacted stage, automatically trigger a Supabase Edge Function that sends a sequence of emails via Resend. Log each send as an email_opened activity when the tracking pixel fires.
Lead source attribution
Pass UTM parameters (utm_source, utm_medium, utm_campaign) from the embed form URL to the leads table. Add source breakdown charts on the analytics page to show which channels generate the highest-scored leads.
Revenue tracking
Add a deal_value column to leads. Show total pipeline value per stage on the Kanban columns and a projected revenue chart on the analytics page based on win probability per stage.
Lead assignment and team routing
Add an assigned_to column to leads. When a new lead is created, auto-assign it to the team member with the fewest open leads using a round-robin Supabase function.
CSV import for bulk lead upload
Add a CSV import Dialog that accepts a spreadsheet of leads with name, email, company, and source columns. Parse client-side and bulk-insert to Supabase, then log a form_submitted activity for each.
Common pitfalls
Pitfall: Calculating lead scores in React instead of using the database trigger
How to avoid: Always use the Postgres trigger set up in Step 1. Every lead_activities insert automatically recalculates and saves the score back to leads.score.
Pitfall: Not logging stage_changed activities when moving leads on the Kanban
How to avoid: Always insert a lead_activities row with activity_type='stage_changed' whenever leads.stage is updated, as shown in the useLeadPipeline hook.
Pitfall: Exposing the Supabase service role key in the embed-form Edge Function HTML
How to avoid: Only use the anon key in the embed HTML. The insert policy on leads is intentionally permissive (with check (true)) so the anon key can insert without bypassing RLS.
Pitfall: Not scoping landing page visibility with is_published
How to avoid: The Edge Function checks .eq('is_published', true) before returning the form HTML. Un-published pages return a 404.
Best practices
- Always calculate lead scores in the database via triggers — never in JavaScript where results are not persisted
- Log every meaningful lead interaction as a lead_activities row so the timeline is complete and scores are accurate
- Use optimistic updates on the Kanban board so drag-and-drop feels instant even on slow connections
- Key embed forms by a readable slug (not UUID) so form URLs are shareable and memorable
- Cap lead scores at 100 using least(100, ...) to keep the color-coding scale predictable
- Sort the lead DataTable by score descending by default so the hottest leads are always at the top
- Enable Supabase Realtime on leads so the Kanban updates when a team member moves a lead without requiring a page refresh
- Use Lovable Plan Mode to design the scoring logic and Edge Function before generating code — both components have tricky edge cases worth planning first
AI prompts to try
Copy these prompts to build this project faster.
Write a PostgreSQL function called calculate_lead_score that sums weighted activity points from a lead_activities table (form_submitted=10, email_opened=5, link_clicked=10, call_completed=20, demo_booked=30) and caps the result at 100. Include a trigger that calls this function after every INSERT on lead_activities.
Build a lead generation tool with a /pipeline Kanban board showing leads grouped by stage with score badges, an /analytics funnel Chart showing conversion rates between stages, and a /leads DataTable sortable by score with stage and source filters.
Create a Supabase Edge Function called embed-form that accepts a GET request with a slug parameter, fetches the matching published landing page from Supabase, and returns a standalone HTML page with a form that submits leads to Supabase via the REST API. The form fields should be generated dynamically from the landing page's fields JSONB column.
Frequently asked questions
How does the lead score get calculated?
The calculate_lead_score Postgres function sums up points from the lead_activities table: form_submitted=10, email_opened=5, link_clicked=10, call_completed=20, demo_booked=30, stage_changed=5. A database trigger runs this function automatically after every INSERT on lead_activities and writes the result back to leads.score, capped at 100.
Can I customize the scoring weights?
Yes. Edit the CASE statement inside calculate_lead_score in the Supabase SQL editor and click Save. The updated weights will apply to all future activity inserts. Existing scores will not retroactively update — to recalculate all existing scores, run: UPDATE leads SET score = calculate_lead_score(id).
How does the embeddable form work on external sites?
The embed-form Edge Function serves a self-contained HTML page that posts directly to the Supabase REST API using the anon key. Any website can embed it with an iframe tag. The anon key is safe to use here because the RLS policy on leads allows inserts but not reads for unauthenticated requests.
Can I build this on the Lovable free plan?
The Kanban board, DataTable, and basic scoring work on the free plan. The embed-form Edge Function requires Lovable Pro. The free plan's 5 daily credits make iterating on a multi-feature build like this difficult — Pro is strongly recommended for faster iteration.
How do I deploy this with a custom domain for the landing pages?
Click the Publish icon in Lovable (top-right) and follow the Settings → Custom Domain instructions. For landing pages, the public /forms/:slug route works without authentication. Add your custom domain to Supabase Auth → URL Configuration → Redirect URLs so auth flows work after the domain change.
How do I prevent spam leads from the embeddable form?
Add a honeypot hidden field to the embed form HTML (a field named 'website' styled display:none). In the Edge Function's insert logic, reject submissions where the honeypot field is non-empty. For stronger protection, integrate Cloudflare Turnstile (free tier) and verify the token server-side before inserting the lead.
My Kanban has too many leads to display. How do I add pagination?
Add a limit to each stage's Supabase query (.limit(50)) and a 'Load more' Button at the bottom of each column that fetches the next page with .range(offset, offset+49). Store the offset per stage in component state. Alternatively, add a virtual scrolling library for very long columns.
Where can I get help if my scoring logic or embed form isn't working?
Use Lovable's Plan Mode to describe the exact problem — Plan Mode never modifies your code, so it is safe for debugging sessions. For Supabase function issues, test the trigger directly in the SQL editor with: INSERT INTO lead_activities (lead_id, activity_type) VALUES ('your-lead-uuid', 'call_completed'); then SELECT score FROM leads WHERE id = 'your-lead-uuid'. RapidDev can review your setup if you are still stuck.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation