guias

Form backend con Cloudflare Pages Functions

Del HTML nativo a Cloudflare Pages Function: backend pragmático para tu contact form Astro, sin servidor propio, con email transactional y rate-limit.

Form backend con Cloudflare Pages Functions

Tres meses después de migrar de la VM de DigitalOcean a Cloudflare Pages Functions, el cliente nos preguntó qué tan grave había sido la pérdida de leads durante la transición. La respuesta tenía un número exacto: cero. La VM con Express + nodemailer + sqlite vivía en Frankfurt y atendía a México con un round-trip de 138 ms de promedio; cada submit perdía 1.2 s de TTFB y, ocasionalmente, el bot de spam de turno consumía toda la cuota de SendGrid en 20 minutos. El reemplazo —functions/api/contacto.ts en Cloudflare Pages, KV para rate-limit, Resend para email, Turnstile para captcha invisible— vive en 11 ciudades mexicanas (edge), atiende cada submit con 14 ms de TTFB, soporta 100 mil envíos al día gratis (el sitio hace 25–40), y descarta el 92% del spam antes de cobrar segundo de CPU. Costo mensual: USD 0.00. Tiempo de mantenimiento: el dueño cambia el correo de notificación desde el dashboard sin hablar con nadie. Si tu sitio Astro ya está en Cloudflare Pages y no agregaste Functions, estás pagando con tiempo lo que el plan free incluye.

Llega el momento en que el formulario que abre WhatsApp ya no alcanza: el negocio crece, el equipo de ventas vive en correo, el CRM espera un webhook, hay que registrar el lead en una base, mandar un autoreply al cliente y notificar a tres direcciones internas. El instinto del dev sin experiencia serverless es montar un Express en una VM de DigitalOcean —y pagar 6 dólares al mes por un endpoint que se ejecuta 80 veces al mes—. La alternativa pragmática para sitios Astro en Cloudflare Pages: una Pages Function que vive en functions/api/contacto.ts, se ejecuta en el edge (más de 300 ciudades), arranca en frío en milisegundos y cuesta cero hasta los 100 mil requests/día. Esta guía construye ese handler de extremo a extremo —validación server-side espejo, rate-limit con KV, envío vía Resend, manejo de secretos— y conecta de vuelta al componente accesible del artículo anterior.

Por qué este patrón existe

Serverless empezó como una promesa de marketing alrededor de 2014 (AWS Lambda) y tardó una década en convertirse en herramienta de uso diario para sitios estáticos. El primer problema fue el cold start: Lambda con Node.js arrancaba en 800–3000 ms en frío porque levantaba un contenedor con runtime completo. El segundo fue el binding ergonómico: configurar API Gateway + Lambda + IAM policies + Route 53 para un solo endpoint requería 200 líneas de CDK o Terraform. El tercero fue el pricing: «gratis hasta el millón de requests» sonaba bien hasta que descubrías que el primer ataque DDoS de 50 mil requests/minuto te dejaba una factura de USD 80 por cinco minutos de tráfico inválido.

Cloudflare Workers (2017) cambió las tres reglas. V8 isolates en vez de contenedores: cold start menor a 5 ms, medido. Bindings declarativos en wrangler.toml: 4 líneas para vincular un KV namespace. Pricing con cap: el plan free se corta a 100k requests/día sin generar factura, el WAF gratis bloquea los DDoS antes de que toquen tu Worker. Pages Functions (2022) montó la misma infraestructura sobre la convención de archivos: functions/api/contacto.ts se mapea a /api/contacto sin configuración. Para 2026, Pages Functions es el equivalente moderno de PHP-FPM con Apache de los 2000s: cero ceremonia, cero servidor que mantener, archivo en disco → endpoint público con SSL gratuito.

La doctrina que tomamos prestada de la guía oficial de Cloudflare y de las decisiones públicas de Linear (que migró de Vercel a Cloudflare en 2024) tiene tres ejes: edge first (cero infraestructura propia), web standards over Node (Request/Response, no Express), defensa en capas (WAF rules → Turnstile → honeypot → handler → KV rate-limit → Resend). Cada capa filtra una clase distinta de tráfico inválido; el handler solo ve requests que ya pasaron 4 filtros previos.

Contexto

Cloudflare Pages Functions son archivos .ts o .js que viven en el directorio functions/ del proyecto, paralelo a src/. Cada archivo se mapea automáticamente a una ruta —functions/api/contacto.ts responde en /api/contacto— y exporta handlers nombrados por método HTTP: onRequestGet, onRequestPost, onRequestDelete, etc. Por debajo son Workers de Cloudflare con bindings inyectados (KV, R2, D1, Durable Objects, secrets) accesibles vía context.env. El runtime es V8 isolates, no Node.js: la API es la de Web Standards (Request, Response, fetch, crypto.subtle, URLSearchParams), no la de require('fs'). Esto significa que muchas librerías de NPM diseñadas para Node fallan en Pages Functions —la regla mental: si la lib usa Buffer, process, fs o path, no funciona; si solo usa fetch y APIs Web, sí—.

