Envía un formulario a tu email

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.

Preet SutharFundador, Ikiform

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:

Qué hace una Server Action cuando envía el email y cuando reenvía el envíoenvíalo túreenvíalotuformulariouseActionStatetu actionvalida,revisa elhoneypotResend oSMTPtu dominioverificadosolo labandejasin copiasi sepierdetuformulariouseActionStatetu actionañade laclavesecretaIkiformvalida,puntúa,guardabandeja ypanelmáswebhooksFormDataclave de APIFormDataJSONQué hace una Server Action cuando envía el email y cuando reenvía el envíoenvíalo túreenvíalotu formulariouseActionStatetu actionvalida, revisa el honeypotResend o SMTPtu dominio verificadosolo la bandejasin copia si se pierdetu formulariouseActionStatetu actionañade la clave secretaIkiformvalida, puntúa, guardabandeja y panelmás webhooksFormDataclave de APIFormDataJSON
  1. 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.
  2. 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.
Las dos mantienen todas las claves en el servidor. La diferencia es cuánto de la caja del medio escribes y mantienes tú.
TareaServer Action con Resend o NodemailerServer Action con Ikiform
ValidaciónZod en la actionLa definición de campos del formulario, aplicada en Ikiform
SpamTu honeypot; cualquier otra cosa la añades túHoneypot, puntuación de spam y una verificación humana opcional
Límites de frecuenciaNinguno hasta que añadas un almacén como RedisIncluidos, por visitante y por formulario
Dominio de envíoVerifica el tuyo con registros SPF y DKIMNinguno: el correo sale de notifications@ikiform.com
Asunto y diseñoLo que quierasFijo: New submission: <form name>
Confirmación al visitanteUna llamada más a sendNo disponible; envíala desde tu herramienta de email
Una copia de cada mensajeSolo si añades una base de datosPanel, 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.

app/inquiry/state.ts
export interface InquiryState {
  sent: boolean;
  errors: Partial<Record<"name" | "email" | "company" | "budget" | "message" | "form", string>>;
}

export const initialInquiry: InquiryState = { sent: false, errors: {} };
app/inquiry/inquiry-form.tsx
"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.
  • useFormStatus lee 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.
  • replyTo en la versión con Resend, y el campo llamado email en 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 sendMail con from, to, replyTo, subject y text.
  • Para la versión con Ikiform, define los cinco campos en la pestaña Fields del formulario, con budget como select, para que un valor incorrecto reciba 422 validation_failed con un mensaje por campo.
Las versiones del lado del servidor envían con una clave secreta, así que Ikiform se salta Allowed domains (dominios permitidos) y su verificación humana. Todos los envíos vienen entonces de la IP de tu servidor, y el límite Standard de 5 por minuto por visitante cuenta a todos tus visitantes juntos. Elige el límite de frecuencia Relaxed o Custom para un formulario que envías a través de un servidor, y guarda 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

  1. Envía con el presupuesto vacío. El error aparece debajo del select y no se pierde nada más.
  2. Envía una consulta real. La línea de éxito sustituye al formulario.
  3. Abre el email y pulsa Reply. La línea To es la dirección que escribiste.
  4. Rellena el honeypot oculto con las herramientas de desarrollo del navegador y envía. Ves el éxito, y no llega ningún email.
  5. 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 a html, 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 from o to. replyTo es seguro con Resend porque Zod ya ha comprobado que es un email.
  • Variables de entorno. RESEND_API_KEY e IKIFORM_SECRET_KEY no deben empezar por NEXT_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 idempotencyKey de Resend, o la cabecera Idempotency-Key de 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

ProblemaQué hacer
Resend responde que el dominio no está verificadoAñ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_limitedUn 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_failedEl 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ónFalta la clave en las variables de entorno del despliegue. Añádela al entorno de producción y vuelve a desplegar.

Preguntas

¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.