# A Next.js contact form that sends email

> Two ways to email a Next.js form with a Server Action: send it yourself with Resend or Nodemailer, or forward it to a form backend, compared honestly.

Published 2026-10-25 · https://ikiform.com/guides/send-form-to-email/nextjs

In the App Router, a Server Action receives the form's `FormData` on the server, where an email API key is safe. You can send the email yourself with Resend or Nodemailer, or forward the post to a form backend that filters spam, stores it and emails you. Sending it yourself gives you full control of the email; forwarding saves you the spam filtering, rate limits and storage you'd otherwise build.

## What's different here

Next.js already has a server, so the usual reason for a form backend, "my site can't run code", doesn't apply. The question is what that code has to do. Sending the email is the easy part. Everything around it is what takes the time:

Diagram: What a Server Action does when it sends the email, and when it forwards the post

1. Sending it yourself: the action validates the fields, checks the honeypot and calls your email provider with an API key. The message exists only as that email.
2. Forwarding it: the action posts the fields to Ikiform with your secret key. Ikiform validates against the form's definition, scores spam, stores the submission and emails the recipients.

Both keep every key on the server. The difference is how much of the middle box you write and maintain.

| Job | Server Action + Resend or Nodemailer | Server Action + Ikiform |
| --- | --- | --- |
| Validation | Zod in the action | The form's field definition, enforced on Ikiform's side |
| Spam | Your honeypot; anything more is yours to add | Honeypot, spam scoring and an optional human check |
| Rate limits | None until you add a store such as Redis | Built in, per visitor and per form |
| Sending domain | Verify yours with SPF and DKIM records | None: mail comes from `notifications@ikiform.com` |
| Subject and design | Anything you like | Fixed: `New submission: <form name>` |
| Confirmation to the visitor | One more `send` call | Not available; send it from your email tool |
| A copy of every message | Only if you add a database | Dashboard, CSV export and API |

Pick the DIY version for a branded email or a confirmation to the visitor, if you're happy to own spam and storage. Pick forwarding to stop thinking about the form once it works.

## Both versions

The form is a project inquiry: name, work email, company, a budget range and a description. The state type and the client component are shared; only `actions.ts` changes.

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

- The state type lives in its own file because a `"use server"` file may only export async functions.
- `useFormStatus` reads the pending state of the form it sits in, so the button lives in its own component.
- In the Resend version, a filled honeypot returns success without sending, so the bot learns nothing. Ikiform does the same when it stores the post as spam.
- `replyTo` in the Resend version, and the field named `email` in the Ikiform version, both make **Reply** write to the visitor.
- Nodemailer works the same way as Resend: create a transport with your SMTP details and call `sendMail` with `from`, `to`, `replyTo`, `subject` and `text`.
- For the Ikiform version, define the five fields on the form's **Fields** tab with `budget` as a `select`, so a bad value gets `422 validation_failed` with a message per field.

> The server-side versions post with a secret key, so Ikiform skips **Allowed domains** and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom [rate limit](https://ikiform.com/docs/rate-limits.md) for a form you post through a server, and keep `IKIFORM_SECRET_KEY` in server-only environment variables. Each server sample passes on the browser's `Origin` header when there is one, because a server's own `fetch` sends none and Ikiform counts free `localhost` test submissions by it.

There's a third option: skip the action and let the browser post straight to Ikiform. No secret key is involved, so the Standard rate limit and **Allowed domains** apply per visitor. The [Next.js docs guide](https://ikiform.com/docs/guides/nextjs.md) shows the client component.

## Check it works

1. Submit with an empty budget. The error shows under the select and nothing else is lost.
2. Submit a real inquiry. The success line replaces the form.
3. Open the email and press **Reply**. The **To** line is the address you entered.
4. Fill in the hidden honeypot with the browser's dev tools and submit. You see success, and no email arrives.
5. Open **Submissions** in the dashboard. The new submission is there with every field you sent. This step applies to the Ikiform version only.

## Gotchas

- **Escaping in HTML email.** The Resend version sends `text`. If you switch to `html`, escape every value first, or a visitor can put markup and links into your inbox.
- **Header injection.** Never put a raw form value into `from` or `to`. `replyTo` is safe with Resend because Zod has already checked it's an email.
- **Environment variables.** `RESEND_API_KEY` and `IKIFORM_SECRET_KEY` must not start with `NEXT_PUBLIC_`, or Next.js ships them to the browser.
- **Duplicate sends.** A double click can run the action twice. The pending state disables the button, but a slow network can still race; Resend's `idempotencyKey` option, or Ikiform's `Idempotency-Key` header, makes a repeat a no-op.
- **Server Actions are public endpoints.** Anyone can call one directly, which is why validation and spam checks must run inside it, not only in the form.

## If something goes wrong

| Problem | What to do |
| --- | --- |
| Resend answers that the domain isn't verified | Add Resend's DNS records for the domain in `from`, or send from Resend's test address while you develop. |
| `429 rate_limited` | One visitor sent more than the form's rate limit allows. Standard allows 5 a minute per visitor. Wait for `Retry-After`, or choose Relaxed or Custom for forms that post from one shared IP. |
| `422 validation_failed` | The post doesn't match the form's field definition. `error.fields` names each field and the reason. Match the input `name` to the field id. |
| The action works locally but not in production | The key is missing in the deployment's environment variables. Add it to the production environment and redeploy. |

## Questions

### Does Nodemailer work in a Server Action?

Yes, in the Node.js runtime, which Server Actions use by default. It opens an SMTP connection, so it doesn't run on the Edge runtime, and some hosts limit outbound SMTP. An HTTP email API such as Resend avoids both.

### How do I send an email from a Next.js form?

Handle the form with a Server Action, which runs on the server. Either call an email API such as Resend or Nodemailer from it, or forward the fields to a form backend that emails you.

### Is it safe to call Resend from a Server Action?

Yes, the API key stays on the server as long as its name doesn't start with NEXT_PUBLIC_. Validate every field inside the action, because Server Actions can be called directly.

### Do I need a database for a Next.js contact form?

Not to send email. You do need one if you want a record of messages that survives a lost email. A form backend keeps that record for you.

### Can Ikiform send the visitor a confirmation email?

No. Ikiform emails you and your team only. Send a confirmation from your own email provider, in your Server Action or from a webhook.

## More guides

- [Send a form to your email](https://ikiform.com/guides/send-form-to-email.md): Get every form submission in your inbox without a mail server: why mailto: and PHP mail() fail, and a setup that filters spam and lets you reply.
- [EmailJS alternative](https://ikiform.com/guides/send-form-to-email/emailjs-alternative.md): Move a contact form off EmailJS: why browser-side email keys are a risk, what changes in your code, and what you give up, such as the auto-reply.
- [Multiple recipients](https://ikiform.com/guides/send-form-to-email/multiple-recipients.md): Email one form to your whole team, or route each message to the right person by topic, without letting the form itself pick who gets the mail.
- [Reply from your inbox](https://ikiform.com/guides/send-form-to-email/reply-to.md): Make Reply on a form notification write to the person who filled in the form: which field names work, when to use _replyto, and why replies go astray.
- [Without PHP](https://ikiform.com/guides/send-form-to-email/without-php.md): Replace a contact.php script that calls mail() with a form that posts to a hosted endpoint, keeping your field names, your thank-you page and replies.
- [To Gmail](https://ikiform.com/guides/send-form-to-email/gmail.md): Get form submissions in Gmail without mailto: links, app passwords or Apps Script, then label them, keep them out of spam and reply from Gmail.