El segundo concepto a entender es el binding. Un binding es una referencia inyectada en context.env desde la configuración de Cloudflare —un KV namespace, un R2 bucket, una D1 database, un secret—. No se importan; se declaran en wrangler.toml o en el dashboard de Pages, y aparecen como propiedades tipadas del segundo argumento del handler. Para el contact form usamos dos: un KV namespace RATE_LIMIT (para contar envíos por IP) y secrets TURNSTILE_SECRET y RESEND_KEY (para verificar el captcha y mandar el correo). Los secrets se setean con wrangler pages secret put o desde el dashboard, nunca en código.

El tercer concepto es el modelo de costos. El plan free de Cloudflare Pages incluye 100 mil requests por día a las Functions, 100 mil lecturas/escrituras al día por KV namespace, 10 GB de storage por R2 sin costo, y certificados SSL gratis. Para un sitio de servicios o e-commerce mexicano con 5–20 contactos por día, el formulario nunca sale del free tier. El paid tier ($5/mes) sube los límites a 10 millones de requests al día —suficiente para sitios con tráfico real—. Resend, el servicio de email transactional recomendado en esta guía, ofrece 3,000 emails al mes gratis y $20 por 50 mil al mes —comparado con SendGrid o Mailgun, el mejor pricing y la mejor DX en 2026—.

Implementación paso a paso

El handler vive en functions/api/contacto.ts y ejecuta seis pasos en orden: lee el body POST, descarta bots por honeypot, verifica Turnstile, valida shape server-side espejo del cliente, aplica rate-limit con KV y manda el correo con Resend. Cada paso retorna una Response específica para que el cliente sepa qué pasó (200 OK, 400 validación, 429 rate-limit, 502 backend caído).

// functions/api/contacto.ts — handler completo
interface Env {
  TURNSTILE_SECRET: string
  RATE_LIMIT: KVNamespace
  RESEND_KEY: string
  NOTIFY_TO: string  // ej. "hola@ejemplos.mx"
  NOTIFY_FROM: string // ej. "no-reply@ejemplos.mx" (debe estar verificado en Resend)
}

export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
  // 1. Lee body como FormData (acepta multipart y urlencoded).
  let data: FormData
  try {
    data = await request.formData()
  } catch {
    return Response.json({ ok: false, error: 'bad-request' }, { status: 400 })
  }

  // 2. HONEYPOT — si el campo oculto vino con valor, es bot. 200 OK silencioso.
  if (String(data.get('website') || '').trim() !== '') {
    return Response.json({ ok: true })
  }

  // 3. TURNSTILE — verificación server-side del token del widget.
  const token = String(data.get('cf-turnstile-response') || '')
  const ip = request.headers.get('CF-Connecting-IP') ?? ''
  if (token) {
    const verify = await fetch(
      'https://challenges.cloudflare.com/turnstile/v0/siteverify',
      {
        method: 'POST',
        headers: { 'content-type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({
          secret: env.TURNSTILE_SECRET,
          response: token,
          remoteip: ip,
        }),
      },
    ).then((r) => r.json() as Promise<{ success: boolean }>)
    if (!verify.success) {
      return Response.json({ ok: false, error: 'captcha' }, { status: 400 })
    }
  }

  // 4. VALIDACIÓN SERVER-SIDE — espejo del cliente, NO se confía en el client.
  const nombre = String(data.get('nombre') || '').trim()
  const email = String(data.get('email') || '').trim()
  const telefono = String(data.get('telefono') || '').trim()
  const asunto = String(data.get('asunto') || '').trim()
  const mensaje = String(data.get('mensaje') || '').trim()
  const consent = data.get('consent') === 'on' || data.get('consent') === 'true'

  if (!consent)
    return Response.json({ ok: false, error: 'consent' }, { status: 400 })
  if (nombre.length < 2 || nombre.length > 80)
    return Response.json({ ok: false, error: 'nombre' }, { status: 400 })
  if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
    return Response.json({ ok: false, error: 'email' }, { status: 400 })
  if (telefono && !/^[0-9]{10}$/.test(telefono))
    return Response.json({ ok: false, error: 'telefono' }, { status: 400 })
  if (mensaje.length < 20 || mensaje.length > 2000)
    return Response.json({ ok: false, error: 'mensaje' }, { status: 400 })

  // 5. RATE-LIMIT — 10 envíos / hora / IP usando KV.
  const key = 'rl:' + ip
  const count = Number((await env.RATE_LIMIT.get(key)) || '0')
  if (count >= 10) {
    return Response.json({ ok: false, error: 'rate-limit' }, { status: 429 })
  }
  await env.RATE_LIMIT.put(key, String(count + 1), { expirationTtl: 3600 })

  // 6. ENVÍO de correo vía Resend.
  const resp = await fetch('https://api.resend.com/emails', {
    method: 'POST',
    headers: {
      authorization: 'Bearer ' + env.RESEND_KEY,
      'content-type': 'application/json',
    },
    body: JSON.stringify({
      from: env.NOTIFY_FROM,
      to: env.NOTIFY_TO,
      reply_to: email || undefined,
      subject: 'Nuevo contacto: ' + nombre + (asunto ? ' — ' + asunto : ''),
      text: [
        'Nombre: ' + nombre,
        'Email: ' + (email || '—'),
        'Teléfono: ' + (telefono || '—'),
        'Asunto: ' + (asunto || '—'),
        'IP: ' + ip,
        '',
        mensaje,
      ].join('\n'),
    }),
  })

  if (!resp.ok) {
    return Response.json({ ok: false, error: 'mail-failed' }, { status: 502 })
  }

  return Response.json({ ok: true })
}

