BlogNext.js

Cómo construimos un sistema de leads automático con Next.js 15 y pg-boss

Un formulario de contacto que solo manda un correo está dejando dinero sobre la mesa. En este artículo mostramos cómo reemplazarlo por un sistema que captura, clasifica y notifica leads de manera automática — todo dentro de la misma aplicación Next.js.

Edward Díaz — Edwsystem30 de abril de 202512 min de lectura

El problema con los formularios tradicionales

La mayoría de sitios web de agencias y freelancers tienen el mismo flujo: alguien llena un formulario, se dispara un correo a contacto@empresa.com y eso es todo. No hay registro persistente, no hay forma de saber de qué canal vino el prospecto, no hay criterio para priorizar quién tiene más urgencia o presupuesto.

En Edwsystem recibíamos solicitudes mezcladas — proyectos de $500 USD junto a proyectos de $15.000 USD — y respondíamos en el mismo orden en que llegaban al correo. El resultado: prospectos calientes esperando horas mientras respondíamos cotizaciones sin presupuesto definido.

La solución fue construir un pipeline de leads propio: cada solicitud se guarda en base de datos, recibe un score automático de 0 a 100 según criterios objetivos, y se clasifica como HOT, WARM o COLD antes de que nosotros veamos el correo.

Arquitectura del sistema

La arquitectura es deliberadamente simple: todo vive dentro de la misma app Next.js 15 sin servicios externos extra (salvo Resend para emails). No hay Lambda, no hay colas en AWS SQS, no hay microservicios.

Formulario (cliente)
       │
       ▼
POST /api/leads          ← Route Handler de Next.js 15
       │
       ├─► Zod validation
       ├─► Prisma → PostgreSQL  (persiste el lead)
       └─► pg-boss.send(...)    (encola jobs en background)
                │
                ├─► Worker: "score-lead"
                │       └─► calcula score 0-100
                │       └─► asigna HOT / WARM / COLD
                │       └─► actualiza DB
                │
                └─► Worker: "notify-lead"
                        └─► Resend → email a ventas

pg-boss es una librería que implementa una cola de trabajos usando la misma base de datos PostgreSQL que ya tienes. Crea tablas internas, gestiona reintentos, concurrencia y expiración — sin Redis, sin infraestructura adicional. Para una aplicación de tamaño mediano es más que suficiente y elimina una dependencia de infraestructura.

El modelo Lead en Prisma

El schema refleja exactamente los datos que necesitamos para tomar decisiones: presupuesto, tipo de proyecto, si dejó teléfono (señal de seriedad), longitud del mensaje y el canal de origen.

// prisma/schema.prisma

enum LeadStatus {
  NEW
  CONTACTED
  QUALIFIED
  PROPOSAL_SENT
  CLOSED_WON
  CLOSED_LOST
}

enum LeadTemperature {
  HOT
  WARM
  COLD
}

enum LeadSource {
  WEBSITE
  INSTAGRAM
  TIKTOK
  REFERRAL
  GOOGLE
  WHATSAPP
  OTHER
}

model Lead {
  id          String           @id @default(cuid())
  createdAt   DateTime         @default(now())
  updatedAt   DateTime         @updatedAt

  // Datos del prospecto
  name        String
  email       String
  phone       String?
  company     String?
  message     String           @db.Text

  // Clasificación del proyecto
  projectType String?          // "ecommerce" | "saas" | "landing" | "api" | "otro"
  budget      String?          // "lt500" | "500-2000" | "2000-5000" | "gt5000"
  timeline    String?          // "urgent" | "1month" | "3months" | "flexible"

  // Scoring automático
  score       Int              @default(0)
  temperature LeadTemperature  @default(COLD)
  status      LeadStatus       @default(NEW)
  source      LeadSource       @default(WEBSITE)

  // Metadata
  ipAddress   String?
  userAgent   String?
  utmSource   String?
  utmMedium   String?
  utmCampaign String?

  @@index([temperature, status])
  @@index([createdAt])
}

Los campos de UTM son importantes: si alguien llega desde una campaña de Google Ads y deja un formulario con presupuesto alto, ese contexto vale oro para el equipo comercial. Lo capturamos desde el cliente y lo enviamos junto con el resto del formulario.

