Codegea

Desarrollo

Desarrollo de software a medidaSistemas, plataformas e integraciones hechas a la medida de tu operaciónSitios corporativosProfesional, rápido y pensado para generar confianzaTiendas onlineCatálogo, pagos y envíos listos para vender las 24 horasWooCommerceTiendas WooCommerce rápidas, con pagos y factura de cada paísLanding pagesUna página enfocada en un solo objetivo: convertir visitantes en clientesApps móvilesApps iOS y Android a medida que ponen tu negocio en el bolsillo de tus clientes

Integración y CMS

Integraciones CMSWordPress, Strapi, Directus y Arc XP conectados a todo tu ecosistemaArc XPSoporte, implementación e integraciones para la plataforma de mediosMigracionesMigra tu sitio, plataforma o base de datos sin perder nada

Crecimiento e infraestructura

Automatización con IALa tecnología hace lo repetitivo y tu equipo se enfoca en lo que importaSEO on-pageQue Google te encuentre y te recomiende: optimización técnica y de contenidoServidores y hostingServidores rápidos y seguros, monitoreados 24/7
RetainerTu equipo dev, por mesDesde 20h al mes. Cuota fija.
Un cotizador con IA para cotizar servicios en minutosCotizaciones profesionales en minutos, con seguimiento de proyectos, vendedores y pagos en un solo lugar.Ver el caso

Por tipo

EcommerceTiendas y catálogosSitios corporativosMarca y captaciónPlataformas y appsSoftware a medida

Explorar

Todos los proyectosCasos y resultadosClientesQuién confía en nosotrosRetainerEquipo dev mensual

Blog

Todos los artículosGuías y opiniónBoletínUn correo al mesContactoRespondemos en 24h

Últimos artículos

Así está construido codegea.com: Next.js, Payload, Turso y R2CMS · 2026-10-20Cloudflare Workers: qué son y cuándo conviene usarlosNode.js · 2026-10-19Live Preview en Payload con Next.jsCMS · 2026-10-19
BoletínÚnete a nuestro boletínNotas prácticas de web, SEO e IA.
ServiciosDesarrollo de software a medidaSitios corporativosTiendas onlineWooCommerceLanding pagesApps móvilesIntegraciones CMSArc XPMigracionesAutomatización con IASEO on-pageServidores y hostingTu equipo dev, por mes
ProyectosCotizaciones profesionales en minutos, con seguimiento de proyectos, vendedores y pagos en un solo lugar.EcommerceSitios corporativosPlataformas y appsTodos los proyectosClientesRetainer
Retainer
BlogTodos los artículosBoletínContactoAsí está construido codegea.com: Next.js, Payload, Turso y R2Cloudflare Workers: qué son y cuándo conviene usarlosLive Preview en Payload con Next.jsÚnete a nuestro boletín
ESEN
Hablemos

Web

Un formulario de contacto en Next.js con API route y envío de correo

Cómo recibir los mensajes de un formulario en Next.js con una API route en vez de Server Actions: validación en el servidor, honeypot contra bots y correo por SMTP.

Derian Pinto · Founder & Full Stack Developer2026-10-193 min

Un formulario de contacto parece sencillo hasta que llega el primer mensaje de spam, o el primero que no llega. Esto es lo que usamos en este sitio: un fetch desde el navegador, una API route que valida y guarda, y el envío de correo por SMTP. Sin Server Actions.

Por qué una API route y no una Server Action

En el proyecto tenemos una regla: toda mutación o consulta desde el cliente va por una API route, y el cliente llama con fetch. Las razones son prácticas:

  • Un endpoint se puede probar con curl, sin pasar por el navegador.
  • Es explícito: ves qué URL recibe qué, y qué responde. Es más fácil de proteger o de limitar.
  • La lógica vive en lib/, fuera de la ruta y del componente, y cualquier otro punto de entrada la puede reutilizar.

No es que las Server Actions estén mal. Es una decisión de equipo: preferimos una sola forma de hacer las cosas.

La ruta, mínima

En app/api/forms/route.ts:

import { submitForm } from '@/lib/forms';

export async function POST(req: Request) {
  let data: FormData;
  try {
    data = await req.formData();
  } catch {
    return Response.json({ status: 'error' }, { status: 400 });
  }
  const state = await submitForm(data);
  return Response.json(state, {
    status: state.status === 'error' ? (state.errors ? 422 : 500) : 200,
  });
}