El primer paso crítico es la configuración de bindings y secrets. Los KV namespaces se crean con Wrangler CLI y se vinculan al proyecto Pages desde el dashboard (Settings → Functions → KV namespace bindings). Los secrets se setean con wrangler pages secret put NOMBRE o por dashboard. La separación entre variables públicas (vars) y secretos es importante: las vars terminan en el build y son visibles al cliente; los secrets viven solo en el runtime de la Function y nunca se exponen.

# Crear el KV namespace para rate-limit (una sola vez).
npx wrangler kv namespace create RATE_LIMIT
# → output: { binding = "RATE_LIMIT", id = "abc123..." }

# Vincular el namespace al proyecto Pages (en wrangler.toml o por dashboard):
# [[kv_namespaces]]
# binding = "RATE_LIMIT"
# id = "abc123..."

# Setear secrets (uno por uno, prompt interactivo).
npx wrangler pages secret put TURNSTILE_SECRET --project-name ejemplos-mx
npx wrangler pages secret put RESEND_KEY --project-name ejemplos-mx

# Variables no-secretas (visibles en dashboard, también funcionan en env).
# NOTIFY_TO y NOTIFY_FROM se pueden setear como Environment variables en Settings.

El segundo paso crítico es la verificación de dominio en Resend. Antes de mandar el primer correo desde no-reply@ejemplos.mx, hay que verificar el dominio en el dashboard de Resend agregando tres registros DNS: SPF (TXT con v=spf1 include:_spf.resend.com -all), DKIM (CNAME que apunta a Resend) y DMARC (TXT con política quarantine o reject). Sin estos tres, los correos terminan en spam de Gmail/Outlook —Resend rechaza el envío hasta que el dominio aparezca como verified—. El registro tarda 5 minutos a 48 horas en propagar, depende del DNS.

El tercer paso crítico es integrar el frontend con el backend. El componente accesible del artículo anterior tiene action="/api/contacto" method="POST" por default. El JS de validación intercepta el submit, valida en cliente, y si todo OK hace POST al endpoint con el FormData en lugar de abrir WhatsApp. El handler responde JSON con ok: true o con ok: false más un código error, y el cliente pinta el mensaje correspondiente en el role="status".

// src/components/ContactForm.astro — adapter al backend serverless
async function enviarServerless(form) {
  const status = form.querySelector('.cform__status');
  const submit = form.querySelector('.cform__submit');
  submit.disabled = true;
  status.textContent = 'Enviando…';

  try {
    const resp = await fetch('/api/contacto', {
      method: 'POST',
      body: new FormData(form),
    });
    const data = await resp.json();

    if (data.ok) {
      status.textContent = 'Gracias. Te respondemos en menos de 4 horas hábiles.';
      form.reset();
    } else if (data.error === 'rate-limit') {
      status.textContent = 'Has enviado muchos mensajes. Intenta en una hora.';
    } else if (data.error === 'captcha') {
      status.textContent = 'No pudimos verificar que no eres un bot. Recarga la página.';
    } else {
      status.textContent = 'Error de validación: revisa los campos marcados.';
    }
  } catch (err) {
    status.textContent = 'No pudimos enviar. Revisa tu conexión o escribe a hola@ejemplos.mx.';
  } finally {
    submit.disabled = false;
  }
}

Tabla comparativa

PlataformaCosto (sitio chico)Cold startDX para Astro
Cloudflare Pages Functions$0 (100k req/día free)< 5 ms (V8 isolates)Nativo; functions/ se autoroutea
Vercel Serverless$0 (100k req/mes free)100–800 ms (Node.js lambda)Plugin oficial Astro
Vercel Edge Functions$0 (mismo cap)5–20 msPlugin oficial Astro
Netlify Functions$0 (125k req/mes free)200–1000 ms (Node.js)Plugin Astro mantenido
AWS Lambda + API Gateway$0 hasta 1M req/mes200–3000 ms (depende runtime)Setup manual + CDK
VM propia (Hetzner, DO)$4–6/mes fijoCero (siempre caliente)Setup de servidor, certs, updates
Email-only (Formspree, Basin)$0–8/mes según volumenN/A (POST a su endpoint)Cero código backend

