# 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.

Publicado em 2026-10-25 · https://ikiform.com/pt-br/guides/send-form-to-email/nextjs

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:

Diagrama: O que uma Server Action faz quando envia o e-mail, e quando encaminha o envio

1. 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.
2. 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.

As duas mantêm todas as chaves no servidor. A diferença é quanto da caixa do meio você escreve e mantém.

| 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.

`app/inquiry/state.ts`:

```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`:

```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>
  );
}
```

**Resend** (`app/inquiry/actions.ts`)

```ts
"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: {} };
}
```

**Ikiform** (`app/inquiry/actions.ts`)

```ts
"use server";

import { headers } from "next/headers";
import type { InquiryState } from "./state";

export async function sendInquiry(_previous: InquiryState, formData: FormData): Promise<InquiryState> {
  const origin = (await headers()).get("origin");
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({
      name: formData.get("name"),
      email: formData.get("email"),
      company: formData.get("company"),
      budget: formData.get("budget"),
      message: formData.get("message"),
      _gotcha: formData.get("_gotcha") ?? "",
    }),
  });
  const result = await response.json().catch(() => null);
  if (result?.ok) return { sent: true, errors: {} };

  const errors: InquiryState["errors"] = {};
  for (const [field, messages] of Object.entries<string[]>(result?.error?.fields ?? {})) {
    errors[field as keyof InquiryState["errors"]] = messages[0];
  }
  if (!Object.keys(errors).length) errors.form = result?.error?.message ?? "Couldn't send your inquiry. Try again.";
  return { sent: false, errors };
}
```

- O tipo do state fica num arquivo próprio porque um arquivo `"use server"` só pode exportar funções assíncronas.
- `useFormStatus` lê 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.
- `replyTo` na versão com Resend, e o campo chamado `email` na 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 `sendMail` com `from`, `to`, `replyTo`, `subject` e `text`.
- Para a versão com Ikiform, defina os cinco campos na aba **Fields** do formulário, com `budget` como `select`, para que um valor ruim receba `422 validation_failed` com uma mensagem por campo.

> As versões no servidor enviam com uma chave secreta, então o Ikiform pula **Allowed domains** e a verificação humana. Todo envio passa a vir do IP do seu servidor, e o limite Standard de 5 por minuto por visitante conta todos os seus visitantes juntos. Escolha o [limite de taxa](https://ikiform.com/pt-br/docs/rate-limits.md) Relaxed ou Custom para um formulário que você envia por um servidor, e mantenha `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](https://ikiform.com/pt-br/docs/guides/nextjs.md) mostra o client component.

## Confira se funciona

1. Envie com o orçamento vazio. O erro aparece abaixo do select e nada mais se perde.
2. Envie um pedido de verdade. A frase de sucesso substitui o formulário.
3. Abra o e-mail e clique em **Reply**. A linha **To** é o endereço que você digitou.
4. Preencha o honeypot oculto com as ferramentas de desenvolvedor do navegador e envie. Você vê sucesso, e nenhum e-mail chega.
5. 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 para `html`, 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 `from` ou `to`. `replyTo` é seguro com o Resend porque o Zod já conferiu que é um e-mail.
- **Variáveis de ambiente.** `RESEND_API_KEY` e `IKIFORM_SECRET_KEY` não podem começar com `NEXT_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 `idempotencyKey` do Resend, ou o header `Idempotency-Key` do 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

### O Nodemailer funciona numa Server Action?

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.

### Como envio um e-mail a partir de um formulário Next.js?

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ê.

### É seguro chamar o Resend a partir de uma Server Action?

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.

### Preciso de um banco de dados para um formulário de contato em Next.js?

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ê.

### O Ikiform pode enviar um e-mail de confirmação ao visitante?

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.

## Mais guias

- [Receber um formulário no e-mail](https://ikiform.com/pt-br/guides/send-form-to-email.md): Receba todo envio de formulário na caixa de entrada sem servidor de e-mail: por que mailto: e mail() do PHP falham, e uma configuração que filtra spam e permite responder.
- [Alternativa ao EmailJS](https://ikiform.com/pt-br/guides/send-form-to-email/emailjs-alternative.md): Tire um formulário de contato do EmailJS: por que chaves de e-mail no navegador são um risco, o que muda no seu código e do que você abre mão, como a resposta automática.
- [Vários destinatários](https://ikiform.com/pt-br/guides/send-form-to-email/multiple-recipients.md): Mande um formulário por e-mail para a equipe toda, ou encaminhe cada mensagem à pessoa certa pelo assunto, sem deixar o próprio formulário escolher quem recebe.
- [Responder pela caixa de entrada](https://ikiform.com/pt-br/guides/send-form-to-email/reply-to.md): Faça o Reply de uma notificação de formulário responder a quem preencheu: quais nomes de campo funcionam, quando usar _replyto e por que as respostas se perdem.
- [Sem PHP](https://ikiform.com/pt-br/guides/send-form-to-email/without-php.md): Substitua um script contact.php que chama mail() por um formulário que envia a um endpoint hospedado, mantendo seus nomes de campo, sua página de agradecimento e as respostas.
- [Para o Gmail](https://ikiform.com/pt-br/guides/send-form-to-email/gmail.md): Receba os envios de formulário no Gmail sem links mailto:, senhas de app ou Apps Script, e depois rotule, tire do spam e responda pelo Gmail.