La API Route: validación con Zod y encolado

El Route Handler de Next.js 15 recibe el payload, lo valida con Zod (nunca confiamos en datos crudos del cliente), persiste el lead y encola dos jobs en pg-boss — uno para scoring y otro para notificación. La respuesta al cliente es inmediata: el trabajo pesado ocurre en background.

Schema de validación Zod

// lib/validations/lead.ts
import { z } from 'zod'

export const leadSchema = z.object({
  name: z.string().min(2, 'Nombre demasiado corto').max(100),
  email: z.string().email('Email inválido'),
  phone: z.string().regex(/^[+]?[0-9\s\-()]{7,20}$/).optional().or(z.literal('')),
  company: z.string().max(100).optional(),
  message: z.string().min(10, 'Cuéntanos más sobre tu proyecto').max(2000),
  projectType: z.enum(['ecommerce', 'saas', 'landing', 'api', 'otro']).optional(),
  budget: z.enum(['lt500', '500-2000', '2000-5000', 'gt5000']).optional(),
  timeline: z.enum(['urgent', '1month', '3months', 'flexible']).optional(),
  source: z.enum(['WEBSITE','INSTAGRAM','TIKTOK','REFERRAL','GOOGLE','WHATSAPP','OTHER'])
           .default('WEBSITE'),
  utmSource: z.string().max(100).optional(),
  utmMedium: z.string().max(100).optional(),
  utmCampaign: z.string().max(100).optional(),
})

export type LeadInput = z.infer<typeof leadSchema>

Route Handler

// app/api/leads/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getBoss } from '@/lib/queue/boss'
import { leadSchema } from '@/lib/validations/lead'

export async function POST(req: NextRequest) {
  try {
    const body = await req.json()
    const parsed = leadSchema.safeParse(body)

    if (!parsed.success) {
      return NextResponse.json(
        { error: 'Datos inválidos', details: parsed.error.flatten() },
        { status: 400 }
      )
    }

    const data = parsed.data

    // Guardar en DB — sin score todavía, el worker lo asigna
    const lead = await prisma.lead.create({
      data: {
        name: data.name,
        email: data.email,
        phone: data.phone || null,
        company: data.company || null,
        message: data.message,
        projectType: data.projectType || null,
        budget: data.budget || null,
        timeline: data.timeline || null,
        source: data.source,
        utmSource: data.utmSource || null,
        utmMedium: data.utmMedium || null,
        utmCampaign: data.utmCampaign || null,
        ipAddress: req.headers.get('x-forwarded-for') ?? req.ip ?? null,
        userAgent: req.headers.get('user-agent') ?? null,
      },
    })

    // Encolar jobs en background
    const boss = await getBoss()
    await boss.send('score-lead', { leadId: lead.id })
    await boss.send('notify-lead', { leadId: lead.id }, { startAfter: 3 }) // 3s delay

    return NextResponse.json({ success: true, id: lead.id }, { status: 201 })

  } catch (err) {
    console.error('[POST /api/leads]', err)
    return NextResponse.json({ error: 'Error interno' }, { status: 500 })
  }
}

Nota: el startAfter: 3 en el job de notificación da 3 segundos de margen para que el worker de scoring termine primero. En producción puedes usar dependencias explícitas de pg-boss con onComplete, pero para la mayoría de casos este delay simple funciona perfectamente.

Setup de pg-boss: singleton y workers

pg-boss necesita una sola instancia por proceso. En Next.js esto se maneja con un singleton global que reutiliza la conexión entre hot-reloads en desarrollo y entre requests en producción.

// lib/queue/boss.ts
import PgBoss from 'pg-boss'

declare global {
  // eslint-disable-next-line no-var
  var __pgboss: PgBoss | undefined
}

export async function getBoss(): Promise<PgBoss> {
  if (global.__pgboss) return global.__pgboss

  const boss = new PgBoss({
    connectionString: process.env.DATABASE_URL,
    max: 5,              // pool máximo de conexiones para pg-boss
    deleteAfterDays: 7,  // limpiar jobs completados después de 7 días
    monitorStateIntervalSeconds: 30,
  })

  await boss.start()
  global.__pgboss = boss
  return boss
}