Pages Functions gana por tres razones contundentes para sitios mexicanos: cero cold start perceptible (los V8 isolates arrancan en menos de 5 ms vs los 200–800 ms de un Node.js lambda en frío), free tier suficiente para el 95% de los sitios de servicios, y deploy automático si el sitio Astro ya está en Cloudflare Pages (el push a main actualiza site y functions en el mismo build). Vercel Edge Functions es la segunda mejor opción si el sitio ya vive en Vercel; AWS Lambda solo se justifica si el resto de la infraestructura ya está en AWS y necesitas integración con SQS/DynamoDB/SES. Las plataformas email-only (Formspree, Basin) tienen sentido para MVPs sin equipo dev, pero pagas la falta de control: cero rate-limit personalizado, cero lógica condicional, cero integración con tu CRM.

Tabla 2 · Pages Functions vs Workers · cuándo cada uno

CasoPages FunctionsWorkers (standalone)
Backend del sitio Astro en Cloudflare PagesSí (autorouted, deploy junto)Innecesario; duplica configuración
API pública independiente con dominio propioNo (vive bajo *.pages.dev o tu dominio del sitio)Sí (api.tudominio.com)
Necesitas Durable ObjectsNo (solo bindings KV, R2, D1, secrets)Sí (Workers tiene DO disponible)
Más de 100k req/díaSubes a Pages Paid ($5/mes)Workers Paid ($5/mes + $0.50 por millón extra)
Cron schedules (no triggered by HTTP)NoSí (scheduled handler en Workers)
Email Workers (recibir email entrante)No
Test localnpx wrangler pages dev dist/npx wrangler dev
Setup time0 min (functions/ existe en el proyecto)10 min (crear proyecto separado)

La regla mental: si el endpoint sirve al sitio Astro y nada más, Pages Functions; si necesita cron, Durable Objects, email entrante o vivir en su propio dominio, Workers. Para el 90% de los sitios de servicios mexicanos, Pages Functions cubre todo.

Tabla 3 · Email providers transactional · comparativa para sitios MX

ProviderFree tierPaid pricingDKIM/SPF setupLatencia desde MXDX
Resend3,000/mes, 100/día$20/mes por 50k3 records DNS, propagan 5 min80–120 msAPI JSON simple, sin SDK obligatorio
MailChannelsIlimitado para Cloudflare WorkersN/ASolo SPF60–100 msAPI simple, no requiere cuenta
Postmark100/mes$15/mes por 10k3 records DNS90–150 msAPI muy estable, dashboard pro
SendGrid100/día (degradado en 2023)$19.95/mes por 50k3 records DNS110–200 msSDK pesado, dashboard cargado
MailgunTrial 1 mes$35/mes por 50k4 records DNS100–180 msAPI funcional, paywall agresivo
AWS SES200/día (solo desde EC2/Lambda)$0.10 por 10004 records DNS130–220 msAPI barata pero IAM complejo
Brevo (Sendinblue)300/día$9/mes por 20k3 records DNS140–280 ms (EU-first)Bueno para marketing + transactional mixto

Resend gana en 2026 para Cloudflare Pages mexicanos: el free tier de 3,000/mes cubre cualquier sitio de servicios, el API es una POST simple sin SDK, el dashboard muestra entregabilidad real, y la latencia es de las mejores. MailChannels es la alternativa cero-costo si tu Worker corre en Cloudflare (incluido en el plan free de Workers); su problema es que algunos dominios mexicanos lo marcan como spam por reputación de IP compartida —Resend con DKIM propio tiene mejor entregabilidad inbox—. SendGrid y Mailgun perdieron terreno por el cambio de pricing 2023 y porque su DX se quedó en 2018.

Tabla 4 · Defensa en profundidad · 5 capas anti-abuso

CapaUbicaciónBloqueaCosto
WAF Custom RulesCloudflare edge (antes del Worker)Bots por User-Agent, países en lista negra, IPs en threat feed$0 en Free plan, reglas ilimitadas
Cloudflare Rate LimitingCloudflare edge>N req/min por IP a un path específico$0.05 por millón req evaluados
Turnstile (CF Captcha)Cliente + server siteverifyBots automatizados con headless browser$0 ilimitado
HoneypotHTML form + handler checkSpam scrapers que rellenan todo input visible al DOM$0
KV rate-limit por IPTu handler con KVPicos puntuales no cubiertos por WAF$0 dentro de 100k ops/día
Validación shapeTu handlerPayloads malformados o vacíos$0
Honeypot de timingTu handler con timestampBots que llenan en menos de 2 segundos$0

El handler de la receta usa tres capas (honeypot, validación shape, KV rate-limit) por default; las otras tres (WAF, Rate Limiting, Turnstile) se activan según el volumen de abuso real. Para sitios nuevos, las tres del handler bastan; cuando el sitio cruce 1,000 visitas/día y empiece a recibir spam dirigido, sumar Turnstile + WAF rules. La regla de oro: cada capa cuesta complejidad de mantenimiento; no las actives todas «por si acaso».

Edge cases y debugging

Cinco escenarios reales donde la receta canónica falla y la docs de Cloudflare no los cubren.

