Replace your $10,000/year lead routing tool with a system you own. Capture, qualify, route, book, enrich, and recover - all serverless, under $50/month.
Lead enters email
on your website
Multi-step form
collects info
Custom logic picks
the next step
Qualified leads
book a call
Score + brief
before the call
Drop-off detected
+ auto nurture
Frontend + API routes
+ zero-config hosting
Postgres database
for permanent logs
Redis for temp state
QStash for task queue
Attio, HubSpot,
or Salesforce
Customer.io, Loops,
or ActiveCampaign
Real-time alerts +
calendar booking
Your Site (any CMS) Lead Router (Next.js on Vercel) Integrations ┌─────────────────┐ ┌──────────────────────────┐ ┌─────────────┐ │ │ │ │ │ │ │ Email form │──────>│ /api/lead │──────>│ CRM │ │ redirects to │ │ /api/qualify │ │ Slack │ │ lead router │ │ /api/booking │ │ Email tool │ │ │ │ /api/precall │ │ Calendly │ │ │ │ /api/check-progress │ │ Enrichment │ └─────────────────┘ └────────────┬─────────────┘ └─────────────┘ │ ┌────────────┴─────────────┐ │ State Layer │ │ Redis = temp lead state │ │ Postgres = permanent log │ └──────────────────────────┘
Create the project, install dependencies, configure environment variables, set up the database, and organize the file structure.
Creates a Next.js app with TypeScript, Tailwind, and the App Router.
Upstash for Redis + QStash, Supabase client, Zod for validation.
For animated transitions between form steps. Not required but makes it feel smooth.
npx create-next-app@latest lead-router --typescript --tailwind --app cd lead-router npm install @upstash/redis @upstash/qstash @supabase/supabase-js zod npm install framer-motion # optional
.env.local file and fill these in as you set up each service.NEXT_PUBLIC_ - those get exposed to the browser. All secrets stay server-side only.
-- Table 1: Every lead submission CREATE TABLE lead_submissions ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, email TEXT NOT NULL, status TEXT DEFAULT 'email_captured', form_data JSONB, tracking JSONB, source_url TEXT, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); -- Table 2: Audit trail for every pipeline step CREATE TABLE pipeline_logs ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, email TEXT, domain TEXT, route TEXT, step TEXT NOT NULL, status TEXT NOT NULL, details JSONB, duration_ms INTEGER, created_at TIMESTAMPTZ DEFAULT NOW() );
src/ ├── app/ │ ├── api/ │ │ ├── lead/route.ts ← Email capture │ │ ├── qualify/route.ts ← Form submission + routing │ │ ├── booking/route.ts ← Calendar webhook handler │ │ ├── precall/route.ts ← Enrichment + scoring pipeline │ │ └── check-progress/route.ts ← Drop-off detection │ ├── page.tsx ← Entry point │ └── layout.tsx │ ├── components/ │ ├── LeadFlow.tsx ← Main flow controller │ ├── QualificationForm.tsx ← Multi-step form │ ├── CalendlyEmbed.tsx ← Calendar embed │ └── ThankYou.tsx ← Confirmation screen │ ├── hooks/ │ └── useLeadFlow.ts ← Flow state machine │ └── lib/ ├── crm.ts ← CRM API helpers ├── redis.ts ← Lead state management ├── qstash.ts ← Task scheduling ├── slack.ts ← Notifications ├── email-tool.ts ← Marketing webhooks ├── supabase.ts ← Database logging ├── routing.ts ← Your routing logic └── validation.ts ← Zod schemas
A single-page state machine that guides leads through capture, qualification, routing, booking, and thank-you. No URL changes - just animated transitions.
Redirected from your main website. Email captured from URL param.
Email is stored in Redis and Supabase. Drop-off timer starts. Lead doesn't see any of this.
Collects name, company, revenue, needs. Use conditional fields based on previous answers.
API runs your routing logic and returns: "high-value", "needs-qualification", or "not-qualified".
High-value = show Calendly embed. Needs qualification = show pricing gate. Not qualified = nurture.
Listen for the Calendly event via window message. Fire the booking endpoint on completion.
Different messaging based on the route they took. Booking confirmation, nurture message, or pricing gate follow-up.
// Only show VC question for lower revenue tiers {revenue === "0-50k" && ( <Select name="vcBacked" label="Are you VC-backed?" options={["Yes", "No"]} /> )}
<InlineWidget url="https://calendly.com/your-team/discovery" prefill={{ email: formData.email, firstName: formData.firstName, lastName: formData.lastName, }} />
useEffect(() => { const handler = (e) => { if (e.data.event === "calendly.event_scheduled") { onBookingComplete(e.data.payload); } }; window.addEventListener("message", handler); return () => window.removeEventListener("message", handler); }, []);
Five independent serverless endpoints. Each one handles a specific stage of the lead's journey. They share nothing except the state layer.
lib/routing.ts. This is the one function you'll change most often.// lib/routing.ts function routeLead(formData, headers) { // Layer 1: Geographic filtering const country = headers.get("x-vercel-ip-country"); if (isBlockedRegion(country) && !isHighRevenue(formData.revenue)) { return { route: "geo-blocked", reason: "Region not served" }; } // Layer 2: Revenue-based routing if (isHighRevenue(formData.revenue)) { return { route: "high-value", reason: "Revenue qualifies" }; } // Layer 3: Sub-qualification (e.g., VC-backed startups) if (formData.vcBacked === "yes") { return { route: "high-value", reason: "VC-backed upgrade" }; } // Layer 4: Budget gate return { route: "needs-qualification", reason: "Needs budget confirmation" }; }
Wire up CRM records, email automation, Slack notifications, and Calendly webhooks. Each integration is decoupled - swap any tool without touching the others.
// lib/crm.ts - the pattern applies to any CRM async function upsertCompany(domain, data) { // 1. Check if company exists by domain // 2. If exists -> update with new data // 3. If not -> create new record } async function upsertPerson(email, data) { // Same pattern - email as unique key } async function createOrUpdateDeal(email, data) { // 1. Query existing deals for this email // 2. If active deal exists -> update stage/data // 3. If not -> create new deal }
function extractDomain(input) { // "https://example.com/about" -> "example.com" // "TopLead LLC" -> null (not a domain!) if (!input.includes(".") || input.includes(" ")) return null; return input.replace(/^https?:\/\//, "").split("/")[0]; }
// lib/email-tool.ts async function trackEvent(eventName, data) { await fetch(process.env.EMAIL_TOOL_WEBHOOK_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ event: eventName, ...data }), }); }
Email captures +
form submissions
Meeting confirmations
with full context
Pre-call briefs
+ lead scores
Pipeline errors
(catch issues fast)
// Rich Slack message with Block Kit const blocks = [ { type: "header", text: { type: "plain_text", text: "New Meeting Booked" } }, { type: "section", fields: [ { type: "mrkdwn", text: `*Name:* ${name}` }, { type: "mrkdwn", text: `*Company:* ${company}` }, { type: "mrkdwn", text: `*Revenue:* ${revenue}` }, ]}, { type: "divider" }, { type: "context", elements: [ { type: "mrkdwn", text: `Booked with ${hostName} - ${meetingDate}` }, ]}, ];
Create a new webhook subscription.
This fires every time someone books a meeting.
URL: https://your-app.vercel.app/api/booking
Catches all team members' bookings, not just yours.
Two systems that make your pipeline smart: drop-off detection that recovers lost leads, and a pre-call agent that scores and briefs your team before every meeting.
// Schedule the check when capturing email: await qstash.publishJSON({ url: `${APP_URL}/api/check-progress`, body: { email, expectedStep: "email" }, delay: 20 * 60, // 20 minutes in seconds }); // In /api/check-progress: const state = await redis.get(`lead:${email}`); if (!state || state.step === expectedStep) { // Lead is stuck - handle the drop-off if (expectedStep === "email") await handleEmailDropOff(email); if (expectedStep === "form") await handleFormDropOff(email); } // Deduplication lock (QStash retries on failure): const lock = await redis.set(`lock:check:${email}:${step}`, "1", { nx: true, ex: 300 }); if (!lock) return; // Already processed
Call your enrichment API with the domain. Get industry, headcount, revenue, funding, tech stack.
Score across dimensions that matter to your ICP: sector fit (0-10), company size (0-5), revenue band (0-15). Add hard gates for automatic disqualification.
Score, company info, focus areas for the call, meeting time and host. Posted to #sales-intelligence.
Write the score and full brief directly into the CRM record so the closer has it in one place.
// Scoring model - customize to YOUR ICP function scoreCompany(enrichment) { let score = { total: 0, max: 30, breakdown: {}, flags: [] }; score.breakdown.sectorFit = scoreSector(enrichment.industry); // 0-10 score.breakdown.companySize = scoreSize(enrichment.employeeCount); // 0-5 score.breakdown.revenueBand = scoreRevenue(enrichment.revenue); // 0-15 if (isBlockedSector(enrichment.industry)) { score.flags.push("BLOCKED_SECTOR"); } score.total = Object.values(score.breakdown).reduce((a, b) => a + b, 0); return score; }
Pre-Call Brief: Acme Corp ━━━━━━━━━━━━━━━━━━━━━━━━ Score: 24/30 Company: Acme Corp (B2B SaaS) Employees: ~120 Revenue: $5M-10M ARR Funding: Series B ($25M) Focus Areas for Call: - Confirm current outbound motion - Validate budget for retainer - Ask about decision timeline Meeting: Tomorrow 2:00 PM with Sarah
Push to Vercel, set up a custom domain, connect your website, and wire up full pipeline monitoring.
Create a repo, push your project.
Import the repo in Vercel Dashboard. Every push to main auto-deploys. Every PR gets a preview.
Vercel Dashboard -> Project -> Settings -> Environment Variables. Set for Production environment.
Vercel Dashboard -> Domains -> Add (e.g., booking.yourdomain.com). Add the CNAME to your DNS. SSL provisions in under 5 minutes.
Add export const maxDuration = 30; at the top of /api/booking and /api/precall route files.
Webflow, WordPress, anything. Just a simple email input.
Pass the email and UTM params so you can track source.
Whitelist your website's domain in every API route. Don't use * in production.
// Redirect URL format: https://booking.yourdomain.com?email={email}&utm_source={source}&utm_medium={medium}&utm_campaign={campaign} // CORS whitelist in your API routes: const ALLOWED_ORIGINS = [ "https://yourdomain.com", "https://www.yourdomain.com", "https://booking.yourdomain.com", ];
// Log function - call at every step async function plog(email, step, status, details) { await supabase.from("pipeline_logs").insert({ email, step, status, details, created_at: new Date().toISOString(), }); console.log(`[${step}] ${status}`, email); } // Debug any lead in one query: SELECT step, status, details, created_at FROM pipeline_logs WHERE email = 'lead@example.com' ORDER BY created_at ASC;
Calendly webhooks fire at the same time as your frontend's booking POST. Use Redis locks for deduplication.
Make every endpoint idempotent. Calling it twice with the same data should produce the same result. Use locks.
Users type "Acme Inc" in the website field. Validate it looks like a domain. Fall back to extracting from email.
Different domains for website and lead router? Whitelist your origin in every API route. Don't use * in production.
Serverless functions cold start in 1-3s. Fine for API endpoints, but can break QStash signature verification. Set timeouts.
Don't create CRM records on email capture alone. Wait for form submission or the 20-minute drop-off at minimum.
Don't test endpoints in isolation. Run email -> form -> route -> book -> enrich end to end. Bugs hide at the boundaries.
Skip email webhooks for test addresses. Skip analytics for internal emails. Keep your data clean from day one.