Send a form to your email

Send a form to your email6 min read

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.

Preet SutharFounder, Ikiform

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:

What a Server Action does when it sends the email, and when it forwards the postsend it yourselfforward ityour formuseActionStateyouractionvalidates,checks thehoneypotResend orSMTPyourverifieddomaininboxonlyno copy ifit's lostyour formuseActionStateyouractionadds thesecret keyIkiformvalidates,scores,storesinbox anddashboardpluswebhooksFormDataAPI keyFormDataJSONWhat a Server Action does when it sends the email, and when it forwards the postsend it yourselfforward ityour formuseActionStateyour actionvalidates, checks the honeypotResend or SMTPyour verified domaininbox onlyno copy if it's lostyour formuseActionStateyour actionadds the secret keyIkiformvalidates, scores, storesinbox and dashboardplus webhooksFormDataAPI keyFormDataJSON
  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.
JobServer Action + Resend or NodemailerServer Action + Ikiform
ValidationZod in the actionThe form's field definition, enforced on Ikiform's side
SpamYour honeypot; anything more is yours to addHoneypot, spam scoring and an optional human check
Rate limitsNone until you add a store such as RedisBuilt in, per visitor and per form
Sending domainVerify yours with SPF and DKIM recordsNone: mail comes from notifications@ikiform.com
Subject and designAnything you likeFixed: New submission: <form name>
Confirmation to the visitorOne more send callNot available; send it from your email tool
A copy of every messageOnly if you add a databaseDashboard, 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
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
"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: {} };
}
  • 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 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 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

ProblemWhat to do
Resend answers that the domain isn't verifiedAdd Resend's DNS records for the domain in from, or send from Resend's test address while you develop.
429 rate_limitedOne 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_failedThe 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 productionThe key is missing in the deployment's environment variables. Add it to the production environment and redeploy.

Questions

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.