Los workers se registran una sola vez al iniciar la aplicación. En Next.js 15 la manera más limpia de hacerlo es en un instrumentation.ts en la raíz del proyecto, que Next.js ejecuta una vez en el servidor antes de empezar a recibir requests.

// instrumentation.ts  (raíz del proyecto, no dentro de /app)
export async function register() {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    const { getBoss } = await import('@/lib/queue/boss')
    const { registerWorkers } = await import('@/lib/queue/workers')

    const boss = await getBoss()
    await registerWorkers(boss)

    console.log('[pg-boss] Workers registrados')
  }
}

Importante: habilita el flag instrumentationHook: true en next.config.ts para que Next.js ejecute el archivo de instrumentación. Sin esto, register() nunca se llama y los workers no se registran.

Algoritmo de scoring: 0 a 100 con criterios objetivos

El score no es magia ni IA — es una función determinista que pondera los factores que en nuestra experiencia mejor predicen si un prospecto va a convertir. Puedes ajustar los pesos según tu propio negocio.

// lib/queue/scoring.ts
import type { Lead } from '@prisma/client'

export function calculateScore(lead: Lead): number {
  let score = 0

  // ── Presupuesto (hasta 40 puntos) ──────────────────────
  const budgetPoints: Record<string, number> = {
    gt5000:   40,
    '2000-5000': 30,
    '500-2000':  15,
    lt500:     5,
  }
  if (lead.budget) score += budgetPoints[lead.budget] ?? 0

  // ── Tipo de proyecto (hasta 20 puntos) ─────────────────
  const projectPoints: Record<string, number> = {
    saas:      20,
    ecommerce: 18,
    api:       15,
    landing:   10,
    otro:       5,
  }
  if (lead.projectType) score += projectPoints[lead.projectType] ?? 0

  // ── Teléfono proporcionado (15 puntos) ─────────────────
  // Quien deja teléfono tiene más intención de hablar
  if (lead.phone && lead.phone.trim().length > 6) score += 15

  // ── Longitud del mensaje (hasta 15 puntos) ─────────────
  const msgLen = lead.message.trim().length
  if (msgLen > 300)      score += 15
  else if (msgLen > 150) score += 10
  else if (msgLen > 50)  score += 5

  // ── Timeline urgente (10 puntos) ───────────────────────
  if (lead.timeline === 'urgent' || lead.timeline === '1month') score += 10

  // ── Canal de origen (bonus: hasta 5 puntos) ────────────
  if (lead.source === 'REFERRAL') score += 5
  if (lead.source === 'GOOGLE')   score += 3

  return Math.min(score, 100) // cap en 100
}

export function getTemperature(score: number): 'HOT' | 'WARM' | 'COLD' {
  if (score >= 70) return 'HOT'
  if (score >= 40) return 'WARM'
  return 'COLD'
}

Un lead con presupuesto mayor a $5.000 USD, proyecto tipo SaaS, teléfono incluido y mensaje detallado puede alcanzar fácilmente 90 puntos — clasificación HOT. Un lead sin presupuesto definido y mensaje de dos líneas queda por debajo de 20 puntos — COLD. Esa diferencia determina en qué orden respondemos.

Workers de pg-boss: scoring y notificación

Los workers son funciones asíncronas que pg-boss ejecuta cuando hay jobs pendientes en la cola. Cada worker recibe el job con su payload y puede hacer lo que necesite: consultar la DB, llamar APIs externas, enviar emails.

// lib/queue/workers.ts
import type PgBoss from 'pg-boss'
import { prisma } from '@/lib/prisma'
import { calculateScore, getTemperature } from './scoring'
import { sendLeadNotification } from '@/lib/email/resend'