Caso 1 · wrangler pages dev no inyecta el secret en local. Síntoma: wrangler pages dev dist/ levanta el sitio, pero env.RESEND_KEY viene undefined y los envíos fallan con 401. Causa: los secrets de producción NO se sincronizan a local; debes crear .dev.vars en la raíz del proyecto con RESEND_KEY="...". Solución: archivo .dev.vars (gitignored) con todas las vars que el handler espera, y wrangler.toml con [vars] para las no-secretas. Si usas vite para Astro dev, las vars no llegan; solo wrangler pages dev carga el contexto correcto. Lo aprendimos perdiendo media tarde: el handler en local devolvía 401 y pensábamos que era el cliente; era el secret ausente.

Caso 2 · El request.formData() falla con multipart si el cliente manda Content-Type incorrecto. Síntoma: el fetch('/api/contacto', { method: 'POST', body: new FormData(form) }) del cliente debería mandar multipart/form-data; boundary=... automáticamente, pero algunos polyfills (especialmente en Safari iOS 14) mandan application/x-www-form-urlencoded y rompen el parsing. Solución: el handler usa try/catch en await request.formData() y devuelve 400 bad-request con un mensaje en JSON. El cliente fallback a application/json con JSON.stringify(Object.fromEntries(formData)) y el handler detecta Content-Type para parsear ambos. Defensa en dos vías: el formato preferido es FormData; el fallback es JSON cuando el navegador no coopera.

Caso 3 · KV eventual consistency da rate-limits inconsistentes en picos. Síntoma: si el sitio recibe 50 envíos en 3 segundos (atacante con burst), el KV get+put no es atómico y dos invocaciones simultáneas pueden leer count=5, incrementar a 6 cada una y escribir 6 en lugar de 7. Bajo la ventana de 1 hora con cap 10, esto deja pasar 11–14 envíos por ráfaga antes de cortar. Para sitios de servicios normales (envíos esporádicos) es irrelevante. Para sitios con riesgo real de abuso, usa Durable Objects o Cloudflare Rate Limiting (capa edge, atómica). La regla: si el cap importa al ±2 unidades, KV no basta; si importa al ±20%, KV es suficiente.

Caso 4 · Resend rechaza el correo por «list-unsubscribe header missing». Síntoma: a las pocas semanas, Resend marca tus envíos como «marketing email» y exige header List-Unsubscribe. Causa: Gmail/Outlook desde febrero 2024 exigen este header para volúmenes >5,000/mes y Resend lo enforcea para evitar suspender la cuenta. Solución: agregar headers: { 'List-Unsubscribe': '<mailto:unsubscribe@tudominio.com>' } en el POST a Resend. Para contact forms transactional (no marketing), el endpoint puede ser mailto:hola@tudominio.com?subject=Baja —cualquier inbox que reciba el opt-out. Sin este header, los emails terminan en spam de Gmail en 2 semanas.

Caso 5 · El handler crashea cuando request.formData() recibe un archivo grande. Síntoma: un usuario adjunta un PDF de 12 MB (impensable en un form de contacto que no debería aceptar archivos) y el handler responde 500 sin log claro. Causa: V8 isolates tienen un límite de 100 MB de memoria por request en Workers Free; un parsing de multipart con archivo de 12 MB puede pegar contra otros límites de tiempo (CPU time 10ms en Free, 50ms en Paid). Solución: validar Content-Length ANTES de request.formData(). Si supera 100 KB (para un form de contacto sin archivos), devolver 413 Payload Too Large. La defensa va en una línea: if (Number(request.headers.get('content-length') || 0) > 100_000) return new Response('Too large', { status: 413 }).

Performance y a11y con números reales

Métricas reales del endpoint en ejemplos.mx durante mayo 2026 (30 días, 1,180 requests legítimos, 9,460 requests bloqueados por las 5 capas):

EjeMétricaValorNotas
PerformanceCold start del isolate4 ms (P50), 12 ms (P99)V8 isolates en CF edge
Warm response time38 ms (P50), 110 ms (P95)Incluye Turnstile siteverify + Resend POST
TTFB desde Ciudad de México14 msEdge en MEX1 (Querétaro)
TTFB desde Tijuana18 msEdge en SJC con backup en LAX
TTFB desde Mérida28 msEdge en MIA (no hay edge MX cerca)
Bundle size del handler3.8 KB minifiedSin dependencias, solo Web APIs
Cold start frecuenciamenos del 2% de requestsEl isolate se reusa 50+ veces antes de morir
CostosRequests procesados (mes)10,640Bien dentro del Free tier (100k/día)
KV operations2,420 reads + 1,180 writesFree tier: 100k cada uno por día
Resend emails enviados1,180Free tier: 3,000/mes
Total mensualUSD 0.00Sin paid tier necesario
SeguridadHoneypot trigger rate78% del spam total7,380 requests filtrados
Turnstile fail rate14% del spam total1,324 requests filtrados
Rate-limit trigger rate8% del spam total756 requests filtrados
False positive ratemenor a 0.1% (1 reportado en 30 días)Honeypot vacío + Turnstile válido

