Del formulario decorativo al formulario que entrega mensajes
Casi cualquier sitio web profesional necesita una forma de que los visitantes se pongan en contacto: un portafolio, la web de una agencia, la página de un producto o un blog personal. Sin embargo, muchos formularios de contacto son decorativos. Tienen campos bonitos y un botón de enviar, pero los mensajes nunca llegan a nadie, acaban en la carpeta de spam o se pierden porque el servidor de correo rechazó el envío.
Construir un formulario que funcione de verdad exige resolver varias piezas a la vez. Hay que capturar los datos en el navegador, validarlos, enviarlos a un servidor que no exponga credenciales, entregarlos a un servicio de correo transaccional fiable y responder al usuario con un mensaje claro. Además, hay que protegerse del spam automatizado y configurar correctamente el dominio para que los correos no se marquen como sospechosos.
En este artículo vamos a construir un formulario de contacto en Next.js que envía correos a través de SendGrid, el servicio de correo de Twilio. Usaremos el App Router de Next.js 16, cuya versión más reciente es la 16.4, Server Actions con el hook useActionState de React 19, validación con Zod y Tailwind CSS para los estilos. También veremos cómo autenticar el dominio, cómo frenar el spam, cómo desplegar en Vercel y qué alternativas a SendGrid existen hoy.
Una advertencia importante antes de empezar: SendGrid retiró su plan gratuito permanente a mediados de 2025, así que para enviar correos con este servicio hoy necesitas un plan de pago. El diseño que vamos a construir aísla el envío en una función, de modo que cambiar de proveedor sea trivial si prefieres otra opción.
Arquitectura de la solución
Antes de escribir código conviene tener claro el recorrido de un mensaje:
- El usuario rellena el formulario con su nombre, correo, asunto y mensaje.
- Al enviarlo, el navegador invoca una Server Action, una función que se ejecuta en el servidor.
- La Server Action valida los datos, comprueba las medidas antispam y llama al servicio de correo.
- SendGrid entrega el mensaje en tu bandeja de entrada.
- La Server Action devuelve un resultado y la interfaz muestra éxito o error.
El punto clave de esta arquitectura es que la clave de API de SendGrid nunca sale del servidor. El navegador solo conoce la acción, no las credenciales.
La estructura del proyecto será la siguiente:
contacto-web/
├── .env.local
├── app/
│ ├── layout.tsx
│ ├── page.tsx
│ └── contacto/
│ ├── page.tsx
│ ├── acciones.ts
│ └── FormularioContacto.tsx
└── lib/
├── correo.ts
└── esquema-contacto.ts
Crear el proyecto
Generamos una aplicación nueva con la herramienta oficial. Las opciones por defecto del asistente incluyen TypeScript, ESLint, Tailwind CSS y el App Router, que es exactamente lo que necesitamos:
npx create-next-app@latest contacto-web
cd contacto-web
npm install @sendgrid/mail zod
npm run dev
▲ Next.js 16.4.0 (Turbopack)
- Local: http://localhost:3000
✓ Ready in 1.1s
El paquete @sendgrid/mail, cuya rama mayor actual es la 8, es el cliente oficial de SendGrid para Node.js. Zod, por su parte, nos permitirá definir las reglas de validación una sola vez y reutilizarlas.
Los tutoriales más antiguos sobre este tema usan el Pages Router, con un archivo en pages/api y la configuración de Tailwind en tailwind.config.js con opciones como purge o el modo jit. Con el App Router y Tailwind CSS 4, que genera los estilos bajo demanda y se configura directamente desde el CSS, ninguna de esas opciones es necesaria.
Preparar SendGrid
Para enviar correos con SendGrid hay que completar tres pasos en su panel.
El primero es crear una cuenta y elegir un plan. Como hemos comentado, el plan gratuito permanente ya no existe, así que revisa los planes de pago disponibles y sus límites de envío.
El segundo es verificar quién envía los correos. SendGrid no permite enviar desde una dirección cualquiera, porque eso facilitaría la suplantación. Tienes dos opciones: verificar una dirección individual mediante la verificación de remitente único, suficiente para pruebas, o autenticar tu dominio completo, que es lo recomendable en producción y que veremos con detalle más adelante.
El tercero es crear una clave de API. En la sección de configuración, genera una clave con permisos restringidos limitados al envío de correo. Aplicar el principio de mínimo privilegio significa que, si la clave se filtrara, el daño estaría acotado. SendGrid solo muestra la clave una vez, así que cópiala en ese momento.
Guarda los valores en el archivo .env.local, que Next.js carga automáticamente y que nunca debe subirse al repositorio:
# .env.local
SENDGRID_API_KEY="SG.xxxxxxxxxxxxxxxxxxxxxxxx"
CONTACTO_DESTINO="[email protected]"
CONTACTO_REMITENTE="[email protected]"
Ninguna de estas variables lleva el prefijo NEXT_PUBLIC_, de modo que solo están disponibles en el servidor. Comprueba también que .env.local aparece en tu .gitignore; la plantilla de Next.js lo incluye por defecto.
Definir el esquema de validación
Toda la información que llega de un formulario debe tratarse como no confiable. Definimos con Zod las reglas que deben cumplir los datos, en un archivo que podremos usar tanto en el servidor como, si queremos, en el cliente:
// lib/esquema-contacto.ts
import { z } from "zod";
export const esquemaContacto = z.object({
nombre: z
.string()
.trim()
.min(2, "Escribe tu nombre")
.max(100, "El nombre es demasiado largo"),
correo: z.email("Introduce un correo válido").max(200),
asunto: z
.string()
.trim()
.min(3, "El asunto es demasiado corto")
.max(150, "El asunto es demasiado largo"),
mensaje: z
.string()
.trim()
.min(10, "Cuéntanos un poco más")
.max(5000, "El mensaje no puede superar los 5000 caracteres"),
});
export type DatosContacto = z.infer<typeof esquemaContacto>;
La validación de formularios con Zod tiene varias ventajas. Las reglas se declaran en un solo lugar, los mensajes de error quedan junto a cada campo y el tipo DatosContacto se deduce automáticamente, de modo que TypeScript conoce la forma exacta de los datos validados. Los límites de longitud no son un capricho: impiden que alguien envíe mensajes gigantescos que saturen tu bandeja o tu cuota de envío.
Encapsular el envío de correo
Aislamos toda la interacción con SendGrid en un módulo propio. Así, el resto de la aplicación no depende directamente del proveedor y cambiarlo en el futuro solo afecta a este archivo:
// lib/correo.ts
import "server-only";
import sgMail from "@sendgrid/mail";
import type { DatosContacto } from "./esquema-contacto";
sgMail.setApiKey(process.env.SENDGRID_API_KEY!);
function escaparHtml(texto: string): string {
return texto
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
export async function enviarMensajeContacto(datos: DatosContacto) {
const { nombre, correo, asunto, mensaje } = datos;
await sgMail.send({
to: process.env.CONTACTO_DESTINO!,
from: {
email: process.env.CONTACTO_REMITENTE!,
name: "Formulario web",
},
replyTo: { email: correo, name: nombre },
subject: `[Contacto] ${asunto}`,
text: `De: ${nombre} <${correo}>\n\n${mensaje}`,
html: `
<p><strong>De:</strong> ${escaparHtml(nombre)} (${escaparHtml(correo)})</p>
<p><strong>Asunto:</strong> ${escaparHtml(asunto)}</p>
<hr>
<p style="white-space: pre-wrap">${escaparHtml(mensaje)}</p>
`,
});
}
Hay varias decisiones importantes en este código.
La importación de server-only provoca un error de compilación si alguien importa este módulo desde un componente de cliente por accidente, una protección extra para que la clave de API nunca acabe en el navegador. Si no está disponible en tu proyecto, se instala con npm install server-only.
El remitente, from, es siempre tu propia dirección verificada, nunca el correo del visitante. Enviar en nombre de una dirección que no controlas haría que el mensaje fallara las comprobaciones de autenticación y acabara en spam o rechazado. Para poder responder cómodamente al visitante, su dirección se coloca en replyTo: al pulsar responder en tu cliente de correo, la respuesta irá directamente a él.
El contenido del usuario se escapa antes de insertarlo en el HTML. Sin esa precaución, alguien podría inyectar etiquetas o enlaces maliciosos en el correo que recibes. Incluir además una versión en texto plano mejora la entrega y la accesibilidad.
La Server Action
La Server Action es el puente entre el formulario y el servicio de correo. Recibe los datos del formulario, los valida y devuelve un estado que la interfaz sabrá interpretar:
// app/contacto/acciones.ts
"use server";
import { esquemaContacto } from "@/lib/esquema-contacto";
import { enviarMensajeContacto } from "@/lib/correo";
export type EstadoContacto = {
estado: "inicial" | "ok" | "error";
mensaje?: string;
errores?: Partial<
Record<"nombre" | "correo" | "asunto" | "mensaje", string[]>
>;
};
export async function enviarContacto(
_anterior: EstadoContacto,
formData: FormData
): Promise<EstadoContacto> {
// Campo trampa: los humanos no lo ven, los bots suelen rellenarlo
if (formData.get("sitio_web")) {
return { estado: "ok", mensaje: "¡Gracias! Te responderemos pronto." };
}
const resultado = esquemaContacto.safeParse({
nombre: formData.get("nombre"),
correo: formData.get("correo"),
asunto: formData.get("asunto"),
mensaje: formData.get("mensaje"),
});
if (!resultado.success) {
return {
estado: "error",
mensaje: "Revisa los campos marcados.",
errores: resultado.error.flatten().fieldErrors,
};
}
try {
await enviarMensajeContacto(resultado.data);
return { estado: "ok", mensaje: "¡Gracias! Te responderemos pronto." };
} catch (error) {
console.error("Error al enviar el formulario de contacto", error);
return {
estado: "error",
mensaje:
"No hemos podido enviar tu mensaje. Inténtalo de nuevo en unos minutos.",
};
}
}
La firma de la función, con el estado anterior como primer argumento y el FormData como segundo, es la que espera el hook useActionState. Usamos safeParse en lugar de parse para obtener un resultado en lugar de una excepción, y flatten para convertir los errores de Zod en un objeto con una lista de mensajes por campo.
Observa también que el mensaje de error que ve el usuario es genérico. Los detalles técnicos se registran en el servidor con console.error, pero nunca se devuelven al navegador, porque podrían revelar información sobre la configuración interna.
El componente del formulario
El formulario es un componente de cliente que usa useActionState para conectar con la Server Action y conocer su estado:
// app/contacto/FormularioContacto.tsx
"use client";
import { useActionState } from "react";
import { enviarContacto, type EstadoContacto } from "./acciones";
const estadoInicial: EstadoContacto = { estado: "inicial" };
function Campo({
id,
etiqueta,
errores,
...props
}: {
id: string;
etiqueta: string;
errores?: string[];
} & React.InputHTMLAttributes<HTMLInputElement>) {
return (
<div className="flex flex-col gap-1">
<label htmlFor={id} className="font-medium">
{etiqueta}
</label>
<input
id={id}
name={id}
aria-invalid={errores ? true : undefined}
aria-describedby={errores ? `${id}-error` : undefined}
className="rounded-md border border-neutral-300 px-3 py-2 aria-[invalid=true]:border-red-500"
{...props}
/>
{errores && (
<p id={`${id}-error`} className="text-sm text-red-600">
{errores[0]}
</p>
)}
</div>
);
}
export default function FormularioContacto() {
const [estado, accion, pendiente] = useActionState(
enviarContacto,
estadoInicial
);
if (estado.estado === "ok") {
return (
<p
role="status"
className="rounded-md bg-green-50 p-4 text-green-800"
>
{estado.mensaje}
</p>
);
}
return (
<form action={accion} className="grid gap-4" noValidate>
<Campo
id="nombre"
etiqueta="Nombre"
autoComplete="name"
required
errores={estado.errores?.nombre}
/>
<Campo
id="correo"
etiqueta="Correo electrónico"
type="email"
autoComplete="email"
required
errores={estado.errores?.correo}
/>
<Campo
id="asunto"
etiqueta="Asunto"
required
errores={estado.errores?.asunto}
/>
<div className="flex flex-col gap-1">
<label htmlFor="mensaje" className="font-medium">
Mensaje
</label>
<textarea
id="mensaje"
name="mensaje"
rows={6}
required
aria-invalid={estado.errores?.mensaje ? true : undefined}
className="rounded-md border border-neutral-300 px-3 py-2"
/>
{estado.errores?.mensaje && (
<p className="text-sm text-red-600">
{estado.errores.mensaje[0]}
</p>
)}
</div>
{/* Campo trampa invisible para personas */}
<div aria-hidden="true" className="absolute -left-[9999px]">
<label htmlFor="sitio_web">No rellenes este campo</label>
<input
id="sitio_web"
name="sitio_web"
tabIndex={-1}
autoComplete="off"
/>
</div>
{estado.estado === "error" && (
<p role="alert" className="text-red-700">
{estado.mensaje}
</p>
)}
<button
type="submit"
disabled={pendiente}
className="rounded-md bg-neutral-900 px-4 py-2 font-medium text-white disabled:opacity-60"
>
{pendiente ? "Enviando…" : "Enviar mensaje"}
</button>
</form>
);
}
// app/contacto/page.tsx
import FormularioContacto from "./FormularioContacto";
export const metadata = { title: "Contacto" };
export default function PaginaContacto() {
return (
<main className="mx-auto max-w-xl p-6">
<h1 className="mb-6 text-3xl font-semibold">Hablemos</h1>
<FormularioContacto />
</main>
);
}
El hook useActionState devuelve tres valores: el estado actual que devolvió la acción, una función que se pasa directamente al atributo action del formulario y un indicador de si la acción está en curso. Con ese indicador desactivamos el botón y cambiamos su texto, evitando que el usuario envíe el mismo mensaje varias veces.
Una ventaja notable de este enfoque es la mejora progresiva: como el formulario usa el atributo action con una Server Action, puede enviarse incluso antes de que el JavaScript de la página termine de cargar.
Los atributos aria-invalid, aria-describedby, role="status" y role="alert" hacen que los errores y las confirmaciones se anuncien correctamente a los usuarios de lectores de pantalla, y los atributos autoComplete permiten que el navegador rellene los datos automáticamente.
Protección contra el spam
Un formulario de contacto público es uno de los primeros objetivos de los bots de spam. Sin protección, en pocas semanas puedes recibir cientos de mensajes basura que, además, consumen tu cuota de envío. La estrategia más eficaz combina varias capas.
La primera capa es el campo trampa que ya hemos incluido. La protección antispam con honeypot consiste en añadir un campo invisible para las personas pero presente en el HTML. Muchos bots rellenan todos los campos que encuentran, así que si ese campo llega con contenido, el envío se descarta silenciosamente. Respondemos con un mensaje de éxito para no dar pistas al bot.
La segunda capa es la limitación de frecuencia. Una misma dirección IP no debería poder enviar decenas de mensajes por minuto. En aplicaciones desplegadas en entornos sin servidor, la memoria no se comparte entre instancias, por lo que conviene usar un almacén externo como Redis o una base de datos. Un esquema básico sería este:
// lib/limite.ts
import "server-only";
import { headers } from "next/headers";
const VENTANA_MS = 10 * 60 * 1000;
const MAXIMO = 3;
const intentos = new Map<string, number[]>(); // en producción, usa un almacén compartido
export async function permitirEnvio(): Promise<boolean> {
const cabeceras = await headers();
const ip =
cabeceras.get("x-forwarded-for")?.split(",")[0]?.trim() ??
"desconocida";
const ahora = Date.now();
const recientes = (intentos.get(ip) ?? []).filter(
(t) => ahora - t < VENTANA_MS
);
if (recientes.length >= MAXIMO) return false;
intentos.set(ip, [...recientes, ahora]);
return true;
}
En la Server Action, basta con llamar a permitirEnvio() antes de validar y devolver un error si se ha superado el límite. Observa que en las versiones actuales de Next.js la función headers() es asíncrona y debe esperarse con await.
La tercera capa es un desafío anti bots. Servicios como Cloudflare Turnstile, hCaptcha o reCAPTCHA verifican que el visitante es humano, en muchos casos sin pedirle que resuelva ningún rompecabezas. Se integran con un componente en el formulario y una verificación del token en la Server Action. Para un formulario de contacto pequeño, el campo trampa y la limitación de frecuencia suelen ser suficientes; si el spam persiste, añadir un desafío es el siguiente paso.
Autenticación del dominio: que tus correos no acaben en spam
Que SendGrid acepte el correo no garantiza que llegue a la bandeja de entrada. Los proveedores de correo como Gmail o Outlook comprueban si el remitente está autorizado a enviar en nombre de su dominio, y desde 2024 los grandes proveedores exigen estas comprobaciones de forma estricta a los remitentes que envían volúmenes significativos.
La autenticación de dominio con SPF, DKIM y DMARC es la forma de demostrarlo:
- SPF indica qué servidores pueden enviar correo en nombre de tu dominio.
- DKIM firma criptográficamente cada mensaje, de modo que el destinatario puede comprobar que no se ha modificado y que procede de tu dominio.
- DMARC indica a los receptores qué hacer con los mensajes que fallan las comprobaciones anteriores y permite recibir informes.
En el panel de SendGrid, la opción de autenticación de dominio genera los registros DNS que debes añadir en tu proveedor de dominio. Son registros CNAME que apuntan a SendGrid, con un aspecto similar a este:
em1234.miestudio.dev. IN CNAME u1234567.wl123.sendgrid.net.
s1._domainkey.miestudio.dev. IN CNAME s1.domainkey.u1234567.wl123.sendgrid.net.
s2._domainkey.miestudio.dev. IN CNAME s2.domainkey.u1234567.wl123.sendgrid.net.
_dmarc.miestudio.dev. IN TXT "v=DMARC1; p=none; rua=mailto:[email protected]"
Los valores exactos los proporciona SendGrid para tu cuenta. Una vez añadidos, puedes comprobar que se han propagado con dig:
dig +short s1._domainkey.miestudio.dev CNAME
dig +short _dmarc.miestudio.dev TXT
Empezar con una política DMARC p=none permite observar los informes sin afectar a la entrega; cuando todo esté correcto, conviene endurecerla a quarantine o reject para proteger tu dominio frente a la suplantación.
Desplegar en Vercel
Con todo funcionando en local, el despliegue en Vercel es directo. Sube el proyecto a un repositorio de GitHub, impórtalo desde el panel de Vercel y añade las variables de entorno en la configuración del proyecto. También puedes hacerlo desde la línea de comandos:
npm install -g vercel
vercel link
vercel env add SENDGRID_API_KEY production
vercel env add CONTACTO_DESTINO production
vercel env add CONTACTO_REMITENTE production
vercel --prod
Las variables de entorno se leen durante la compilación y el arranque de la aplicación, por lo que, si las añades o modificas después de un despliegue, necesitas volver a desplegar para que surtan efecto. Es un olvido frecuente que provoca errores de autenticación aparentemente inexplicables.
Una buena práctica es usar claves de API distintas para los entornos de desarrollo, vista previa y producción. Así puedes revocar una sin afectar a las demás y limitar el impacto de cualquier filtración.
Probar el envío sin abrir el navegador
Antes de depender de la interfaz, es útil verificar que las credenciales y el remitente funcionan con una petición directa a la API de SendGrid:
curl --request POST \
--url https://api.sendgrid.com/v3/mail/send \
--header "Authorization: Bearer $SENDGRID_API_KEY" \
--header "Content-Type: application/json" \
--data '{
"personalizations": [{ "to": [{ "email": "[email protected]" }] }],
"from": { "email": "[email protected]" },
"subject": "Prueba de envío",
"content": [{ "type": "text/plain", "value": "Funciona." }]
}' -i
HTTP/2 202
x-message-id: Lp2xQe9vRYm4JkSh7tBz1w
Un código 202 significa que SendGrid ha aceptado el mensaje para su entrega. A partir de ahí, el registro de actividad del panel muestra si el correo se entregó, rebotó o se marcó como spam.
Para las pruebas automatizadas, lo recomendable es no enviar correos reales. Al haber encapsulado el envío en lib/correo.ts, es sencillo sustituirlo por una versión simulada en los tests y comprobar únicamente la lógica de validación y de la acción.
Errores comunes y cómo resolverlos
Al integrar el envío de correo aparecen siempre los mismos problemas.
El más frecuente es el error de remitente no verificado, que SendGrid devuelve con un código 403:
ResponseError: Forbidden
code: 403,
response: {
body: { errors: [ { message: 'The from address does not match a verified Sender Identity.' } ] }
}
La dirección del campo from debe coincidir con un remitente verificado o pertenecer a un dominio autenticado. Recuerda que nunca debe ser el correo del visitante.
El segundo es el error 401, que indica que la clave de API no es válida, ha sido revocada o no tiene permisos de envío. Comprueba que la variable de entorno está definida en el entorno correcto, que no tiene espacios ni comillas extra y que has vuelto a desplegar tras configurarla.
El tercero es que la variable de entorno sea undefined en producción. Ocurre cuando se configura solo en .env.local, que no se sube al repositorio. Hay que añadirla también en la plataforma de despliegue.
El cuarto es que el correo llegue, pero a la carpeta de spam. Casi siempre se debe a un dominio sin autenticar. Configura SPF, DKIM y DMARC, evita asuntos y contenidos con apariencia promocional y envía desde una dirección de tu propio dominio.
El quinto es que la cuenta tenga los envíos pausados. Tras la retirada del plan gratuito, las cuentas que no actualizaron su plan tienen el envío desactivado. Si tu formulario dejó de funcionar sin cambios en el código, revisa el estado de tu cuenta en el panel.
El sexto es recibir mensajes duplicados porque el usuario pulsa varias veces el botón. Desactivar el botón mientras la acción está pendiente, como hicimos con el valor pendiente de useActionState, resuelve el problema.
Alternativas a SendGrid
Con la desaparición de su plan gratuito, muchos desarrolladores han reconsiderado la elección de proveedor. Gracias a que el envío está aislado en un único módulo, cambiar de servicio solo requiere reescribir la función enviarMensajeContacto.
Resend se ha popularizado entre los desarrolladores de Next.js por su API sencilla y su integración con React Email, que permite diseñar las plantillas de correo como componentes de React. Postmark destaca por su enfoque exclusivo en correo transaccional y su alta tasa de entrega. Amazon SES ofrece precios muy bajos a gran escala, a cambio de una configuración algo más compleja. Mailgun y Brevo son otras opciones consolidadas con APIs similares.
Si ya dispones de un servidor de correo propio o de una cuenta de correo corporativa con acceso SMTP, Nodemailer permite enviar sin depender de ningún servicio de API:
import nodemailer from "nodemailer";
const transporte = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: 587,
auth: { user: process.env.SMTP_USUARIO, pass: process.env.SMTP_CLAVE },
});
await transporte.sendMail({
from: '"Formulario web" <[email protected]>',
to: "[email protected]",
replyTo: correoVisitante,
subject: `[Contacto] ${asunto}`,
text: mensaje,
});
Antes de elegir, compara los límites de envío, el precio a tu volumen esperado, la facilidad de autenticar el dominio y la calidad de los registros de entrega, que son fundamentales para diagnosticar problemas.
Conclusiones
Un formulario de contacto que funciona de verdad es más que unos cuantos campos y un botón. Requiere capturar los datos de forma accesible, validarlos en el servidor, enviarlos sin exponer credenciales, entregarlos con garantías y responder al usuario con claridad.
Con Next.js 16 y el App Router, las Server Actions simplifican enormemente esta tarea: el formulario llama directamente a una función que se ejecuta en el servidor, y el hook useActionState de React 19 gestiona el estado de envío, los errores y la confirmación con muy poco código. Zod centraliza las reglas de validación, y encapsular el envío en un módulo propio, protegido con server-only, mantiene la clave de API lejos del navegador y facilita cambiar de proveedor.
SendGrid sigue siendo una opción sólida para el correo transaccional, aunque la retirada de su plan gratuito obliga hoy a contratar un plan de pago o valorar alternativas como Resend, Postmark, Amazon SES o un servidor SMTP propio. Sea cual sea el proveedor, el remitente debe ser siempre una dirección de tu dominio, el visitante debe ir en el campo de respuesta y el dominio debe estar autenticado con SPF, DKIM y DMARC para evitar la carpeta de spam.
Por último, no subestimes el spam: un campo trampa, una limitación de frecuencia y, si es necesario, un desafío anti bots mantendrán tu bandeja limpia y tu cuota de envío bajo control. Con estas piezas, tu formulario dejará de ser decorativo y se convertirá en un canal de comunicación fiable con tus visitantes.