export async function registerWorkers(boss: PgBoss) {

  // ── Worker 1: calcular score y temperatura ─────────────
  await boss.work<{ leadId: string }>(
    'score-lead',
    { teamSize: 2, teamConcurrency: 2 },
    async (job) => {
      const { leadId } = job.data
      const lead = await prisma.lead.findUniqueOrThrow({ where: { id: leadId } })

      const score = calculateScore(lead)
      const temperature = getTemperature(score)

      await prisma.lead.update({
        where: { id: leadId },
        data: { score, temperature },
      })

      console.log(`[score-lead] ${lead.name} → ${score}pts (${temperature})`)
    }
  )

  // ── Worker 2: enviar email de notificación ─────────────
  await boss.work<{ leadId: string }>(
    'notify-lead',
    { teamSize: 1 },
    async (job) => {
      const { leadId } = job.data
      const lead = await prisma.lead.findUniqueOrThrow({ where: { id: leadId } })

      await sendLeadNotification(lead)
      console.log(`[notify-lead] Email enviado para ${lead.email}`)
    }
  )
}

El parámetro teamSize controla cuántos jobs del mismo tipo puede procesar el worker en paralelo. Para scoring podemos procesar 2 a la vez sin problema — es pura lógica local. Para emails lo dejamos en 1 para no saturar la API de Resend y respetar rate limits.

Notificaciones por email con Resend

Resend es la opción actual más limpia para enviar emails transaccionales desde Node.js. Su SDK es minimalista y su tasa de entrega en Colombia y Latinoamérica es mucho mejor que la de los proveedores tradicionales. El email que enviamos incluye el score, la temperatura y todos los datos del lead para que el equipo pueda priorizar sin abrir el dashboard.

// lib/email/resend.ts
import { Resend } from 'resend'
import type { Lead } from '@prisma/client'

const resend = new Resend(process.env.RESEND_API_KEY)

const TEMP_COLORS = {
  HOT:  { label: '🔥 HOT',  bg: '#7f1d1d', border: '#ef4444' },
  WARM: { label: '🟡 WARM', bg: '#713f12', border: '#f59e0b' },
  COLD: { label: '🧊 COLD', bg: '#1e3a5f', border: '#3b82f6' },
}

export async function sendLeadNotification(lead: Lead) {
  const { label, bg, border } = TEMP_COLORS[lead.temperature]

  await resend.emails.send({
    from: 'leads@edwsystem.com',
    to: process.env.SALES_EMAIL ?? 'ventas@edwsystem.com',
    subject: `[${label}] Nuevo lead: ${lead.name} — ${lead.score}pts`,
    html: `
      <div style="font-family:sans-serif;max-width:600px;margin:0 auto;background:#0a0a0a;color:#a3a3a3;padding:24px;border-radius:12px">
        <div style="background:${bg};border:1px solid ${border};border-radius:8px;padding:12px 16px;margin-bottom:20px">
          <strong style="color:#fff;font-size:18px">${label} — ${lead.score} / 100</strong>
        </div>
        <table style="width:100%;border-collapse:collapse">
          <tr><td style="padding:6px 0;color:#525252;width:140px">Nombre</td>
              <td style="padding:6px 0;color:#fafafa">${lead.name}</td></tr>
          <tr><td style="padding:6px 0;color:#525252">Email</td>
              <td style="padding:6px 0;color:#fafafa">${lead.email}</td></tr>
          <tr><td style="padding:6px 0;color:#525252">Teléfono</td>
              <td style="padding:6px 0;color:#fafafa">${lead.phone ?? '—'}</td></tr>
          <tr><td style="padding:6px 0;color:#525252">Presupuesto</td>
              <td style="padding:6px 0;color:#fafafa">${lead.budget ?? '—'}</td></tr>
          <tr><td style="padding:6px 0;color:#525252">Tipo</td>
              <td style="padding:6px 0;color:#fafafa">${lead.projectType ?? '—'}</td></tr>
          <tr><td style="padding:6px 0;color:#525252">Canal</td>
              <td style="padding:6px 0;color:#fafafa">${lead.source}</td></tr>
        </table>
        <div style="margin-top:20px;padding:16px;background:#111;border-radius:8px;border:1px solid #262626">
          <p style="color:#525252;font-size:12px;margin:0 0 8px">Mensaje:</p>
          <p style="color:#a3a3a3;margin:0">${lead.message}</p>
        </div>
      </div>
    `,
  })
}

Dashboard de administración

