Building a Lead Generation Machine with Next.js and AI
A complete technical guide to building an automated lead generation system with Next.js — covering AI-powered form optimization, email nurture sequences, A/B testing, conversion tracking, and the exact code we use to turn anonymous visitors into qualified leads.
Why Most Tech Company Websites Waste 97% of Their Traffic
The average B2B website converts 2-3% of visitors into leads. That means for every 1,000 people who find your content through search, read your blog post, and think "these people know what they're doing" — 970 of them leave without you ever knowing they existed.
At TechSaaS, we decided that was unacceptable. We built an automated lead generation system directly into our Next.js website that captures, qualifies, and nurtures leads without any manual intervention. The result: our conversion rate went from 2.1% to 8.7% — a 4x improvement.
This post covers exactly how we built it — the architecture, the code, and the AI components that make it work.
Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ Next.js Website │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Smart Forms │ │ Exit Intent │ │ Content Gating │ │
│ │ (AI-scored) │ │ Capture │ │ (Progressive) │ │
│ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │
│ │ │ │ │
│ └────────────┬────┘────────────────────┘ │
│ ▼ │
│ ┌──────────────┐ │
│ │ /api/leads │ ← Server Action / API Route │
│ └──────┬───────┘ │
└─────────────────────┼────────────────────────────────────────┘
│
┌────────────┼────────────────┐
▼ ▼ ▼
┌────────────┐ ┌─────────┐ ┌──────────────┐
│ PostgreSQL │ │ Listmonk│ │ Umami │
│ (leads DB) │ │ (email) │ │ (analytics) │
└────────────┘ └─────────┘ └──────────────┘
Part 1: Smart Lead Capture Forms
The Multi-Step Form Component
Single-step forms with 6+ fields have a 15% completion rate. Multi-step forms with progressive disclosure hit 45-60%. We break capture into micro-commitments:
// components/LeadCaptureForm.tsx
'use client';
import { useState, useTransition } from 'react';
import { submitLead } from '@/app/actions/leads';
interface FormData {
email: string;
name: string;
company: string;
role: string;
interest: string;
}
export function LeadCaptureForm({ source }: { source: string }) {
const [step, setStep] = useState(1);
const [data, setData] = useState<Partial<FormData>>({});
const [isPending, startTransition] = useTransition();
const [submitted, setSubmitted] = useState(false);
const handleStepOne = (email: string) => {
setData(prev => ({ ...prev, email }));
// Submit partial lead immediately — even if they abandon,
// we have the email for a soft follow-up
startTransition(async () => {
await submitLead({ email, source, step: 1 });
});
setStep(2);
};
const handleStepTwo = (name: string, company: string) => {
setData(prev => ({ ...prev, name, company }));
startTransition(async () => {
await submitLead({ ...data, name, company, source, step: 2 });
});
setStep(3);
};
const handleStepThree = (role: string, interest: string) => {
const finalData = { ...data, role, interest };
startTransition(async () => {
await submitLead({ ...finalData, source, step: 3, complete: true });
});
setSubmitted(true);
};
if (submitted) {
return (
<div className="p-6 bg-green-50 dark:bg-green-950 rounded-lg">
<h3 className="font-semibold text-green-800 dark:text-green-200">
You're in!
</h3>
<p className="text-green-700 dark:text-green-300 mt-1">
Check your inbox for your download link.
</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Progress indicator */}
<div className="flex gap-2">
{[1, 2, 3].map(s => (
<div
key={s}
className={`h-1 flex-1 rounded ${
s <= step ? 'bg-indigo-500' : 'bg-gray-200 dark:bg-gray-700'
}`}
/>
))}
</div>
{step === 1 && <StepEmail />}
{step === 2 && <StepDetails />}
{step === 3 && <StepInterest />}
</div>
);
}
function StepEmail({ onSubmit }: { onSubmit: (email: string) => void }) {
const [email, setEmail] = useState('');
return (
<form
=> {
e.preventDefault();
onSubmit(email);
}}
>
<label className="block text-sm font-medium mb-1">
Work email
</label>
<div className="flex gap-2">
<input
type="email"
required
value={email}
=> setEmail(e.target.value)}
placeholder="[email protected]"
className="flex-1 rounded-md border px-3 py-2"
/>
<button
type="submit"
className="px-4 py-2 bg-indigo-600 text-white rounded-md
hover:bg-indigo-700 transition-colors"
>
Get access
</button>
</div>
</form>
);
}
The critical design decision: we submit partial data after every step. If someone enters their email but abandons at step 2, we still have a lead. That partial capture alone increased our effective lead count by 35%.
The Server Action
// app/actions/leads.ts
'use server';
import { db } from '@/lib/db';
import { leads, leadEvents } from '@/lib/schema';
import { addToListmonk } from '@/lib/listmonk';
import { scoreLead } from '@/lib/ai-scoring';
export async function submitLead(data: {
email: string;
name?: string;
company?: string;
role?: string;
interest?: string;
source: string;
step: number;
complete?: boolean;
}) {
// Upsert — update if email already exists (progressive enrichment)
const existing = await db
.select()
.from(leads)
.where(eq(leads.email, data.email))
.limit(1);
let leadId: string;
if (existing.length > 0) {
// Update with new fields (don't overwrite existing data with undefined)
const updates: Record<string, unknown> = {};
if (data.name) updates.name = data.name;
if (data.company) updates.company = data.company;
if (data.role) updates.role = data.role;
if (data.interest) updates.interest = data.interest;
updates.lastSeenAt = new Date();
updates.formStep = data.step;
await db.update(leads).set(updates).where(eq(leads.id, existing[0].id));
leadId = existing[0].id;
} else {
// Create new lead
const [newLead] = await db
.insert(leads)
.values({
email: data.email,
name: data.name,
company: data.company,
role: data.role,
interest: data.interest,
source: data.source,
formStep: data.step,
createdAt: new Date(),
lastSeenAt: new Date(),
})
.returning();
leadId = newLead.id;
}
// Log the event for funnel analysis
await db.insert(leadEvents).values({
leadId,
event: `form_step_${data.step}`,
source: data.source,
metadata: { complete: data.complete || false },
});
// On complete submission: score + add to email sequence
if (data.complete) {
const score = await scoreLead(data);
await db
.update(leads)
.set({ score, status: score >= 70 ? 'qualified' : 'nurture' })
.where(eq(leads.id, leadId));
// Add to Listmonk email nurture sequence
await addToListmonk({
email: data.email,
name: data.name || '',
lists: score >= 70
? ['qualified-leads', 'weekly-insights']
: ['nurture-sequence', 'weekly-insights'],
attributes: {
company: data.company,
role: data.role,
interest: data.interest,
score,
source: data.source,
},
});
}
}
Part 2: AI-Powered Lead Scoring
Get more insights on Tutorials
Join 2,000+ engineers who get our weekly deep-dives. No spam, unsubscribe anytime.
Not all leads are equal. Someone who reads 5 blog posts, downloads a whitepaper, and lists their role as "CTO" is far more valuable than a student browsing casually.
The Scoring Model
// lib/ai-scoring.ts
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic();
interface LeadData {
email: string;
name?: string;
company?: string;
role?: string;
interest?: string;
source: string;
pageViews?: number;
timeOnSite?: number;
}
export async function scoreLead(data: LeadData): Promise<number> {
// Rule-based pre-scoring (fast, no API call)
let baseScore = 0;
// Email domain signals
const domain = data.email.split('@')[1];
if (['gmail.com', 'yahoo.com', 'hotmail.com'].includes(domain)) {
baseScore += 10; // Personal email — likely individual
} else {
baseScore += 30; // Company email — more likely a real lead
}
// Role signals
const seniorRoles = ['cto', 'vp', 'director', 'head', 'chief', 'founder', 'ceo'];
const midRoles = ['manager', 'lead', 'senior', 'architect', 'principal'];
const role = (data.role || '').toLowerCase();
if (seniorRoles.some(r => role.includes(r))) baseScore += 30;
else if (midRoles.some(r => role.includes(r))) baseScore += 20;
else baseScore += 10;
// Interest alignment
const highIntentInterests = [
'cloud migration', 'devops consulting', 'infrastructure',
'security audit', 'platform engineering'
];
const interest = (data.interest || '').toLowerCase();
if (highIntentInterests.some(i => interest.includes(i))) baseScore += 20;
else baseScore += 10;
// Engagement signals
if (data.pageViews && data.pageViews > 3) baseScore += 10;
if (data.timeOnSite && data.timeOnSite > 120) baseScore += 10;
// For high base scores, use AI to enrich the assessment
if (baseScore >= 50 && data.company) {
try {
const aiScore = await aiEnrichScore(data);
return Math.min(100, Math.round((baseScore + aiScore) / 2));
} catch {
return Math.min(100, baseScore);
}
}
return Math.min(100, baseScore);
}
async function aiEnrichScore(data: LeadData): Promise<number> {
const response = await client.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 100,
system: `You are a B2B lead scoring assistant for a cloud infrastructure
and DevOps consulting company. Score leads from 0-100 based on
fit and intent. Return ONLY a JSON object: {"score": N, "reason": "..."}`,
messages: [{
role: 'user',
content: `Score this lead:
Company: ${data.company}
Role: ${data.role}
Interest: ${data.interest}
Source: ${data.source}
Email domain: ${data.email.split('@')[1]}`
}]
});
const text = response.content[0].type === 'text' ? response.content[0].text : '';
const parsed = JSON.parse(text);
return parsed.score;
}
The scoring is hybrid: rule-based for speed and cost, AI-enriched only for high-potential leads. This keeps API costs under $5/month while scoring thousands of leads.
Part 3: A/B Testing Framework
We A/B test everything — headlines, CTA text, form placement, and lead magnet offers. Here's our lightweight framework built directly into Next.js:
// lib/ab-testing.ts
import { cookies } from 'next/headers';
import { db } from './db';
import { abExperiments, abEvents } from './schema';
interface Variant {
id: string;
weight: number; // 0-100, must sum to 100 across variants
}
export async function getVariant(
experimentId: string,
variants: Variant[]
): Promise<string> {
const cookieStore = await cookies();
const cookieKey = `ab_${experimentId}`;
const existing = cookieStore.get(cookieKey);
if (existing) return existing.value;
// Weighted random assignment
const rand = Math.random() * 100;
let cumulative = 0;
let assigned = variants[0].id;
for (const v of variants) {
cumulative += v.weight;
if (rand <= cumulative) {
assigned = v.id;
break;
}
}
// Set cookie for consistent experience (30-day expiry)
cookieStore.set(cookieKey, assigned, {
maxAge: 30 * 24 * 60 * 60,
httpOnly: true,
sameSite: 'lax',
});
return assigned;
}
export async function trackAbEvent(
experimentId: string,
variantId: string,
event: 'view' | 'click' | 'submit' | 'convert'
) {
await db.insert(abEvents).values({
experimentId,
variantId,
event,
timestamp: new Date(),
});
}
Using It in Components
// app/blog/[slug]/page.tsx
import { getVariant, trackAbEvent } from '@/lib/ab-testing';
import { LeadCaptureForm } from '@/components/LeadCaptureForm';
import { InlineCapture } from '@/components/InlineCapture';
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
// Test: inline form vs. bottom CTA vs. exit-intent popup
const captureVariant = await getVariant('blog-capture-2026-03', [
{ id: 'inline-mid', weight: 34 },
{ id: 'bottom-cta', weight: 33 },
{ id: 'exit-intent', weight: 33 },
]);
// Test: CTA copy
const ctaVariant = await getVariant('cta-copy-2026-03', [
{ id: 'get-guide', weight: 50 },
{ id: 'start-free', weight: 50 },
]);
const ctaText = ctaVariant === 'get-guide'
? 'Get the Free Infrastructure Guide'
: 'Start Your Free Assessment';
return (
<article>
<h1>{post.title}</h1>
{/* First half of content */}
<div dangerouslySetInnerHTML={{ __html: post.contentFirstHalf }} />
{/* Inline capture — only for 'inline-mid' variant */}
{captureVariant === 'inline-mid' && (
<InlineCapture
headline="Want the complete checklist?"
cta={ctaText}
source={`blog:${params.slug}:inline`}
experimentId="blog-capture-2026-03"
variantId="inline-mid"
/>
)}
{/* Second half of content */}
<div dangerouslySetInnerHTML={{ __html: post.contentSecondHalf }} />
{/* Bottom CTA — only for 'bottom-cta' variant */}
{captureVariant === 'bottom-cta' && (
<div className="mt-12 p-8 bg-gray-50 dark:bg-gray-900 rounded-xl">
<h3 className="text-xl font-bold">Enjoyed this article?</h3>
<p className="mt-2 text-gray-600">Get weekly insights like this.</p>
<LeadCaptureForm source={`blog:${params.slug}:bottom`} />
</div>
)}
{/* Exit intent — client component handles mouse tracking */}
{captureVariant === 'exit-intent' && (
<ExitIntentCapture
source={`blog:${params.slug}:exit`}
cta={ctaText}
/>
)}
</article>
);
}
The Exit-Intent Detector
// components/ExitIntentCapture.tsx
'use client';
import { useState, useEffect, useCallback } from 'react';
import { LeadCaptureForm } from './LeadCaptureForm';
export function ExitIntentCapture({
source,
cta,
}: {
source: string;
cta: string;
}) {
const [showModal, setShowModal] = useState(false);
const [dismissed, setDismissed] = useState(false);
const handleMouseLeave = useCallback(
(e: MouseEvent) => {
// Only trigger when cursor moves to top of viewport (leaving)
if (e.clientY <= 5 && !dismissed && !showModal) {
setShowModal(true);
}
},
[dismissed, showModal]
);
useEffect(() => {
// Don't show if already captured (check localStorage)
if (localStorage.getItem('lead_captured')) return;
// Delay activation — don't trigger in first 15 seconds
const timer = setTimeout(() => {
document.addEventListener('mouseleave', handleMouseLeave);
}, 15000);
return () => {
clearTimeout(timer);
document.removeEventListener('mouseleave', handleMouseLeave);
};
}, [handleMouseLeave]);
if (!showModal) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center
bg-black/50 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-800 rounded-xl p-8 max-w-md
mx-4 shadow-2xl">
<button
=> { setShowModal(false); setDismissed(true); }}
className="float-right text-gray-400 hover:text-gray-600"
>
×
</button>
<h3 className="text-xl font-bold">Before you go...</h3>
<p className="mt-2 text-gray-600 dark:text-gray-300">
{cta} — delivered straight to your inbox.
</p>
<div className="mt-4">
<LeadCaptureForm source={source} />
</div>
</div>
</div>
);
}
The exit-intent detector waits 15 seconds before arming (avoids annoying immediate popups), checks localStorage to avoid re-showing to captured leads, and only triggers when the cursor physically moves toward the browser chrome (top of viewport).
Part 4: Email Nurture Automation
Once we have a lead, Listmonk handles the email sequences:
// lib/listmonk.ts
const LISTMONK_URL = process.env.LISTMONK_URL!; // Self-hosted
const LISTMONK_USER = process.env.LISTMONK_USER!;
const LISTMONK_PASS = process.env.LISTMONK_PASS!;
interface ListmonkSubscriber {
email: string;
name: string;
lists: string[]; // List names, resolved to IDs
attributes: Record<string, unknown>;
}
export async function addToListmonk(sub: ListmonkSubscriber) {
// Resolve list names to IDs
const listIds = await resolveListIds(sub.lists);
const response = await fetch(`${LISTMONK_URL}/api/subscribers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`${LISTMONK_USER}:${LISTMONK_PASS}`)}`,
},
body: JSON.stringify({
email: sub.email,
name: sub.name,
status: 'enabled',
lists: listIds,
attribs: sub.attributes,
preconfirm_subscriptions: true,
}),
});
if (!response.ok) {
const error = await response.text();
// 409 = already exists — update instead
if (response.status === 409) {
await updateListmonkSubscriber(sub);
} else {
console.error('Listmonk error:', error);
}
}
}
The Nurture Sequence
Our email sequence is triggered by list membership and timed with Listmonk's campaign scheduler:
Day 0: Welcome + requested resource download link
Day 3: "Here's what most teams get wrong about [interest topic]"
Day 7: Case study relevant to their industry
Day 14: "Quick question" — personalized based on role/interest
Day 21: Soft CTA — "We help companies like [company] with [interest]"
Day 30: Final value email + clear opt-out
Each email is personalized using Listmonk's template variables:
<!-- Listmonk campaign template -->
<h2>Hey {{ .Subscriber.Name | default "there" }},</h2>
{{ if eq .Subscriber.Attribs.interest "cloud migration" }}
<p>Since you're interested in cloud migration, I thought you'd find this useful:
our team recently helped a {{ .Subscriber.Attribs.role | default "team" }}
at a company similar to {{ .Subscriber.Attribs.company | default "yours" }}
migrate 40+ services to Kubernetes in 12 weeks.</p>
{{ else if eq .Subscriber.Attribs.interest "devops consulting" }}
<p>Here's the DevOps maturity assessment framework we use with our
consulting clients...</p>
{{ else }}
<p>Here's what's been working for the teams we work with this quarter...</p>
{{ end }}
Part 5: Conversion Tracking with Umami
We use self-hosted Umami for privacy-friendly analytics. Custom events track every step of the funnel:
// lib/tracking.ts
'use client';
export function trackEvent(
eventName: string,
data?: Record<string, string | number>
) {
// Umami tracking
if (typeof window !== 'undefined' && (window as any).umami) {
(window as any).umami.track(eventName, data);
}
}
// Usage in components
trackEvent('form_view', { source: 'blog-sidebar', variant: 'a' });
trackEvent('form_submit', { source: 'blog-sidebar', step: 1 });
trackEvent('lead_qualified', { score: 85, source: 'blog-sidebar' });
The Funnel Dashboard
We query Umami's API to build a real-time funnel dashboard:
// app/api/dashboard/funnel/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
const now = new Date();
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const metrics = await fetch(
`${process.env.UMAMI_URL}/api/websites/${process.env.UMAMI_SITE_ID}/events?` +
`startAt=${thirtyDaysAgo.getTime()}&endAt=${now.getTime()}`,
{
headers: { Authorization: `Bearer ${process.env.UMAMI_TOKEN}` },
}
).then(r => r.json());
const funnel = {
visitors: metrics.filter((e: any) => e.eventName === 'page_view').length,
formViews: metrics.filter((e: any) => e.eventName === 'form_view').length,
step1: metrics.filter((e: any) => e.eventName === 'form_step_1').length,
step2: metrics.filter((e: any) => e.eventName === 'form_step_2').length,
step3: metrics.filter((e: any) => e.eventName === 'form_step_3').length,
qualified: metrics.filter((e: any) => e.eventName === 'lead_qualified').length,
};
return NextResponse.json({
funnel,
conversionRates: {
visitorToForm: ((funnel.formViews / funnel.visitors) * 100).toFixed(1),
formToStep1: ((funnel.step1 / funnel.formViews) * 100).toFixed(1),
step1ToComplete: ((funnel.step3 / funnel.step1) * 100).toFixed(1),
overallConversion: ((funnel.step3 / funnel.visitors) * 100).toFixed(1),
},
});
}
Free Resource
Free Cloud Architecture Checklist
A 47-point checklist covering security, scalability, cost optimization, and disaster recovery for production cloud environments.
Part 6: Dynamic Content Personalization
Returning visitors see different CTAs based on their previous interactions:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const response = NextResponse.next();
// Track visit count
const visitCount = parseInt(
request.cookies.get('visit_count')?.value || '0'
);
response.cookies.set('visit_count', String(visitCount + 1), {
maxAge: 90 * 24 * 60 * 60, // 90 days
});
// Track pages viewed for interest detection
const path = request.nextUrl.pathname;
if (path.startsWith('/blog/')) {
const viewedTopics = JSON.parse(
request.cookies.get('viewed_topics')?.value || '[]'
);
const slug = path.replace('/blog/', '');
if (!viewedTopics.includes(slug)) {
viewedTopics.push(slug);
response.cookies.set('viewed_topics', JSON.stringify(viewedTopics.slice(-20)), {
maxAge: 90 * 24 * 60 * 60,
});
}
}
return response;
}
Then in server components:
// Personalized CTA based on visit history
async function PersonalizedCTA() {
const cookieStore = await cookies();
const visitCount = parseInt(cookieStore.get('visit_count')?.value || '0');
const isReturning = visitCount > 2;
const isCaptured = cookieStore.get('lead_captured')?.value === 'true';
if (isCaptured) {
return <UpgradeCTA />; // Already a lead — show upgrade offer
}
if (isReturning) {
return <ReturningVisitorCTA />; // "Welcome back" + stronger offer
}
return <FirstVisitCTA />; // Standard lead capture
}
Results and Metrics
After 90 days of running this system:
| Metric | Before | After | Change |
|---|---|---|---|
| Overall conversion rate | 2.1% | 8.7% | +314% |
| Blog-to-lead rate | 1.4% | 6.2% | +343% |
| Partial captures (email only) | 0 | 1,200/month | New |
| Email open rate (nurture) | N/A | 38% | — |
| Qualified lead rate | 12% of leads | 31% of leads | +158% |
| Cost per lead | $45 | $11 | -76% |
The biggest win was partial captures. The multi-step form submitting after step 1 means we capture leads even when they abandon. 35% of our leads are partial captures that we'd have lost entirely with a traditional single-step form.
The Bottom Line
Lead generation isn't about more traffic — it's about converting the traffic you already have. A 4x improvement in conversion rate has the same impact as 4x more traffic, but it costs almost nothing to implement.
The technical stack is straightforward: Next.js server actions for form handling, a lightweight A/B testing framework using cookies, AI-powered scoring to prioritize follow-up, and Listmonk for self-hosted email automation. No expensive SaaS subscriptions, no vendor lock-in, and complete control over your data.
The code above is production code from our actual website. Adapt it, improve it, and start capturing the 97% of visitors you're currently losing.
Related Service
Cloud Solutions
Let our experts help you build the right technology strategy for your business.
Need help with tutorials?
TechSaaS provides expert consulting and managed services for cloud infrastructure, DevOps, and AI/ML operations.
We Will Build You a Demo Site — For Free
Like it? Pay us. Do not like it? Walk away, zero complaints. You will spend way less than hiring developers or any agency.
No spam. No contracts. Just a free demo.