Para validar um endereço de e-mail no Next.js você precisa responder três
perguntas, e o App Router lhe dá um lugar limpo para responder cada uma. O
endereço tem o formato correto? O domínio dele consegue receber e-mail? A caixa
postal realmente existe? A primeira é um schema zod que você compartilha entre
um Client Component e o servidor; a segunda é uma consulta de DNS; a terceira é
um problema de rede que você delega a uma API de verificação. A armadilha é parar
na primeira pergunta — uma aprovação de z.string().email() no navegador é uma
gentileza de UX, não validação, e ela nunca chega intacta ao seu banco de dados,
porque qualquer um pode dar um POST passando reto por ela.
A resposta curta#
Valide com um schema zod compartilhado para a sintaxe tanto no cliente quanto no
servidor, node:dns/promises para a consulta MX dentro de um Route Handler, e uma
API de verificação para a checagem de caixa postal por SMTP — o mais barato
primeiro, encerrando o processo assim que uma camada for decisiva. Mantenha no
servidor toda verificação que importa: a cópia no cliente serve para retorno
instantâneo, e a chave de API que autentica a sondagem de caixa postal jamais pode
ir para o navegador. Não tente abrir conexões SMTP a partir de uma função do
Next.js para sondar caixas postais você mesmo — a porta 25 de saída fica bloqueada
na maioria dos hosts (e é totalmente indisponível no runtime Edge), e a resposta
depende de reputação de IP de envio e de greylisting que você não vai querer
reimplementar.
Camada 1: validação de formato com um schema zod compartilhado#
O Next.js não tem uma primitiva de e-mail própria, mas a checagem de formato
idiomática é um schema zod, e a razão pela qual o zod encaixa tão bem aqui é
que o mesmo schema roda no navegador e no servidor. Defina-o uma vez:
// lib/email.ts
import { z } from "zod";
export const signupSchema = z.object({
email: z.string().email("Enter a valid email address."),
});
Em um Client Component, faça o parse contra ele no envio para que o usuário receba
um erro instantâneo sem uma ida e volta ao servidor. O safeParse retorna um
resultado discriminado que você pode ler sem um 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>
);
}
A checagem no cliente vale a pena — ela evita uma requisição em erros óbvios de
digitação — mas é o oposto de confiável. Um usuário com a aba de rede aberta pode
dar um POST direto para /api/signup e ignorá-la por completo, então o servidor
precisa rodar exatamente o mesmo schema de novo antes de acreditar em qualquer
coisa.
Camada 2: o domínio consegue receber e-mail?#
Esta é a primeira checagem que o navegador não consegue fazer e que o zod não
vai fazer: um domínio sem registros MX não consegue aceitar e-mail para ninguém,
então uma consulta de DNS elimina domínios mortos, nomes de empresas digitados
errado e TLDs inventados. Um Route Handler do Next.js roda no Node por padrão,
então você tem node:dns/promises sem nenhuma dependência:
// 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;
}
}
Uma aprovação aqui significa "o domínio aceita e-mail", não "esta caixa postal existe" — mas uma reprovação é decisiva e gratuita, que é exatamente o que você quer de uma camada barata.
Camada 3: a caixa postal realmente existe?#
As camadas 1 e 2 só conseguem descartar um endereço. Um domínio pode publicar
registros MX perfeitos e ainda assim não ter caixa postal no endereço que você
tem em mãos — noreply-9f2x@gmail.com tem sintaxe válida em uma rota de e-mail
ativa, e mesmo assim é uma caixa postal que nunca foi criada. Confirmar uma caixa
postal específica significa a conversa de entrega SMTP: conectar ao host de
e-mail, emitir RCPT TO, ler a resposta e desconectar antes de enviar qualquer
coisa.
Você poderia programar isso a partir de um runtime Node, mas não deveria executá-lo a partir de uma função do Next.js. A maioria dos hosts em nuvem bloqueia a porta 25 de saída, a resposta depende da reputação do IP a partir do qual você conecta, e os servidores de recebimento fazem greylisting e limitam a taxa de remetentes desconhecidos — então uma sondagem que funciona localmente falha silenciosamente, ou faz você entrar em uma blocklist, em produção. Como funciona a verificação de e-mail percorre o pipeline completo, domínios catch-all e tudo mais. Esta é a camada que vale a pena delegar.
O endpoint de verificação da Qualisend roda o pipeline inteiro a partir de uma
infraestrutura com reputação gerenciada e retorna um veredito. Chame-o no lado do
servidor com fetch, lendo a chave de uma variável de ambiente para que ela nunca
saia da 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"
}
A resposta é um envelope { "result": { ... } }. Você lê result.status junto de
um score, um reason e sub_flags para características como endereços de função
(role) ou descartáveis:
{
"result": {
"status": "deliverable",
"score": 95,
"reason": null,
"sub_flags": { "role": false, "disposable": false, "free": false, "catch_all": false }
}
}
Consulte a referência da API para os formatos exatos dos campos; o
helper acima só precisa de status para rejeitar um endereço que não vai ser
entregue. Repare que a variável de ambiente é simplesmente QUALISEND_API_KEY,
sem o prefixo NEXT_PUBLIC_ — esse prefixo é o que embute um valor no bundle
do cliente, e uma chave de verificação no navegador é uma chave que qualquer um
pode ler e gastar.
Compondo as camadas para validar um endereço de e-mail no Next.js#
Agora costure os três helpers em um único route.ts, o mais barato primeiro, para
que o crédito de API só seja gasto em um endereço que já passou pela sintaxe e pela
verificação 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 });
}
Essa ordenação é o truque inteiro: o schema descarta o lixo, a checagem local de
DNS elimina domínios mortos de graça, e a API só é acionada para endereços que
passaram pelas duas. O handler só reprova de forma definitiva o undeliverable e
deixa risky e unknown passarem, então um endereço limítrofe ou uma falha
transitória da API nunca afasta um cliente de verdade — você pode ramificar com
base no score ou em uma entrada de sub_flags mais adiante, em vez de bloquear o
cadastro.
Se você preferir enviar o formulário sem escrever código de fetch no cliente, o
corpo idêntico funciona em uma Server Action. Retorne { error } em vez de
NextResponse e consuma-o com 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 };
}
Este é o mesmo formato de três camadas que você vai encontrar no guia do Node.js — é a estratificação, não o framework, que torna a validação confiável. Rode este pipeline rápido de forma síncrona no cadastro e reserve verificações em lote mais profundas para a limpeza de listas; o guia de cadastro serverless mostra o padrão em tempo real de ponta a ponta, e a comparação de APIs alinha os serviços que podem sustentar a camada três.
Perguntas frequentes#
z.string().email() é suficiente para validar um endereço de e-mail no Next.js?#
Para a sintaxe, sim — z.string().email() é a verificação certa para a camada um e
muito melhor do que um regex feito à mão. Mas ela valida o formato: nunca resolve
o DNS nem contata um servidor de e-mail, então uma aprovação significa "parece um
e-mail", não "vai ser entregue". E uma verificação feita apenas no navegador é
burlada trivialmente ao enviar uma requisição diretamente para a sua rota. Rode o
schema novamente no servidor e depois acrescente uma consulta MX e uma verificação
de caixa postal por SMTP antes de confiar no endereço.
Onde eu coloco a chave de API da Qualisend em um app Next.js?#
Em uma variável de ambiente exclusiva do servidor chamada QUALISEND_API_KEY —
nunca em uma com o prefixo NEXT_PUBLIC_. O prefixo NEXT_PUBLIC_ embute um valor
no bundle do cliente, então uma chave de verificação com esse prefixo pode ser
lida por qualquer pessoa que abrir o seu site. Leia process.env.QUALISEND_API_KEY
dentro de um Route Handler ou de uma Server Action, onde ela permanece no servidor,
e deixe o navegador chamar o seu próprio endpoint em vez de chamar a Qualisend
diretamente.
Devo validar um e-mail no cliente ou no servidor no Next.js?#
Nos dois, mas só o servidor conta. Rode o schema zod compartilhado no Client
Component para dar um retorno instantâneo sobre erros de digitação e rode
exatamente o mesmo schema de novo no Route Handler ou na Server Action, porque a
verificação no cliente pode ser totalmente ignorada. As camadas de DNS e SMTP são
exclusivas do servidor por natureza — precisam de APIs do Node e de uma chave
secreta — então o navegador nunca as enxerga.
Posso rodar a verificação de DNS/MX no runtime Edge?#
Não. node:dns/promises exige o runtime do Node.js, que os Route Handlers usam por
padrão; uma rota com export const runtime = "edge" não tem o módulo dns. Se
você precisa do Edge, dispense o gate local de MX e deixe a Qualisend resolver o
DNS como parte da chamada de verificação — o pipeline dela já faz a consulta MX e a
sondagem SMTP, então a única chamada de API cobre as camadas dois e três.
Pronto para adicionar a camada de caixa postal? Jogue um endereço sintaticamente
perfeito no verificador de e-mail gratuito para ver uma
string aprovada pelo zod voltar como undeliverable, e depois integre o mesmo
veredito ao seu Route Handler com os exemplos de copiar e colar da
referência da API.