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.
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.