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

URL: https://codegea.com/formulario-de-contacto-en-next-js-con-api-route
Web · 2026-10-19 · Derian Pinto

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`:

```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:

```ts
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:

```ts
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:**

```ts
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:

```ts
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:

```ts
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](/cloudflare-delante-de-tu-web-firewall-y-ssl).
- **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:

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