Envía un formulario a tu email7 min de lectura
Un formulario de contacto en Next.js que envía emails
Dos formas de enviar por email un formulario de Next.js con una Server Action: enviarlo tú con Resend o Nodemailer, o reenviarlo a un backend de formularios, comparadas con honestidad.
En el App Router, una Server Action recibe el FormData del formulario en el servidor, donde una clave de API de email está a salvo. Puedes enviar el email tú mismo con Resend o Nodemailer, o reenviar el envío a un backend de formularios que filtra el spam, lo guarda y te manda un email. Enviarlo tú te da control total del email; reenviarlo te ahorra el filtro antispam, los límites de frecuencia y el almacenamiento que, si no, tendrías que construir.
Qué cambia aquí
Next.js ya tiene un servidor, así que el motivo habitual para un backend de formularios, "mi sitio no puede ejecutar código", no aplica. La pregunta es qué tiene que hacer ese código. Enviar el email es la parte fácil. Lo que lleva tiempo es todo lo que hay alrededor:
- Enviarlo tú: la action valida los campos, comprueba el honeypot y llama a tu proveedor de email con una clave de API. El mensaje solo existe como ese email.
- Reenviarlo: la action envía los campos a Ikiform con tu clave secreta. Ikiform valida con la definición del formulario, puntúa el spam, guarda el envío y manda un email a los destinatarios.
| Tarea | Server Action con Resend o Nodemailer | Server Action con Ikiform |
|---|---|---|
| Validación | Zod en la action | La definición de campos del formulario, aplicada en Ikiform |
| Spam | Tu honeypot; cualquier otra cosa la añades tú | Honeypot, puntuación de spam y una verificación humana opcional |
| Límites de frecuencia | Ninguno hasta que añadas un almacén como Redis | Incluidos, por visitante y por formulario |
| Dominio de envío | Verifica el tuyo con registros SPF y DKIM | Ninguno: el correo sale de notifications@ikiform.com |
| Asunto y diseño | Lo que quieras | Fijo: New submission: <form name> |
| Confirmación al visitante | Una llamada más a send | No disponible; envíala desde tu herramienta de email |
| Una copia de cada mensaje | Solo si añades una base de datos | Panel, exportación CSV y API |
Elige la versión hecha por ti para un email con tu marca o una confirmación al visitante, si te parece bien encargarte del spam y del almacenamiento. Elige el reenvío para olvidarte del formulario una vez que funcione.
Las dos versiones
El formulario es una consulta de proyecto: nombre, email de trabajo, empresa, un rango de presupuesto y una descripción. El tipo del estado y el componente de cliente son compartidos; solo cambia actions.ts.
export interface InquiryState {
sent: boolean;
errors: Partial<Record<"name" | "email" | "company" | "budget" | "message" | "form", string>>;
}
export const initialInquiry: InquiryState = { sent: false, errors: {} };"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { sendInquiry } from "./actions";
import { initialInquiry } from "./state";
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "Sending" : "Send inquiry"}</button>;
}
export function InquiryForm() {
const [state, action] = useActionState(sendInquiry, initialInquiry);
if (state.sent) return <p role="status">Thanks. We reply to every inquiry within two working days.</p>;
return (
<form action={action}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{state.errors.name ? <p role="alert">{state.errors.name}</p> : null}
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{state.errors.email ? <p role="alert">{state.errors.email}</p> : null}
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" />
<label htmlFor="budget">Budget</label>
<select id="budget" name="budget" required defaultValue="">
<option value="" disabled>Choose a range</option>
<option value="under-5k">Under $5k</option>
<option value="5k-20k">$5k to $20k</option>
<option value="over-20k">Over $20k</option>
</select>
{state.errors.budget ? <p role="alert">{state.errors.budget}</p> : null}
<label htmlFor="message">About the project</label>
<textarea id="message" name="message" rows={6} required minLength={20} />
{state.errors.message ? <p role="alert">{state.errors.message}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.errors.form ? <p role="alert">{state.errors.form}</p> : null}
<SubmitButton />
</form>
);
}"use server";
import { Resend } from "resend";
import { z } from "zod";
import type { InquiryState } from "./state";
const resend = new Resend(process.env.RESEND_API_KEY);
const schema = z.object({
name: z.string().trim().min(1, "Tell us your name.").max(120),
email: z.email("Enter an address we can reply to."),
company: z.string().trim().max(120).optional(),
budget: z.enum(["under-5k", "5k-20k", "over-20k"], "Pick a budget range."),
message: z.string().trim().min(20, "A few sentences help us reply properly.").max(5000),
});
export async function sendInquiry(_previous: InquiryState, formData: FormData): Promise<InquiryState> {
if (formData.get("_gotcha")) return { sent: true, errors: {} };
const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
const fields = z.flattenError(parsed.error).fieldErrors;
return {
sent: false,
errors: Object.fromEntries(Object.entries(fields).map(([key, messages]) => [key, messages?.[0]])),
};
}
const inquiry = parsed.data;
const { error } = await resend.emails.send({
from: "Website <inquiries@yourdomain.com>",
to: ["you@yourdomain.com"],
replyTo: inquiry.email,
subject: `Project inquiry from ${inquiry.name}`,
text: [
`Name: ${inquiry.name}`,
`Email: ${inquiry.email}`,
`Company: ${inquiry.company ?? "-"}`,
`Budget: ${inquiry.budget}`,
"",
inquiry.message,
].join("\n"),
});
if (error) return { sent: false, errors: { form: "Couldn't send your inquiry. Try again." } };
return { sent: true, errors: {} };
}- El tipo del estado vive en su propio archivo porque un archivo
"use server"solo puede exportar funciones async. useFormStatuslee el estado pendiente del formulario en el que está, así que el botón va en su propio componente.- En la versión con Resend, un honeypot relleno devuelve éxito sin enviar nada, así el bot no aprende nada. Ikiform hace lo mismo cuando guarda el envío como spam.
replyToen la versión con Resend, y el campo llamadoemailen la versión con Ikiform, hacen que Reply responda al visitante.- Nodemailer funciona igual que Resend: crea un transport con tus datos SMTP y llama a
sendMailconfrom,to,replyTo,subjectytext. - Para la versión con Ikiform, define los cinco campos en la pestaña Fields del formulario, con
budgetcomoselect, para que un valor incorrecto reciba422 validation_failedcon un mensaje por campo.
IKIFORM_SECRET_KEY en variables de entorno solo del servidor. Cada ejemplo de servidor reenvía la cabecera Origin del navegador cuando la hay, porque el fetch del propio servidor no envía ninguna e Ikiform cuenta con ella los envíos de prueba gratuitos desde localhost.Hay una tercera opción: prescindir de la action y dejar que el navegador envíe directamente a Ikiform. No interviene ninguna clave secreta, así que el límite de frecuencia Standard y Allowed domains se aplican por visitante. La guía de Next.js de la documentación muestra el componente de cliente.
Comprueba que funciona
- Envía con el presupuesto vacío. El error aparece debajo del select y no se pierde nada más.
- Envía una consulta real. La línea de éxito sustituye al formulario.
- Abre el email y pulsa Reply. La línea To es la dirección que escribiste.
- Rellena el honeypot oculto con las herramientas de desarrollo del navegador y envía. Ves el éxito, y no llega ningún email.
- Abre Submissions en el panel. El nuevo envío está ahí con todos los campos que mandaste. Este paso solo aplica a la versión con Ikiform.
Detalles a tener en cuenta
- Escapado en emails HTML. La versión con Resend envía
text. Si cambias ahtml, escapa antes cada valor, o un visitante podrá meter markup y enlaces en tu bandeja. - Inyección de cabeceras. Nunca pongas un valor del formulario sin procesar en
fromoto.replyToes seguro con Resend porque Zod ya ha comprobado que es un email. - Variables de entorno.
RESEND_API_KEYeIKIFORM_SECRET_KEYno deben empezar porNEXT_PUBLIC_, o Next.js las enviará al navegador. - Envíos duplicados. Un doble clic puede ejecutar la action dos veces. El estado pendiente desactiva el botón, pero con una red lenta aún puede haber carrera; la opción
idempotencyKeyde Resend, o la cabeceraIdempotency-Keyde Ikiform, hace que una repetición no tenga efecto. - Las Server Actions son endpoints públicos. Cualquiera puede llamar a una directamente, por eso la validación y las comprobaciones antispam deben ejecutarse dentro de ella, no solo en el formulario.
Si algo sale mal
| Problema | Qué hacer |
|---|---|
| Resend responde que el dominio no está verificado | Añade los registros DNS de Resend para el dominio de from, o envía desde la dirección de prueba de Resend mientras desarrollas. |
429 rate_limited | Un visitante envió más de lo que permite el límite de frecuencia del formulario. Standard permite 5 por minuto por visitante. Espera a Retry-After, o elige Relaxed o Custom para formularios que envían desde una IP compartida. |
422 validation_failed | El envío no coincide con la definición de campos del formulario. error.fields indica cada campo y el motivo. Haz que el name del input coincida con el id del campo. |
| La action funciona en local pero no en producción | Falta la clave en las variables de entorno del despliegue. Añádela al entorno de producción y vuelve a desplegar. |
Preguntas
Sí, en el runtime de Node.js, que es el que usan las Server Actions por defecto. Abre una conexión SMTP, así que no funciona en el runtime Edge, y algunos hostings limitan el SMTP saliente. Una API de email por HTTP como Resend evita ambas cosas.
Gestiona el formulario con una Server Action, que se ejecuta en el servidor. Desde ella, llama a una API de email como Resend o a Nodemailer, o reenvía los campos a un backend de formularios que te mande un email.
Sí, la clave de API se queda en el servidor siempre que su nombre no empiece por NEXT_PUBLIC_. Valida cada campo dentro de la action, porque las Server Actions se pueden llamar directamente.
No para enviar emails. Sí la necesitas si quieres un registro de los mensajes que sobreviva a un email perdido. Un backend de formularios guarda ese registro por ti.
No. Ikiform solo te envía emails a ti y a tu equipo. Envía la confirmación desde tu propio proveedor de email, en tu Server Action o desde un webhook.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.