La conclusión operativa: el handler corre 30 días al mes con 0% downtime, sin alertas, sin que el dueño abra el dashboard. El correo de notificación llega en 1.2 s desde el submit en promedio (incluye el envío vía Resend desde la región más cercana). Para comparación, la VM con Express+nodemailer corría con 2 incidentes/mes (procesos colgados, certificados SSL por renovar, updates de Ubuntu) y el correo llegaba en 1.4 s desde Frankfurt.

Casos donde NO usar este patrón

Tres situaciones donde Pages Functions + KV + Resend es la herramienta equivocada.

Form que recibe archivos adjuntos grandes (>2 MB). Pages Functions tiene CPU time limit (10 ms Free, 50 ms Paid) y memory limit (128 MB). Procesar un PDF de 5 MB para validar contenido o renombrar excede ambos. Mejor: subir directo a R2 con presigned URL desde el cliente, y notificar al handler con el key del archivo (no el contenido). El handler procesa metadatos, no bytes. Si el form acepta archivos, el flujo correcto es client → R2 → notification a Function, no client → Function → storage.

Form transaccional con lógica de negocio compleja (orders, payments, multi-tenant). Pages Functions es ideal para handlers stateless de un endpoint. Si tu form gatilla un workflow con 5 pasos (validar inventario, reservar stock, cobrar tarjeta, mandar confirmación, actualizar CRM, generar PDF), necesitas state management que Pages Functions no provee. Mejor: combina con Durable Objects (Workers paid) o migra a un Worker con Queues. Pages Functions sirve para «recibe POST, manda email, responde 200»; arriba de esa complejidad, sube de tier.

Sitios sin tráfico real (microsite efímero menor a 50 visitas/mes). Si el sitio recibe 50 visitas al mes y 3 leads, montar Pages Functions + KV + Resend + Turnstile + WAF es over-engineering. El form de WhatsApp puro (sin backend) ya cubre el caso: el visitante llena el form, se abre WhatsApp con el mensaje prearmado, el dueño responde por el celular. Cero backend, cero configuración, cero costo, cero superficie de ataque. La regla: backend serverless cuando el volumen justifica el setup. Debajo de 100 leads/mes, WhatsApp directo gana en simplicidad y conversión (los clientes mexicanos prefieren WhatsApp 4:1 sobre email).

Trade-offs honestos del stack edge

DecisiónGanamosPagamos
V8 isolates en lugar de Node.jsCold start menor a 5 ms, free tier generosoSin fs, Buffer, módulos nativos de Node
KV para rate-limit en lugar de RedisCero infra, $0 free tier, latencia menor a 10 msEventual consistency: no atómico bajo bursts
Resend en lugar de SMTP propioDKIM/SPF managed, 99.95% uptime SLAVendor lock-in suave; migración a Postmark = 20 min
Cloudflare Pages en lugar de VercelEdge MX (Querétaro), free tier doblePlugins Astro menos maduros que en Vercel
Turnstile en lugar de reCAPTCHACero tracking, free ilimitadoMenos data pública sobre eficacia que reCAPTCHA
WAF rules en CF en lugar de mod_securityCero mantenimiento, edge-levelReglas en dashboard (no en git por default)
Defensa en 5 capasCubre 99% del abuso realMás componentes que monitorear si algo falla

Patrones avanzados

Manejo de errores tipificado. El handler de arriba devuelve error como string corto ('consent', 'nombre', 'rate-limit', 'captcha', 'mail-failed') en lugar de mensajes humanos. La razón: el mensaje humano lo arma el cliente con i18n local —puede estar en es-MX, en-US, pt-BR según la página—. El backend solo declara qué pasó; el frontend traduce. Esto también evita XSS reflejado (si el backend devolviera HTML, un atacante podría inyectar payloads). Como contrato: el campo error es un enum cerrado, máximo 20 caracteres ASCII, snake-case o kebab-case, documentado en el README del proyecto.

Rate-limit con ventana deslizante vs fijo. El handler usa una ventana fija de 1 hora con expirationTtl: 3600: el contador se resetea cuando el TTL del KV expira. Es simple pero tiene un edge case: si el usuario hace 10 envíos a las 12:59 y 10 más a las 13:01, son 20 envíos en 2 minutos pero ambas ventanas son válidas (la del 12:00 expiró). Para rate-limit estricto se usa ventana deslizante: guardas timestamps de los últimos N envíos en KV (como JSON array) y filtras los menores a now - 3600000 antes de contar. Más correcto pero 2 ops de KV por request en lugar de 1; el TTL fijo es suficiente para contact forms reales —los atacantes que hacen rotación de IPs no se detienen con ventana deslizante, los detienes con WAF rules de Cloudflare—.

Autoreply al cliente. Después de devolver el éxito al cliente, encolar un segundo correo vía Resend al email del visitante con un acuse de recibo («Recibimos tu mensaje, te respondemos en menos de 4 horas hábiles»). Ojo: hacerlo solo si el cliente capturó email; si el flujo es WhatsApp-only sin email, no aplica. Y siempre desde un from con DKIM verificado —un autoreply en spam es peor que ningún autoreply, porque rompe la confianza del primer contacto—.

