Para validar una dirección de correo electrónico en Next.js tienes que responder
a tres preguntas, y el App Router te da un lugar limpio donde responder a cada
una. ¿Tiene la dirección la forma correcta? ¿Puede su dominio recibir correo?
¿Existe realmente el buzón? La primera es un esquema zod que compartes entre un
Client Component y el servidor; la segunda es una consulta DNS; la tercera es un
problema de red que delegas en una API de verificación. La trampa está en quedarse
en la primera pregunta: que z.string().email() pase en el navegador es un
detalle de UX, no una validación, y nunca llega intacto a tu base de datos, porque
cualquiera puede hacer un POST que lo esquive.
La respuesta corta#
Valida con un esquema zod compartido para la sintaxis, tanto en el cliente como
en el servidor, con node:dns/promises para la consulta MX dentro de un Route
Handler, y con una API de verificación para la comprobación del buzón por SMTP:
lo más barato primero, cortocircuitando en cuanto una capa sea decisiva. Mantén en
el servidor todas las comprobaciones que importan: la copia del cliente es para dar
respuesta inmediata, y la clave de API que autentica el sondeo del buzón jamás debe
llegar al navegador. No intentes abrir conexiones SMTP desde una función de Next.js
para sondear buzones tú mismo: el puerto 25 de salida está bloqueado en la mayoría
de los hosts (y no está disponible en absoluto en el runtime Edge), y la respuesta
depende de la reputación de la IP emisora y del greylisting, algo que no querrás
reimplementar.
Capa 1: validación del formato con un esquema zod compartido#
Next.js no tiene un tipo de correo propio, pero la comprobación de formato
idiomática es un esquema zod, y la razón por la que zod encaja tan bien aquí es
que el mismo esquema se ejecuta en el navegador y en el servidor. Defínelo una
sola vez:
// lib/email.ts
import { z } from "zod";
export const signupSchema = z.object({
email: z.string().email("Enter a valid email address."),
});
En un Client Component, analízalo contra el esquema al enviar para que el usuario
reciba un error inmediato sin un viaje de ida y vuelta. safeParse devuelve un
resultado discriminado que puedes leer sin un try/catch:
"use client";
import { useState } from "react";
import { signupSchema } from "@/lib/email";
export function SignupForm() {
const [error, setError] = useState<string | null>(null);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const email = String(new FormData(e.currentTarget).get("email"));
// Instant feedback only — this is not a security boundary.
const parsed = signupSchema.safeParse({ email });
if (!parsed.success) {
setError(parsed.error.issues[0].message);
return;
}
const res = await fetch("/api/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
setError(res.ok ? null : (await res.json()).error);
}
return (
<form onSubmit={onSubmit}>
<input name="email" type="email" autoComplete="email" required />
{error && <p role="alert">{error}</p>}
<button type="submit">Sign up</button>
</form>
);
}
La comprobación del cliente merece la pena tenerla —ahorra una petición ante
erratas evidentes—, pero es lo contrario de fiable. Un usuario con la pestaña de
red abierta puede hacer un POST directo a /api/signup y saltársela por completo,
así que el servidor tiene que ejecutar exactamente el mismo esquema otra vez antes
de dar nada por bueno.
Capa 2: ¿puede el dominio recibir correo?#
Esta es la primera comprobación que el navegador no puede hacer y que zod no
hará: un dominio sin registros MX no puede aceptar correo de nadie, así que una
consulta DNS elimina dominios muertos, nombres de empresa mal escritos y TLD
inventados. Un Route Handler de Next.js se ejecuta en Node por defecto, así que
tienes node:dns/promises sin ninguna dependencia:
// lib/email.ts (continued)
import { resolveMx } from "node:dns/promises";
export async function hasMailRoute(domain: string): Promise<boolean> {
try {
const mx = await resolveMx(domain);
return mx.length > 0;
} catch {
// ENOTFOUND / ENODATA → the domain doesn't exist or publishes no MX.
return false;
}
}
Superarla aquí significa «el dominio acepta correo», no «este buzón existe»; pero un fallo es decisivo y gratuito, que es justo lo que quieres de una capa barata.
Capa 3: ¿existe realmente el buzón?#
Las capas 1 y 2 solo pueden descartar una dirección. Un dominio puede publicar
registros MX perfectos y aun así no tener buzón en la dirección que tienes entre
manos: noreply-9f2x@gmail.com es sintaxis válida sobre una ruta de correo activa,
y sigue siendo un buzón que nunca se creó. Confirmar un buzón concreto significa
mantener la conversación de entrega SMTP: conectar con el host de correo, emitir
RCPT TO, leer la respuesta y desconectar antes de enviar nada.
Podrías programar eso desde un runtime de Node, pero no deberías ejecutarlo desde una función de Next.js. La mayoría de los hosts en la nube bloquean el puerto 25 de salida, la respuesta depende de la reputación de la IP desde la que conectas, y los servidores receptores aplican greylisting y limitan la tasa a emisores desconocidos, así que un sondeo que funciona en local falla silenciosamente, o hace que acabes en una lista de bloqueo, en producción. Cómo funciona la verificación de correo recorre el pipeline completo, con dominios catch-all incluidos. Esta es la capa que vale la pena delegar.
El endpoint de verificación de Qualisend ejecuta todo el pipeline desde una
infraestructura con reputación gestionada y devuelve un veredicto. Llámalo desde el
servidor con fetch, leyendo la clave desde una variable de entorno para que nunca
salga de la máquina:
// lib/email.ts (continued)
export async function verifyMailbox(email: string): Promise<string> {
const res = await fetch("https://api.qualisend.com/v1/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.QUALISEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
});
// Don't block a real signup on our outage — treat a failure as "unknown".
if (!res.ok) return "unknown";
const { result } = await res.json();
return result.status; // "deliverable" | "risky" | "undeliverable" | "unknown"
}
La respuesta es un sobre { "result": { ... } }. Lees result.status junto con un
score, un reason y sub_flags para rasgos como direcciones de rol o
desechables:
{
"result": {
"status": "deliverable",
"score": 95,
"reason": null,
"sub_flags": { "role": false, "disposable": false, "free": false, "catch_all": false }
}
}
Consulta la referencia de la API para conocer la forma exacta de los
campos; el helper de arriba solo necesita status para rechazar una dirección que
no se va a entregar. Fíjate en que la variable de entorno es simplemente
QUALISEND_API_KEY, no lleva el prefijo NEXT_PUBLIC_: ese prefijo es lo que
incrusta un valor en el bundle del cliente, y una clave de verificación en el
navegador es una clave que cualquiera puede leer y gastar.
Componer las capas para validar una dirección de correo en Next.js#
Ahora cose los tres helpers en un único route.ts, lo más barato primero, de modo
que el crédito de la API solo se gaste en una dirección que ya haya superado la
sintaxis y la comprobación MX:
// app/api/signup/route.ts
import { NextResponse } from "next/server";
import { signupSchema, hasMailRoute, verifyMailbox } from "@/lib/email";
export async function POST(request: Request) {
// Layer 1 — re-validate on the server; the client check is not a boundary.
const parsed = signupSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json({ error: "Enter a valid email address." }, { status: 400 });
}
const { email } = parsed.data;
const domain = email.slice(email.lastIndexOf("@") + 1);
// Layer 2 — cheap MX gate, rules out dead domains before any API call.
if (!(await hasMailRoute(domain))) {
return NextResponse.json({ error: "That domain can't receive email." }, { status: 400 });
}
// Layer 3 — the SMTP mailbox probe, delegated to Qualisend.
if ((await verifyMailbox(email)) === "undeliverable") {
return NextResponse.json(
{ error: "We couldn't confirm a mailbox at this address." },
{ status: 400 },
);
}
// Every layer passed (or the probe was inconclusive) — create the account.
// await createUser(email);
return NextResponse.json({ ok: true });
}
Ese orden es todo el truco: el esquema cortocircuita la basura, la comprobación DNS
local descarta gratis los dominios muertos, y la API solo se toca para las
direcciones que superaron ambas. El handler solo falla en firme con undeliverable
y deja pasar risky y unknown, de modo que una dirección límite o un fallo
transitorio de la API nunca rechacen a un cliente real: puedes ramificar según
score o una entrada de sub_flags más adelante en lugar de bloquear el registro.
Si prefieres enviar el formulario sin escribir código de fetch en el cliente, el
mismo cuerpo idéntico funciona en una Server Action. Devuelve { error } en lugar
de NextResponse y consúmelo con useActionState:
"use server";
import { signupSchema, hasMailRoute, verifyMailbox } from "@/lib/email";
export async function signup(_prev: unknown, formData: FormData) {
const parsed = signupSchema.safeParse({ email: formData.get("email") });
if (!parsed.success) return { error: "Enter a valid email address." };
const { email } = parsed.data;
const domain = email.slice(email.lastIndexOf("@") + 1);
if (!(await hasMailRoute(domain))) return { error: "That domain can't receive email." };
if ((await verifyMailbox(email)) === "undeliverable") {
return { error: "We couldn't confirm a mailbox at this address." };
}
return { ok: true };
}
Esta es la misma estructura de tres capas que encontrarás en la guía de Node.js: lo que hace fiable la validación es el sistema de capas, no el framework. Ejecuta este pipeline rápido de forma síncrona en el registro y reserva las comprobaciones por lotes más profundas para la limpieza de listas; la guía de registro serverless muestra el patrón en tiempo real de principio a fin, y la comparación de APIs alinea los servicios que pueden respaldar la tercera capa.
Preguntas frecuentes#
¿Basta con z.string().email() para validar una dirección de correo en Next.js?#
Para la sintaxis, sí: z.string().email() es la comprobación correcta de la
primera capa y muy superior a una expresión regular hecha a mano. Pero valida la
forma: nunca resuelve el DNS ni contacta con un servidor de correo, así que
superarla significa «parece un correo», no «se va a entregar». Y una comprobación
solo en el navegador se salta con toda facilidad enviando una petición directa a tu
ruta. Vuelve a ejecutar el esquema en el servidor y luego añade una consulta MX y
una comprobación de buzón por SMTP antes de fiarte de la dirección.
¿Dónde pongo la clave de API de Qualisend en una aplicación Next.js?#
En una variable de entorno exclusiva del servidor llamada QUALISEND_API_KEY,
nunca en una con el prefijo NEXT_PUBLIC_. El prefijo NEXT_PUBLIC_ incrusta un
valor en el bundle del cliente, de modo que una clave de verificación con ese
prefijo la puede leer cualquiera que abra tu sitio. Lee
process.env.QUALISEND_API_KEY dentro de un Route Handler o una Server Action,
donde permanece en el servidor, y deja que el navegador llame a tu propio endpoint
en lugar de a Qualisend directamente.
¿Debería validar un correo en el cliente o en el servidor en Next.js?#
En ambos, pero solo cuenta el servidor. Ejecuta el esquema zod compartido en el
Client Component para dar respuesta inmediata ante erratas, y vuelve a ejecutar
exactamente el mismo esquema en el Route Handler o la Server Action, porque la
comprobación del cliente se puede saltar por completo. Las capas de DNS y SMTP son,
por naturaleza, exclusivas del servidor —necesitan APIs de Node y una clave
secreta—, así que el navegador nunca las ve.
¿Puedo ejecutar la comprobación de DNS/MX en el runtime Edge?#
No. node:dns/promises requiere el runtime de Node.js, que los Route Handlers usan
por defecto; una ruta con export const runtime = "edge" no tiene módulo dns. Si
necesitas Edge, elimina el filtro MX local y deja que Qualisend resuelva el DNS como
parte de la llamada de verificación: su pipeline ya hace la consulta MX y el sondeo
SMTP, así que esa única llamada a la API cubre las capas dos y tres.
¿Listo para añadir la capa del buzón? Suelta una dirección sintácticamente perfecta
en el comprobador de correo gratuito para ver cómo una cadena
aprobada por zod vuelve como undeliverable, y luego integra ese mismo veredicto
en tu Route Handler con los ejemplos listos para copiar y pegar de la
referencia de la API.