Receber um formulário no e-mail7 min de leitura
Um formulário de contato em Next.js que envia e-mail
Duas formas de mandar um formulário Next.js por e-mail com uma Server Action: enviar você mesmo com Resend ou Nodemailer, ou encaminhar a um backend de formulários, comparadas com honestidade.
No App Router, uma Server Action recebe o FormData do formulário no servidor, onde uma chave de API de e-mail fica segura. Você pode enviar o e-mail você mesmo com Resend ou Nodemailer, ou encaminhar o envio a um backend de formulários que filtra spam, armazena e manda um e-mail para você. Enviar você mesmo dá controle total do e-mail; encaminhar poupa o filtro de spam, os limites de taxa e o armazenamento que você teria que construir.
O que muda aqui
O Next.js já tem um servidor, então o motivo habitual para um backend de formulários, "meu site não consegue rodar código", não se aplica. A questão é o que esse código precisa fazer. Enviar o e-mail é a parte fácil. Tudo em volta é o que toma tempo:
- Enviando você mesmo: a action valida os campos, confere o honeypot e chama seu provedor de e-mail com uma chave de API. A mensagem só existe como esse e-mail.
- Encaminhando: a action envia os campos ao Ikiform com sua chave secreta. O Ikiform valida com a definição do formulário, pontua o spam, armazena o envio e manda e-mail aos destinatários.
| Trabalho | Server Action com Resend ou Nodemailer | Server Action com Ikiform |
|---|---|---|
| Validação | Zod na action | A definição de campos do formulário, aplicada do lado do Ikiform |
| Spam | Seu honeypot; qualquer coisa além disso fica por sua conta | Honeypot, pontuação de spam e uma verificação humana opcional |
| Limites de taxa | Nenhum até você adicionar um armazenamento como o Redis | Embutidos, por visitante e por formulário |
| Domínio de envio | Verifique o seu com registros SPF e DKIM | Nenhum: o e-mail vem de notifications@ikiform.com |
| Assunto e design | O que você quiser | Fixo: New submission: <form name> |
| Confirmação para o visitante | Mais uma chamada a send | Não disponível; envie pela sua ferramenta de e-mail |
| Uma cópia de cada mensagem | Só se você adicionar um banco de dados | Painel, exportação CSV e API |
Escolha a versão faça-você-mesmo para um e-mail com a sua marca ou uma confirmação para o visitante, se não se importa de cuidar do spam e do armazenamento. Escolha encaminhar para parar de pensar no formulário depois que ele funcionar.
As duas versões
O formulário é um pedido de projeto: nome, e-mail de trabalho, empresa, uma faixa de orçamento e uma descrição. O tipo do state e o client component são compartilhados; só o actions.ts muda.
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: {} };
}- O tipo do state fica num arquivo próprio porque um arquivo
"use server"só pode exportar funções assíncronas. useFormStatuslê o estado pendente do formulário em que está, então o botão fica num componente próprio.- Na versão com Resend, um honeypot preenchido devolve sucesso sem enviar, então o bot não aprende nada. O Ikiform faz o mesmo quando armazena o envio como spam.
replyTona versão com Resend, e o campo chamadoemailna versão com Ikiform, fazem o Reply responder ao visitante.- O Nodemailer funciona do mesmo jeito que o Resend: crie um transport com seus dados de SMTP e chame
sendMailcomfrom,to,replyTo,subjectetext. - Para a versão com Ikiform, defina os cinco campos na aba Fields do formulário, com
budgetcomoselect, para que um valor ruim receba422 validation_failedcom uma mensagem por campo.
IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor. Cada exemplo de servidor repassa o header Origin do navegador quando existe, porque o fetch do próprio servidor não envia nenhum e o Ikiform usa esse header para contar os envios de teste gratuitos de localhost.Há uma terceira opção: pular a action e deixar o navegador enviar direto ao Ikiform. Nenhuma chave secreta entra nisso, então o limite de taxa Standard e Allowed domains valem por visitante. O guia de Next.js da documentação mostra o client component.
Confira se funciona
- Envie com o orçamento vazio. O erro aparece abaixo do select e nada mais se perde.
- Envie um pedido de verdade. A frase de sucesso substitui o formulário.
- Abra o e-mail e clique em Reply. A linha To é o endereço que você digitou.
- Preencha o honeypot oculto com as ferramentas de desenvolvedor do navegador e envie. Você vê sucesso, e nenhum e-mail chega.
- Abra Submissions no painel. O novo envio está lá com todos os campos que você enviou. Este passo vale só para a versão com Ikiform.
Pegadinhas
- Escape em e-mail HTML. A versão com Resend envia
text. Se você mudar parahtml, escape cada valor antes, ou um visitante consegue colocar markup e links na sua caixa de entrada. - Injeção de headers. Nunca coloque um valor cru do formulário em
fromouto.replyToé seguro com o Resend porque o Zod já conferiu que é um e-mail. - Variáveis de ambiente.
RESEND_API_KEYeIKIFORM_SECRET_KEYnão podem começar comNEXT_PUBLIC_, ou o Next.js as envia para o navegador. - Envios duplicados. Um clique duplo pode rodar a action duas vezes. O estado pendente desativa o botão, mas uma rede lenta ainda pode causar uma corrida; a opção
idempotencyKeydo Resend, ou o headerIdempotency-Keydo Ikiform, faz uma repetição não ter efeito. - Server Actions são endpoints públicos. Qualquer um pode chamar uma diretamente, e é por isso que a validação e as verificações de spam precisam rodar dentro dela, não só no formulário.
Se algo der errado
| Problema | O que fazer |
|---|---|
| O Resend responde que o domínio não está verificado | Adicione os registros DNS do Resend para o domínio em from, ou envie pelo endereço de teste do Resend enquanto desenvolve. |
429 rate_limited | Um visitante enviou mais do que o limite de taxa do formulário permite. O Standard permite 5 por minuto por visitante. Espere o Retry-After, ou escolha Relaxed ou Custom para formulários que enviam a partir de um único IP compartilhado. |
422 validation_failed | O envio não corresponde à definição de campos do formulário. error.fields aponta cada campo e o motivo. Faça o name do input bater com o id do campo. |
| A action funciona localmente, mas não em produção | A chave está faltando nas variáveis de ambiente do deploy. Adicione-a ao ambiente de produção e faça o deploy de novo. |
Perguntas
Sim, no runtime Node.js, que as Server Actions usam por padrão. Ele abre uma conexão SMTP, então não roda no runtime Edge, e alguns hosts limitam SMTP de saída. Uma API de e-mail via HTTP, como o Resend, evita os dois problemas.
Trate o formulário com uma Server Action, que roda no servidor. A partir dela, chame uma API de e-mail como o Resend ou o Nodemailer, ou encaminhe os campos a um backend de formulários que manda o e-mail para você.
Sim, a chave de API fica no servidor desde que o nome dela não comece com NEXT_PUBLIC_. Valide cada campo dentro da action, porque Server Actions podem ser chamadas diretamente.
Não para enviar e-mail. Você precisa de um se quiser um registro das mensagens que sobreviva a um e-mail perdido. Um backend de formulários guarda esse registro para você.
Não. O Ikiform envia e-mail só para você e sua equipe. Envie uma confirmação pelo seu próprio provedor de e-mail, na sua Server Action ou a partir de um webhook.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.