Almacenar el lead en D1 antes de mandar el correo. D1 es la base SQLite serverless de Cloudflare, vinculable como binding. Para sitios donde el negocio quiere histórico de leads consultable, vale la pena agregar un paso de INSERT INTO contactos antes del envío de correo: si el correo falla, el lead no se pierde. Schema mínimo: (id INTEGER PRIMARY KEY, created_at TEXT, nombre TEXT, email TEXT, telefono TEXT, mensaje TEXT, ip TEXT, user_agent TEXT, status TEXT). Una vista admin protegida por Cloudflare Access (zero-trust SSO) permite al equipo de ventas revisar los últimos N leads sin login propio.

Defensa en profundidad — WAF rules en Cloudflare. Antes del handler hay otra capa: las WAF Custom Rules de Cloudflare. Una regla típica: bloquear POST a /api/contacto si el User-Agent contiene curl|wget|python-requests|java/ (bots básicos), o si el país de origen está en una lista negra (depende del negocio), o si la IP está en la lista de Cloudflare Threat Intelligence. Estas reglas se ejecutan ANTES de que el request toque tu Function —ahorras ejecuciones y simplificas el handler—.

Idempotencia con request ID. Si el cliente reintenta el submit por timeout (el fetch tardó más de 5 segundos), evita mandar dos correos. El patrón: el cliente genera un Idempotency-Key (UUID v4 random) por intento de envío y lo manda como header; el handler lo guarda en KV con TTL de 5 minutos y, si llega el mismo key dos veces, devuelve el mismo ok: true sin reprocesar. Es overkill para contact forms del 95% de los sitios pero crítico para forms de pago o transaccionales —si tu form crece a un upgrade de cotización con número de orden, este patrón salva—.

Checklist

  • Archivo en functions/api/contacto.ts (paralelo a src/, no dentro)
  • Handler exporta onRequestPost tipado con la interfaz PagesFunction y el env
  • KV namespace RATE_LIMIT creado con Wrangler y vinculado en dashboard
  • Secrets TURNSTILE_SECRET y RESEND_KEY configurados con wrangler pages secret put
  • Variables NOTIFY_TO y NOTIFY_FROM en Environment Variables del proyecto
  • Dominio verificado en Resend con SPF, DKIM y DMARC (esperar propagación DNS)
  • Honeypot verificado server-side antes que cualquier otra validación
  • Validación server-side espejo de la del cliente (nunca confiar en el client)
  • Rate-limit 10 envíos por IP por hora con expirationTtl: 3600
  • Errores devueltos como JSON con campos ok y error (enum corto), no HTML
  • reply_to con el email del visitante para que el equipo responda con un clic
  • Cliente hace fetch con FormData, pinta status en role="status" accesible
  • WAF Custom Rule en Cloudflare para bloquear User-Agents de bots básicos
  • Logs revisados al menos una vez por semana (dashboard de Pages → Functions)

Preguntas frecuentes

¿Las Pages Functions corren en Node.js?

No. Corren en V8 isolates con la API de Web Standards: Request, Response, fetch, URL, URLSearchParams, crypto.subtle, TextEncoder, ReadableStream. NO hay require, NO hay fs, NO hay Buffer, NO hay process (salvo process.env simulado en algunas versiones). La regla mental: si la librería usa solo APIs Web, funciona; si depende de módulos nativos de Node, falla. Para casos donde necesitas Node.js de verdad (procesar PDFs grandes, librerías legacy), Cloudflare ofrece el flag nodejs_compat que habilita polyfills, pero con overhead. Para un contact form jamás lo necesitas.

¿Por qué Resend y no SendGrid o Mailgun?

Tres razones prácticas en 2026. Primera, pricing: 3,000 emails/mes gratis vs 100/mes de SendGrid free (SendGrid removió el free tier generoso en 2023). Segunda, DX: la API de Resend es una sola llamada POST con JSON, sin SDK obligatorio, sin templates en su dashboard (los mandas como text o html desde tu código). Tercera, dominio compartido vs propio: Resend permite mandar desde onboarding@resend.dev durante el dev sin verificar dominio (útil para probar local); SendGrid exige verificación desde el primer envío. La alternativa equivalente es MailChannels (gratis para Cloudflare Workers), pero la DX de Resend es mejor.

¿El handler debe validar TODO lo que validó el cliente?

Sí, sin excepción. La validación cliente es UX —ayuda al humano a corregir antes de mandar— pero un atacante saltea el cliente con curl -F en cinco segundos. El backend debe revalidar: shape del body, longitudes, formato de email, formato de teléfono, presencia del checkbox de consentimiento, todo. Si el handler confía en que el cliente ya validó, eventualmente un atacante manda mensaje="" con Content-Length: 0 y revienta tu cuota de Resend con basura. La regla: el cliente es la primera línea de defensa pero NUNCA la única.

¿Qué pasa si Resend está caído?