El dashboard es un Server Component de Next.js 15 protegido por middleware de autenticación. Lee directamente desde Prisma — sin API intermedia — y muestra los leads ordenados por temperatura y fecha. No hay estado en el cliente, no hay SWR, no hay polling: cada vez que entras ves datos frescos.

// app/(admin)/admin/leads/page.tsx
import { prisma } from '@/lib/prisma'

const TEMP_BADGE: Record<string, string> = {
  HOT:  'bg-red-500/10 text-red-400 border border-red-500/20',
  WARM: 'bg-yellow-500/10 text-yellow-400 border border-yellow-500/20',
  COLD: 'bg-blue-500/10 text-blue-400 border border-blue-500/20',
}

export default async function LeadsPage() {
  const leads = await prisma.lead.findMany({
    orderBy: [
      { temperature: 'asc' }, // HOT primero (alphabetically: COLD < HOT < WARM)
      { createdAt: 'desc' },
    ],
    take: 100,
  })

  // Reordenar: HOT → WARM → COLD
  const ORDER = { HOT: 0, WARM: 1, COLD: 2 }
  leads.sort((a, b) => ORDER[a.temperature] - ORDER[b.temperature])

  return (
    <div className="p-6 max-w-6xl mx-auto">
      <h1 className="text-2xl font-black text-[#fafafa] mb-6">
        Leads ({leads.length})
      </h1>
      <div className="flex flex-col gap-3">
        {leads.map((lead) => (
          <div key={lead.id}
            className="flex items-center gap-4 p-4 bg-[#111111] border border-[#262626] rounded-xl"
          >
            <span className={`px-2 py-0.5 text-xs font-bold rounded ${TEMP_BADGE[lead.temperature]}`}>
              {lead.temperature}
            </span>
            <span className="text-sm font-semibold text-[#fafafa] w-40 truncate">{lead.name}</span>
            <span className="text-sm text-[#a3a3a3] flex-1 truncate">{lead.email}</span>
            <span className="text-sm font-mono text-[#525252]">{lead.score}pts</span>
            <span className="text-xs text-[#525252]">{lead.budget ?? '—'}</span>
            <span className="text-xs text-[#525252]">
              {new Date(lead.createdAt).toLocaleDateString('es-CO')}
            </span>
          </div>
        ))}
      </div>
    </div>
  )
}

Resultados: qué cambia en la práctica

Después de poner esto en producción, el impacto más inmediato fue en el tiempo de respuesta a leads calificados. Antes: respondíamos en el orden en que llegaban los correos, a veces con 4–6 horas de delay para proyectos grandes. Después: los leads HOT se ven destacados en el dashboard desde el primer minuto.

Lo que hace el sistema automáticamente:

  • Guarda cada lead en base de datos con timestamp, IP y canal de origen
  • Calcula el score 0-100 en menos de 100ms sin bloquear la respuesta al usuario
  • Clasifica el lead como HOT, WARM o COLD según criterios objetivos
  • Envía un email de notificación con el score ya calculado y los datos completos
  • Registra los UTM params para saber qué campañas generan leads de calidad
  • Permite filtrar y ordenar leads en el dashboard sin SQL manual

Lo más valioso no es la tecnología — es tener datos. Después de dos meses podemos ver que el 80% de nuestros leads HOT vienen de referidos o de búsqueda orgánica, y que los leads de TikTok tienen presupuestos menores pero mayor urgencia. Eso cambia cómo priorizamos el contenido y cómo respondemos según el canal.

El sistema completo — schema, API, workers, email y dashboard — lo tenemos en menos de 600 líneas de TypeScript. Sin servicios externos extra más allá de Resend (que cobra $0 hasta 3.000 emails/mes). No necesitas infraestructura compleja para tener un CRM funcional — necesitas un buen diseño dentro de la herramienta que ya tienes.

¿Quieres algo así para tu negocio?

Construimos sistemas de captura y calificación de leads a medida — integrados con tu CRM actual o desde cero. Cuéntanos tu caso y lo analizamos sin costo.

¿Te fue útil este artículo?

Déjame tu email y te aviso cuando publique nuevos artículos técnicos.