Lee el FormData, llama a una función y traduce el resultado a un código HTTP: 422 si hay errores de campos, 500 si falló algo nuestro, 200 si todo salió bien. La ruta no sabe nada del formulario.

La lógica: valida en el servidor, no en el navegador

En lib/forms.ts, submitForm hace cuatro cosas:

1. Descarta bots con un honeypot. El formulario incluye un campo oculto, website. Una persona nunca lo ve ni lo rellena; un bot que llena todo, sí. Si llega con valor, respondemos «éxito» sin guardar nada:

if (data.get('website')) return { status: 'success', message: copy.successFallback };

Responder éxito es a propósito: no le damos al bot una pista de que fue detectado.

2. Valida contra la definición, no contra lo que manda el navegador. El formulario está definido en el panel (con el plugin Form Builder de Payload). El servidor recarga esa definición y comprueba cada campo contra ella:

const form = await getForm(Number(data.get('_form')), locale);

for (const field of form.fields ?? []) {
  const value = String(data.get(field.name) ?? '').trim();
  if (!value) {
    if ('required' in field && field.required) errors[field.name] = copy.required;
    continue;
  }
  if (field.blockType === 'email' && !EMAIL.test(value)) {
    errors[field.name] = copy.invalidEmail;
  } else if (value.length > MAX_LENGTH) {
    errors[field.name] = copy.required;
  }
  // …
}

Así, aunque alguien manipule el HTML, el servidor aplica las reglas reales. Los campos que no existen en la definición se ignoran, y hay un tope de longitud.

3. Guarda el envío:

await payload.create({
  collection: 'form-submissions',
  data: { form: form.id, submissionData },
});

4. Devuelve un mensaje. Usa el texto de confirmación que el equipo escribió en el panel; si no hay, uno por defecto del diccionario de cada idioma.

El correo

El correo lo envía el plugin de formularios al guardar el envío, a la dirección que configures. Para que salga de verdad, Payload necesita un adaptador de correo:

email: process.env.SMTP_HOST
  ? nodemailerAdapter({
      defaultFromAddress: process.env.EMAIL_FROM ?? 'no-reply@codegea.com',
      defaultFromName: 'Codegea',
      transportOptions: {
        host: process.env.SMTP_HOST,
        port: Number(process.env.SMTP_PORT ?? 587),
        // …usuario y contraseña
      },
    })
  : undefined,

Con SMTP_HOST vacío, Payload escribe el correo en la consola, algo cómodo en desarrollo. En local usamos Mailpit, que recibe los correos en una bandeja web sin enviar nada. En producción, cualquier proveedor SMTP; por ejemplo, Resend en smtp.resend.com. Los destinatarios y el remitente salen de variables de entorno, no del código.

El cliente

El componente solo envía y muestra el resultado:

const res = await fetch('/api/forms', { method: 'POST', body: new FormData(form) });
const next = (await res.json()) as FormState;
setState(next);
if (next.status === 'success') form.reset();

Los errores por campo llegan en next.errors, y se muestran junto a cada campo, con la etiqueta visible. La respuesta tiene la misma forma en los tres casos (éxito, error de campos, error general), así que el componente es corto.

Lo que esta versión no hace

  • No limita la velocidad de envíos. Un honeypot detiene a los bots simples, no a un ataque insistente. Si te llega spam, el siguiente paso es limitar por IP, ya sea en tu aplicación o con reglas de Cloudflare como las de Cloudflare delante de tu web.
  • No incluye captcha. Es una decisión: los captchas cuestan conversiones. Los añadiríamos solo si el spam lo justifica.

Prueba rápida

Sin abrir el navegador:

curl -X POST http://localhost:4000/api/forms \
  -F "_form=1" -F "_locale=es" \
  -F "email=prueba@ejemplo.com" -F "message=Hola"

Debe devolver JSON con status. Y con -F "website=x" debe responder éxito sin guardar nada.

¿Compartir este artículo?LinkedInWhatsAppX

Escrito por

Derian Pinto

Founder & Full Stack Developer

Fundador de Codegea. Desarrolla sitios, tiendas y plataformas a medida, e integra y da soporte a Arc XP para medios.

Únete a nuestro boletín

Un correo con novedades y notas prácticas de web, SEO e IA. Sin spam, y te das de baja cuando quieras.

¿Montamos tu proyecto?

¿Necesitas ayuda? Chatea con nosotros