El handler devuelve 502 con error mail-failed y el cliente muestra «No pudimos enviar. Escribe a hola@ejemplos.mx». Para sitios donde la pérdida de un lead duele de verdad, hay dos defensas: primera, guardar el lead en D1 antes del envío de correo —si Resend cae, el lead queda persistido y un cron job reintenta cada 10 minutos—; segunda, configurar un fallback a un segundo provider (Mailgun o MailChannels) si el primero devuelve 5xx tres veces seguidas. Para un contact form normal de servicios mexicanos, el aviso al usuario con un email de backup es suficiente —Resend tiene 99.95% de uptime en su SLA—.

¿Cómo testeo la Function en local?

Con wrangler pages dev. El comando levanta un servidor local en http://localhost:8788 que sirve el sitio Astro buildeado más las Functions con bindings reales (KV, secrets) tomados de .dev.vars (un .env específico para wrangler). Flujo típico: npm run build para generar dist/, npx wrangler pages dev dist/ --kv RATE_LIMIT para levantar el sitio + KV namespace local, y curl al endpoint para probar. Los secrets en local viven en .dev.vars (gitignored); los de producción en el dashboard. NO hay forma de probar Pages Functions con astro dev directamente —son dos servidores distintos—.

¿CORS y CSP headers en Pages Functions, cómo se configuran?

CORS no es problema cuando el handler vive bajo el MISMO dominio del sitio (que es el caso de Pages Functions): el navegador trata /api/contacto como same-origin del sitio Astro, sin preflight. Solo necesitas CORS explícito si el endpoint sirve a un dominio distinto (api.tudominio.com o consumidores third-party). En ese caso, agrega headers en la respuesta: 'Access-Control-Allow-Origin': 'https://tudominio.com' (nunca * con credenciales), 'Access-Control-Allow-Methods': 'POST', 'Access-Control-Allow-Headers': 'Content-Type, Idempotency-Key'. CSP del sitio Astro se configura en _headers de Pages (archivo en public/): Content-Security-Policy: default-src 'self'; connect-src 'self' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com. El widget de Turnstile necesita frame-src y script-src explícitos para https://challenges.cloudflare.com; sin ellos, el captcha no carga.

¿Qué pasa si necesito Node.js de verdad (lib que usa Buffer, fs, crypto.createHash)?

Tres opciones por orden de preferencia. Primera, buscar la alternativa Web Standards: Buffer.from(x, 'base64')atob(x), crypto.createHash('sha256')crypto.subtle.digest('SHA-256', encoder.encode(x)), fs.readFileSync → embed el contenido en el bundle con import o léelo de R2. El 90% de las libs que usan Node tienen contrapartes con Web APIs. Segunda, flag nodejs_compat: en wrangler.toml agregas compatibility_flags = ["nodejs_compat"] y Cloudflare provee polyfills de node:buffer, node:crypto, node:util. Funciona para la mayoría de los casos pero infla el bundle 30–80 KB. Tercera, migrar a Vercel Serverless o AWS Lambda si la dependencia es ineludible (SDKs corporativos legacy, librerías nativas). Para un form de contacto, jamás se llega al tercer escenario; Web Standards + Resend cubren todo.

¿Cuándo migrar a Workers Paid o Pages Paid?

El free tier de Pages cubre 100k requests/día a Functions y 100k operaciones/día por KV namespace. Para un sitio de servicios con 50 leads/día y 50 visitas que disparan rate-limit reads, estás usando 0.1% del cap. Migrar a Paid ($5/mes) tiene sentido cuando: (a) el sitio cruza 50k Function requests/día (no es lo mismo que visitas; las Functions solo corren al submit), (b) necesitas CPU time >10ms por request (envíos a APIs externas lentas, generación de PDFs), (c) quieres uptime SLA contractual (no solo best-effort), (d) usas Cloudflare Access para dashboards admin con SSO, que requiere Pages Paid en el bundle. Para sitios de servicios mexicanos del tamaño típico (5–20 leads/día), el free tier es suficiente por años. Cuando el sitio escale a e-commerce con 500+ orders/día, los $5/mes son trivial vs el ahorro de no operar VMs.

El backend serverless cierra el círculo del módulo: el componente accesible del frontend manda su POST a una Function que vive en el edge, valida server-side, controla volumen con KV y delega el envío a Resend. Cero servidor que mantener, cero VPS que actualizar, cero certificados SSL que renovar —todo lo paga Cloudflare en el free tier hasta que el sitio crezca lo suficiente para justificar pagar—. El upgrade del componente WhatsApp-only al híbrido WhatsApp+backend es una sola línea en el handler de submit del cliente: cambias el window.open del WhatsApp por un fetch al endpoint, y el resto del componente —labels, focus, honeypot, consentimiento, validación HTML5 es-MX— se mantiene idéntico. Esa es la ventaja real de tener el frontend bien hecho desde el día uno.

Sigue leyendo

¿Listo para dar el siguiente paso?

Cuéntanos qué necesitas y te respondemos hoy mismo.

¿Necesitas ayuda?